Ship an MCP Server That Survives Real Agent Traffic
The OAuth 2.1 audience validation, tool-schema, and rate-limit decisions that separate a demo MCP server from one you trust with real agent traffic.
An MCP server is easy to demo and surprisingly hard to run. You wire up a few tools, point Claude or another agent at the endpoint, and within an hour it is calling your functions and returning data. That demo proves nothing about whether the thing survives contact with real agent traffic: tokens that belong to a different service, an agent that calls your delete tool with a hallucinated argument, ten parallel sessions hammering an unbounded list endpoint. The gap between the demo and the production server is entirely in the decisions you make about auth, validation, and rate limiting.
The Model Context Protocol has matured fast. The authorization model in the November 2025 specification is now a real OAuth 2.1 profile with specific MUST requirements, not a hand-wave. If you are exposing tools to agents over HTTP, those requirements are the difference between a server you can trust and one that quietly becomes a confused deputy. Here is the checklist we work through before an MCP server goes anywhere near production.
Auth: your server is an OAuth resource server now
The first mental shift is that an HTTP MCP server is classified as an OAuth 2.1 resource server. It does not issue tokens. It accepts them, validates them, and serves protected resources. The authorization server is a separate concern, hosted by you or a provider. Your job is to validate correctly, and the spec is blunt about what correct means.
Validate the audience, every time
The single most important rule, and the one that turns a leak into a breach when ignored, is audience validation. Your server MUST verify that an access token was issued specifically for it. A token minted for some other service in your stack is not a token for your MCP server, and accepting it breaks a fundamental OAuth boundary. This is the same property that makes a JWT attackers cannot forge or replay hold: you verify the audience claim and reject anything not bound to you. The spec spells out the attack: if you accept tokens with the wrong audience, an attacker can replay a legitimate token issued elsewhere against your tools.
// Reject anything that is not for us, before any tool logic runs.
if (!token.aud.includes(MCP_SERVER_RESOURCE_URI)) {
return reply(401);
}
Tied to this is the resource indicator. MCP clients MUST send the resource parameter (RFC 8707) in authorization and token requests, naming the canonical URI of your server, so the authorization server can bind the token's audience to you. You enforce the other half: reject any token that does not list you in the audience claim.
Never pass the client's token downstream
When your MCP server calls an upstream API, it acts as an OAuth client to that API with its own separate token. You MUST NOT forward the token the agent gave you to a downstream service. Token passthrough is explicitly forbidden because it creates the confused deputy problem: the downstream API trusts the token as if your server vetted it, when in fact you just relayed whatever you were handed. Mint a fresh token for each upstream, scoped to that upstream, and keep the inbound and outbound credentials strictly separate.
The mechanics that the spec requires
The same boundary discipline that keeps a scoped API token from touching everything when it leaks applies to the credentials your MCP server holds: each upstream gets its own narrowly scoped token, nothing shared, nothing over-privileged.
A few more MUSTs worth pinning to the wall:
- Implement OAuth 2.0 Protected Resource Metadata (RFC 9728) so clients can discover your authorization server, either via a
WWW-Authenticateheader on 401 responses or a well-known URI. - Require PKCE with the S256 method. Clients must verify PKCE support from authorization-server metadata and refuse to proceed without it.
- Serve every authorization endpoint over HTTPS. Redirect URIs must be HTTPS or localhost.
- Issue short-lived access tokens and rotate refresh tokens for public clients, so a leaked token has a small blast radius, the same reason you rotate production secrets without taking the app down.
- Return the right status codes: 401 for missing or invalid tokens, 403 for insufficient scope (with a
WWW-Authenticateheader naming the scopes needed), 400 for malformed requests.
None of this is exotic. It is standard OAuth 2.1 hygiene applied to a new kind of client. The reason it gets skipped is that the demo works without it, and the demo is the thing people show. The same discipline we apply to authentication on every web application we build applies here, with the twist that the client is an autonomous agent rather than a human at a keyboard, and often one of several agents that have to hand off to each other without losing control of the flow.
Tool schemas: the contract the agent reasons over
Auth decides who can call your tools. The tool schema decides whether the agent can call them correctly. An MCP tool's inputSchema is a JSON Schema object describing the arguments, and it is the only contract the model reasons over. Get it loose and the agent fills the gaps with plausible-looking inputs it invented.
Constrain in the schema, not in the prose
A weak tool definition relies on the free-text description to tell the model what is valid: "the status should be one of active, paused, or closed." A strong one encodes that as enum: ["active", "paused", "closed"] so an invalid value is rejected before your code runs. Use the JSON Schema keywords that do real work: type, enum, minimum, maximum, pattern, required. Typing every argument is the first line of defense against hallucinated inputs, because the schema is enforced where the model cannot talk its way around it.
The most common production bug here is schema drift: the server treats a field as required but the schema does not list it in required, so the agent omits it and your handler throws on a missing argument. The schema and the implementation must agree exactly. If your code needs it, the schema declares it required. If the schema permits it, your code handles it.
Keep schemas flat and tools narrow
Deeply nested schemas read fine to a developer and badly to a model. Every level of nesting adds tokens and cognitive load, which raises latency and the chance of a malformed call. Keep the shape as flat as the domain allows. Prefer several narrow tools with clear names over one mega-tool with a mode switch and twelve conditional fields, because the agent picks the right narrow tool more reliably than it navigates a branchy one.
And validate at the boundary regardless of the schema. The schema guides the model, but a misbehaving or malicious client can send whatever it likes over the wire. Re-validate every argument server-side with the same strictness you would apply to any public API, because for all practical purposes an MCP endpoint is a public API with a very creative caller. This is also where you force LLM output into schemas your code can actually trust: never let a model-shaped argument reach a handler without passing the same parser you would use on untrusted form input.
Rate limiting and resource bounds
The protocol does not specify rate limiting, which means it is yours to add at the infrastructure layer, and you do need it. An agent in a loop can call a tool hundreds of times in a few seconds. A single unbounded list tool that returns every row in a table will happily try to serialize a million records into one response and fall over.
Two defenses, both non-negotiable:
- Per-identity rate limits. Track calls per token or per session in a sliding window and reject over the threshold with a clear error the agent can back off from. This protects you from runaway loops and from one tenant starving another. The same sliding-window approach that locks out credential stuffers works here, just keyed on identity instead of IP.
- Bounded results. Every list or search tool takes a limit and a cursor, defaults to a sane page size, and never returns an unbounded set. Keyset pagination over offset on large tables, so page five hundred costs the same as page one.
Bound the inputs too. Cap string lengths, array sizes, and any field that fans out into work. An agent that sends a 50,000-character argument should hit a clean rejection, not a memory spike.
Observability and the destructive-tool question
When an agent misbehaves, you want to know exactly which tool it called, with which arguments, and what came back. Log every tool invocation with a correlation ID, the resolved identity, the arguments (with secrets redacted the way you would mask PII in public API responses), and the outcome. This is the MCP-server side of seeing exactly what your agent did when it goes off the rails: without spans and correlation IDs, debugging an agent's behavior is guesswork, because you cannot ask the agent what it was thinking.
The hardest design question is what to do about tools that can cause irreversible damage. An agent will, eventually, call your delete tool or your send tool with an argument it invented from context, especially once it is exposed to prompt injection from untrusted content that tries to steer it toward exactly that. We learned this lesson building LadenX, our AI site-reliability engineer, which classifies every command it considers and refuses destructive ones without a human signing off, the same guardrails autonomous fixes need before they touch production. The same principle belongs in any MCP server that exposes a tool with teeth. Separate read tools from write tools, gate the destructive ones behind an explicit confirmation step or a higher scope, and never let a single hallucinated argument trigger something you cannot undo. The agent is powerful precisely because it acts on its own, which is exactly why the dangerous actions need a check the agent cannot satisfy alone.
The short list
If you do nothing else before shipping an MCP server, do these five things. Validate the token audience and reject anything not issued for you. Never forward the client's token downstream. Constrain every tool argument in the schema and re-validate it server-side. Rate limit per identity and bound every result set. Gate the destructive tools behind a human. The demo works without all of it. The production server does not survive without it.






