The AI Agents certification taught you to build one agent well: connect it over MCP, ground it in live data, handle its errors, test it and fence it in. This course starts where that one ends. Your company now has a support assistant in three editors, a nightly enrichment job, a payout reviewer, and a customer-facing feature where each of your customers gets an agent that calls APIs on their behalf. Each was built sensibly. Together they are a mess: four ways of calling the same catalog, keys copied between them, nobody sure which agent spent last Tuesday's credits, and no single place where a rule such as "agents never fetch URLs outside our allow-list" can be enforced.
An architect's job is to pick the shapes deliberately. This lesson covers the three integration patterns, when each one fits, how to keep the tool surface small on purpose, how keys map to deployments, and what changes when the agents serve many tenants.
Three ways to put an API catalog behind a model
Every agent that uses APIVerve reaches it through one of three shapes. They differ less in what the model sees than in who runs the loop, who owns the tool definitions and where your rules can live.
| Hosted APIVerve MCP server | Your own MCP server | Direct function calling | |
|---|---|---|---|
| Who runs the loop | The MCP host (an editor, a desktop app, a chat client) | Any MCP host your users choose | Your backend code |
| Who owns tool definitions | APIVerve, generated from the live catalog | You | You |
| Where guards can live | The host, if it is yours; otherwise only key scoping | Inside your server, for every host | Inside your executor |
| What you see on failure | An isError result with a sentence, or a JSON-RPC error | Whatever your server reads from REST | Full HTTP status, body and headers |
| What you operate | Nothing | A service, its auth and its uptime | Your existing service |
The hosted server is https://api.apiverve.com/v1/mcp. It answers initialize, tools/list and tools/call, builds one tool per catalog endpoint, and forwards each tools/call to the ordinary REST endpoint with your credential, so a call over MCP returns exactly what the REST call returns:
curl "https://api.apiverve.com/v1/numbertowords?number=2048" \
-H "x-api-key: YOUR_API_KEY"Over MCP, this same object arrives as structuredContent. The tool layer adds nothing and removes nothing.
Four properties of the hosted server matter at platform scale, and all of them come from how it is built rather than from configuration you can change:
- The tool list is generated from the live catalog. A new endpoint is a new tool on the day it ships, and a reworded description is a reworded description for every connected agent. That is convenient for a person exploring in a chat window and a change-control problem for a production agent, which lesson 4 deals with.
- Input schemas do not forbid extra properties. They list properties and
required, but they do not setadditionalProperties: false. A model that invents an argument passes schema validation, the call goes through, and the API drops the unknown key silently. - Failures are text. A failed
tools/callcomes back as a normal result withisError: trueand a sentence such as Invalid request to NumbertoWords: {"missing":["number"]}. The HTTP status and the credit headers stay on the server's side of the hop. A tool name the server does not know is different again: it comes back as a JSON-RPC error object, not as anisErrorresult. - Discovery is free and calls are billed.
tools/listcosts nothing;tools/callcosts what the endpoint costs over REST, and each call has a 60-second limit.
None of these is a flaw. They are the properties of a server designed to plug straight into any client. They become constraints when you need status-aware error handling, strict argument checking or a stable tool contract, and that is what the other two shapes give you.
Your own MCP server sits in front of the REST API. It publishes tools you wrote, with names in your domain, strict schemas and composite tools that hold state. When a host calls one, your server validates the arguments, applies your policies, calls APIVerve over REST with the right key, and returns a result you shaped. Because MCP is the interface, every editor and chat client your teams use gets the same tools and the same rules.
Direct function calling skips the protocol. Your backend sends tool definitions to a model API, runs the loop, and executes each call itself, as you did in Build Agent Workflows. Nothing is reusable across hosts, but nothing sits between you and the response either.
When each pattern fits
The deciding question is not "which is more modern". It is who is in the loop, and do you own the host?
Use the hosted server when a person is driving and the host is not yours. A developer in Cursor, an analyst in a desktop chat app, a support engineer looking something up. The person reads every answer, the tools are lookups, and the cost of a wrong tool choice is a second question. Mount single-tool servers (/v1/mcp/<id>) for anything with a defined job, connect with OAuth so no key lands in a committed file, and let key scoping be the backstop.
Build your own MCP server when several hosts need the same curated toolset with your rules applied. The moment you want "the payout tools, with our argument checks, our budget and our audit log, available in every editor the finance team uses", the enforcement point has to be the server, because you do not control the hosts. Your server is the one place every call passes through.
Use direct function calling when one backend service owns the whole run. Unattended jobs, request handlers that run an agent for a user, anything with an SLA. You want the HTTP status to classify failures, the credit headers for accounting, cf-ray and x-cloud-trace-context for support tickets, and full control of retries and timeouts. A protocol hop adds latency and removes information you need.
Two hybrids are worth naming. The healthy one: your own MCP server whose tool handlers call APIVerve over REST, so you get MCP's reach on the outside and full HTTP information on the inside. The unhealthy one: your own MCP server that forwards to the hosted MCP server. It stacks two protocol hops, inherits the text-only failures, and makes your server a proxy with no information advantage. If you are writing a server anyway, call REST from it.
Here is the core of an MCP server of your own, stripped to the routing so the shape is visible. In production use an official MCP SDK for transport, sessions and streaming; the logic you own is the tools/call branch.
// Node 18+, ESM. The routing core of a tools-only MCP server.
// session = { tools: Map<name, { definition, run(args, ctx) }>, ctx } built per authenticated connection.
const VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
const toolError = (text) => ({ isError: true, content: [{ type: 'text', text }] });
export async function handleRpc(msg, session) {
switch (msg.method) {
case 'initialize': {
const asked = msg.params?.protocolVersion;
return ok(msg.id, {
protocolVersion: VERSIONS.includes(asked) ? asked : VERSIONS[0],
capabilities: { tools: {} },
serverInfo: { name: 'acme-agent-tools', version: '3.2.0' },
});
}
case 'tools/list':
return ok(msg.id, { tools: [...session.tools.values()].map((t) => t.definition) });
case 'tools/call': {
const tool = session.tools.get(msg.params?.name);
// Unknown tool as a readable result, so the model can recover instead of the client failing.
if (!tool) return ok(msg.id, toolError(`No tool named "${msg.params?.name}". Use one of: ${[...session.tools.keys()].join(', ')}.`));
return ok(msg.id, await tool.run(msg.params.arguments ?? {}, session.ctx));
}
default:
return { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `Method not found: ${msg.method}` } };
}
}Notice the one deliberate difference from the hosted server: an unknown tool name becomes an isError result the model can read, rather than a protocol error. When you own the server, you choose which failures the model gets to see.
A finance team wants the same three payout-review tools, with your argument checks and an audit log, available in Cursor, VS Code and a desktop chat app. Which pattern fits?
Limit the tool surface on purpose
Every tool you expose is context the model reads on every turn, a choice it can get wrong, and a capability an injected instruction can reach. The Associate courses made that point for one agent. On a platform it becomes a policy: each deployment gets an explicit list of tool ids, and nothing reaches it that is not on the list.
The hosted server gives you two levers. Single-tool paths expose exactly one tool, and an id the product does not carry is simply absent. Key scoping blocks endpoints, but it is a deny-list, so every endpoint that ships after you scoped the key is reachable by default. Neither lever pins the definition of a tool, and the definition is what the model reads.
So treat the tool surface like a dependency. Generate a manifest per deployment from the definitions you approved, store it in the repository, and hash it. Fetching the definitions costs nothing, because tools/list is free:
// Node 18+, ESM. Run: node snapshot-tools.mjs support-triage emailvalidator phonenumbervalidator
import { createHash } from 'node:crypto';
import { writeFileSync } from 'node:fs';
const [deployment, ...ids] = process.argv.slice(2);
const key = process.env.APIVERVE_API_KEY;
async function listTools(id) {
const res = await fetch(`https://api.apiverve.com/v1/mcp/${id}`, {
method: 'POST',
headers: { 'x-api-key': key, 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
signal: AbortSignal.timeout(15_000),
});
const msg = await res.json();
if (msg.error) throw new Error(`${id}: ${msg.error.message}`);
return msg.result.tools[0];
}
const tools = [];
for (const id of ids.sort()) tools.push({ id, definition: await listTools(id) });
const hash = createHash('sha256').update(JSON.stringify(tools)).digest('hex').slice(0, 16);
writeFileSync(`manifests/${deployment}.tools.json`, JSON.stringify({ deployment, hash, tools }, null, 2));
console.log(`${deployment}: ${tools.length} tools, definition hash ${hash}`);Your platform loads tools only from the manifest. A changed upstream description now shows up as a diff in a pull request rather than as a behaviour change nobody approved, and the hash becomes part of every trace so you can tell which tool contract a run used.
One scoped key per agent deployment
A deployment is one agent, in one environment, with one job: support-triage, production is a deployment; the same agent in staging is another. Each gets its own sub-key, and the reasons compound on a platform:
- Revocation is per deployment. Deleting a sub-key stops exactly its consumer, immediately; everything else keeps working.
- Rate limits are per key. Each sub-key has its own per-minute window, so a runaway batch agent exhausts its own bucket, not the one your interactive agents share. On Mega a sub-key can also carry a per-minute cap below the plan's ceiling.
- Restrictions are per key. A deployment's key can be blocked from everything outside its job, and a blocked call is refused with a
403before it runs and costs nothing. - Rotation can overlap. Sub-keys have no rotate action. You create the replacement, deploy it, watch the old key leave the call log, then delete it, with no window where calls fail.
Sub-keys carry a distinct prefix, apv_sbk, while primary keys start apv_ without it. That makes a useful assertion: the platform refuses to start an agent on anything that is not a sub-key.
// Node 18+, ESM. Keys come from your secret store, keyed by deployment id. Never from the model.
const KEYS = {
'support-triage:prod': process.env.APV_KEY_SUPPORT_TRIAGE_PROD,
'payout-review:prod': process.env.APV_KEY_PAYOUT_REVIEW_PROD,
};
export function keyFor(deploymentId) {
const key = KEYS[deploymentId];
if (!key) throw new Error(`No key configured for ${deploymentId}`);
if (!key.startsWith('apv_sbk')) throw new Error(`${deploymentId} must run on a sub-key, not a primary key`);
return key;
}Two limits shape the design. Sub-keys share the account's credits, so a sub-key cannot be given its own budget; spend limits are something your platform builds, which lesson 3 does. And key management is owner-only in a shared workspace, so creating and deleting deployment keys is a deliberate, audited act rather than something any admin does in passing. On Mega, the audit history records sub-key creation and deletion with who did it.
Multi-tenant agent platforms
When your customers each get an agent that calls APIs, two architectures are available, and they put the bill and the blast radius in different places.
Bring your own account. Each customer connects their own APIVerve account, typically through OAuth, which the hosted MCP server supports by default: an unauthenticated request gets a 401 with a WWW-Authenticate challenge, the client runs the flow, and calls carry the customer's own credential. Their usage bills to their plan and their keys are theirs to revoke. Your platform holds no API credentials for them. This fits developer tools whose users already have accounts.
Platform account, key per tenant. Your account pays, and each tenant's agent runs on its own sub-key. The documentation names this case directly: one key per customer means one customer's compromise is one customer's problem, and it tells you which of them generates the traffic. Each tenant gets its own rate-limit window, so one tenant's burst does not throttle the others, and its own restrictions, so a tenant on your basic tier can be blocked from the expensive endpoints.
The platform-account model has three consequences you must design for:
- Credits are shared. Every tenant spends the same pool, and when it is empty every key returns
429at the same moment. Per-tenant quotas therefore live in your ledger, enforced before each call. - Plans cap how many sub-keys you can hold. A key per tenant suits tens or hundreds of tenants. For thousands, group tenants into shards or tiers with one key each and do per-tenant metering yourself.
- Everything tenant-scoped must be keyed by tenant. Caches, stored tool outputs, approval queues and logs. A cache keyed only by arguments will happily return tenant A's lookup to tenant B.
curl "https://api.apiverve.com/v1/ibanvalidator?iban=GB82WEST12345698765432" \
-H "x-api-key: YOUR_API_KEY"A per-tenant payout agent would run this on that tenant's key. bankCode and accountNumber are premium fields and come back null on lower plans.
The most important rule is about where the tenant comes from. The tenant id, the deployment id and therefore the key are resolved from the authenticated session that started the run, never from anything the model produced. If a tool takes a tenantId argument, an injected instruction can ask for someone else's. This is the confused-deputy problem: your executor holds authority the model does not, and must not exercise it on the model's say-so.
// Node 18+, ESM. ctx is built by your auth layer when the run starts.
import { keyFor } from './keys.mjs';
export function makeExecutor(ctx, manifest, callRest) {
const key = keyFor(ctx.deploymentId); // from the session, not from arguments
const allowed = new Map(manifest.tools.map((t) => [t.definition.name, t]));
return async function execute(name, args) {
const tool = allowed.get(name);
if (!tool) return { isError: true, content: [{ type: 'text', text: `Tool ${name} is not available in this deployment.` }] };
if ('tenantId' in (args ?? {})) {
return { isError: true, content: [{ type: 'text', text: 'Tenant is fixed by the session and cannot be passed as an argument.' }] };
}
return callRest(tool.id, args, { key, tenantId: ctx.tenantId, runId: ctx.runId });
};
}Your platform runs one agent per customer on your own APIVerve account, with a sub-key per tenant. A large tenant must not be able to spend more than 50,000 credits a month. Where does that limit live?
Next
You have chosen where the loop runs, pinned what each deployment can reach and bound every call to the right key and tenant. Next, look inside the tools themselves: how granular they should be, how to check what a model sends before it costs anything, and how to return outputs and errors an agent can actually use: Design tools for agents at scale.