A server that answers is not a server that survives

Getting a Model Context Protocol server to answer its first tools/call takes an afternoon. Copy a schema from a working example, wire a single function to a stdio transport, and a model is calling it within the hour. Getting that same server to survive a week of real traffic from a real agent is a longer, different project, and almost none of the extra work is about the function itself. It is about the edges: what the server does with a request it cannot parse, what it does when the same request arrives twice, what it does when a client built against last month’s schema shows up today, and how anyone finds out any of this actually works before a user does.

MCP’s own description of itself is modest by design. It is a standardized way for applications to share contextual information with language models, expose tools and capabilities to AI systems, and build composable integrations and workflows, built on JSON-RPC 2.0 messages passed between hosts, clients, and servers, with a base protocol characterised as stateless, self-contained requests under per-request capability negotiation [3]. That description is accurate and nearly useless for deciding how to build one, because everything the protocol leaves open — the shape of the schema, what happens on a repeat, what an error actually says, how a tool’s contract changes over time, how any of it gets proven correct before it ships — is exactly where a server goes from answering to production-grade.

This article works through five of those decisions in the order a server author actually meets them: schema, retries, errors, versioning, testing. Each recommendation is grounded in the current specification, the reference tooling built around it, or a documented production system solving the same problem at a larger scale.

ADVERTISEMENT

Write the schema so the model cannot guess wrong

A tool’s inputSchema is not documentation of what the function accepts. It is the only channel through which a model learns what the function accepts, and the specification treats it as load-bearing rather than advisory: it “MUST be a valid JSON Schema object (not null),” defaults to the 2020-12 dialect when no $schema field is present, and where a server declares an outputSchema it “MUST provide structured results that conform to this schema” while clients “SHOULD validate structured results against this schema” [1]. Two enforceable directions of checking, one on the way in and one on the way out — and both mechanical, which is exactly what makes them worth using. A validator either accepts an argument set or it does not; it never has to guess at intent the way a model reading a vague description does.

Tool names carry their own small, specific rules that are easy to violate by accident: they should run one to a hundred and twenty-eight characters, be treated as case-sensitive, draw only from letters, digits, underscore, hyphen and dot, and be unique within a server — and the specification is explicit that a server’s own name field “is not guaranteed to be unique across servers and SHOULD NOT be relied upon for disambiguation” by anything aggregating multiple servers’ tools [1]. A tool set assembled by a gateway or a multi-server client can and does collide on a name like search; a server author who wants predictable behaviour downstream should assume the name alone will not stay unique forever.

Anthropic’s own engineering guidance for building tools that agents call correctly is worth reading as a companion document to the schema rules, because it is about the layer the schema cannot enforce: naming and description choices that remove ambiguity a validator cannot catch. Its central recommendation on parameter naming is concrete — “instead of a parameter named user, try a parameter named user_id” — because a generic name invites a generic, wrong guess about what value belongs there [4]. The same source recommends resolving opaque identifiers into meaningful names in responses, since models “grapple with natural language names… significantly more successfully than they do with cryptic identifiers,” and it treats even something as small as prefix- versus suffix-based tool namespacing as an empirical question with “non-trivial effects” on measured performance rather than a matter of taste [4]. None of that is enforceable by a schema validator. All of it changes whether the model that reads the schema calls the tool the way its author intended.

A benchtop protocol analyzer with a row of probe leads clipped onto test pads on a small board, one lead caught just short of its pad while the analyzer's channel lamps show the rest already lit steady
Figure 1. A schema is checked field by field before a call is allowed to run; the one property still unclipped is exactly the one a vague description would have let through.Image prompt and art direction by Brecht Corbeel; image generated to that direction.

One schema feature deserves a caution rather than a recommendation. MCP allows a property to carry an x-mcp-header annotation that mirrors its value into an HTTP header on the Streamable HTTP transport, so intermediaries can route on it without parsing the body — and the specification pairs that capability with an explicit warning that developers “SHOULD NOT mark sensitive parameters (passwords, API keys, tokens, PII)” this way, because header values are visible to network intermediaries in a way body fields are not [1]. A schema is a contract offered to a caller that cannot ask a follow-up question mid-call; every field in it should be designed with that same question in mind — not only “is this unambiguous,” but “where does this value travel once it leaves the request.”

Make repeats safe before you make them impossible

A stateless, self-contained request protocol has an unavoidable consequence: a caller that sends a request and does not get a response back cannot tell whether the request never arrived, arrived and executed with the response lost in transit, or executed only halfway. Nothing in the base MCP specification closes that gap, because it is not a protocol-level problem — it is what falls out of building on request/response messaging at all. What a server author controls is what happens when the caller, unable to tell the difference, does the only reasonable thing and sends the request again.

ADVERTISEMENT

Stripe’s production API answers this with a mechanism worth copying directly rather than reinventing. A client generates an idempotency key — Stripe suggests a V4 UUID or another string with enough entropy to avoid collisions — and attaches it to a POST request; the server then saves “the resulting status code and body of the first request made for any given idempotency key, regardless of whether it succeeds or fails,” so that “subsequent requests with the same key return the same result, including 500 errors” [5]. Three details in that design carry the most weight for anyone implementing the pattern on top of an MCP tool. First, the comparison is on parameters as well as key: “the idempotency layer compares incoming parameters to those of the original request and errors if they’re not the same,” which stops a key from being silently reused for a different call [5]. Second, results are saved “only after the execution of an endpoint begins,” so a request that fails validation before anything runs is not cached and can simply be retried [5]. Third, the key has a bounded lifetime — Stripe prunes keys after roughly twenty-four hours — so the deduplication table does not grow without limit [5]. A tools/call argument named plainly, something like idempotency_key, checked against exactly this discipline before a tool’s side effect runs, gives a caller — model or harness — a mechanism to retry safely that the protocol itself does not supply.

MCP’s own non-normative guidance for stateful tools solves an adjacent but different problem: not deduplicating a single call, but carrying state — a shopping basket, an open transaction — across several calls in a protocol with “no protocol-level session,” where “a server cannot rely on implicit per-connection state to relate one tool call to the next” [1]. Its answer is an explicit handle returned by a creation tool and passed back on later calls, and the design notes attached to that pattern generalise well beyond baskets. Handles should be opaque, since identifiers that “encode internal structure invite parsing or guessing”; for an unauthenticated server a handle is necessarily a bearer token and should be generated “with sufficient entropy,” while for an authenticated one “a handle is a name, not a capability” and authorization must be checked against it on every call, not only at creation; and an expired or unknown handle “should return a tool execution error that says so, so the model can recover by creating a new one” [1]. A durable identifier of either kind — an idempotency key or a state handle — converts an unanswerable question, did this run, into an answerable one: what is the current state of this key.

A benchtop ticket printer and tray in the workshop, a freshly printed ticket caught rising from the slot while an identical ticket already sits in the tray below, a mechanical stop bar blocking the new one from dropping in beside it
Figure 2. The server does not re-run a call it has already answered; it recognises the ticket it has already seen and hands back what the first one earned.Image prompt and art direction by Brecht Corbeel; image generated to that direction.

Server-side deduplication only pays off if the retry that triggers it is well behaved, and naive retry timing defeats itself. If every failed client retries after exactly the same fixed delay, the retries arrive in synchronized waves and can overload a server that was only briefly struggling. AWS’s widely adopted answer is to randomise the delay rather than only grow it, and the simplest of the algorithms it documents, full jitter, is stated as

tn=random ⁣(0, min ⁣(tmax, tbase2n)), t_n = \mathrm{random}\!\left(0,\ \min\!\left(t_{\max},\ t_{\mathrm{base}} \cdot 2^{n}\right)\right),

drawing the wait before attempt nn uniformly between zero and a capped exponential ceiling, rather than sleeping for the ceiling itself [8]. The reasoning given is about contention, not just delay: without jitter, “N clients compete in the first round, N-1 in the second round” and so on, and even adding plain exponential backoff on top just moves the pile-up rather than removing it, since “there are still clusters of calls” — spikes at each retry boundary instead of one continuous overload [8]. Full jitter spreads those clusters into “an approximately constant rate of calls,” which is the property that actually protects a server under a retry storm [8]. The assumption this formula exposes is worth stating plainly: idempotency keys make a retry safe to execute twice; jitter is what keeps a wave of safe retries from arriving as a second incident in its own right.

An error is a diagnosis, not a wall

MCP splits failure into two channels with different destinations and different purposes, and the split is functional rather than stylistic. Protocol errors — an unknown tool, a malformed request, a server-side failure — are “issues with the request structure itself that models are less likely to be able to fix,” and they are returned as ordinary JSON-RPC errors, for instance a -32602 response reading "Unknown tool: invalid_tool_name" [1]. Tool execution errors — an upstream API failure, a validation failure, a business-logic rule — carry “actionable feedback that language models can use to self-correct and retry with adjusted parameters,” and are returned inside an ordinary successful result with isError: true [1]. The specification’s own worked example is the whole design lesson compressed into one sentence: rather than a bare rejection, the returned text reads “Invalid departure date: must be in the future. Current date is 08/08/2025” [1]. It names the rule that was broken and the fact that would let the caller satisfy it. Clients, correspondingly, “SHOULD provide tool execution errors to language models to enable self-correction,” while protocol errors “MAY” be surfaced but are flagged as less likely to lead anywhere useful [1].

Underneath that split sits the older JSON-RPC error object, and it is worth knowing precisely because MCP builds directly on it rather than replacing it: an error carries an integer code, a message that “SHOULD be limited to a concise single sentence,” and an optional data field for structured detail, with a defined range of pre-assigned codes — -32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal error — plus a reserved band from -32000 to -32099 set aside for implementation-defined server errors [6]. A server author choosing which channel to use for a given failure is, underneath, choosing between this fixed, narrow vocabulary and the open content and structuredContent fields of a tool result — and the fixed vocabulary is the wrong place for anything domain-specific, because a model has no way to learn what a project’s own -32005 means beyond what happens to be in the message string.

ADVERTISEMENT
A panel-mounted dial gauge on the workshop bench with its needle resting just outside a marked tolerance band, a small mechanical flag beside it caught swinging up to indicate the reading, wired into a fault-injection appliance behind it
Figure 3. A useful error names the rule that was broken and where the value actually sits against it, so the next call can be aimed rather than guessed.Image prompt and art direction by Brecht Corbeel; image generated to that direction.

Google’s API design guidance, developed independently and at a much larger scale across thousands of internal and public services, converges on the same instinct and pushes it one step further into structure. Its standard error model requires that “all error responses must include an ErrorInfo within details,” giving each error a machine-readable reason in fixed UPPER_SNAKE_CASE form alongside a domain, so that “users can write code against specific aspects of the error” rather than parsing the human-readable message [9]. Request-specific values that produced the error belong in a separate metadata field rather than only in prose [9]. That structure maps almost directly onto what an MCP tool result already has available: an isError: true result’s human-readable text can stay exactly as prescriptive as the departure-date example, while its structuredContent — validated, if declared, against the tool’s own outputSchema [1] — can carry a stable reason string and the offending field and value as data. A calling agent, or the harness wrapping it, then has two independent ways to recover: read the sentence, or pattern-match on the code.

Change the tool surface without breaking the callers who already trust it

MCP’s current protocol revision handles its own versioning with a deliberately simple mechanism: there is no negotiation handshake at all. Every request declares the protocol version it is using, and a server “MUST respond with an UnsupportedProtocolVersionError” — carrying a code of -32022 and a data.supported list of the versions it actually accepts — whenever the requested version is one it does not implement, whether unknown or merely unsupported by choice; the client is then expected to “select a mutually supported version from the supported list and retry the request” [2]. A server that needs to serve both older, handshake-based clients and newer, per-request-versioned ones is explicitly permitted to run both behaviours at once: “a dual-era server MAY serve both eras concurrently on the same endpoint or process” [2]. That is a live-migration technique stated directly in the specification — old and new callers served from the same process during a transition, rather than a hard cutover — and it is the pattern worth borrowing even where the change in question is not the wire protocol at all.

Two rows of rack-mounted test servers in the workshop, an older row on one side and a newer row on the other, a single patch cord caught mid-transfer with one end still seated in the older row and the other end reaching toward an open port in the newer row
Figure 4. A version boundary is crossed by callers one at a time; the server that answers both rows at once is what keeps the crossing from becoming an outage.Image prompt and art direction by Brecht Corbeel; image generated to that direction.

Most of the breaking changes a server author actually makes are to a single tool’s shape, not to the protocol underneath it, and MCP gives a tool no independent version field to signal that — a tool’s identity on the wire is just its name string. Semantic Versioning’s discipline, designed for exactly this class of compatibility promise, still transfers cleanly onto that shape: “MAJOR version when you make incompatible API changes,” “MINOR version when you add functionality in a backward compatible manner,” “PATCH version when you make backward compatible bug fixes” [7]. Adding an optional property to inputSchema, or adding a field to a result already validated only loosely, is minor-safe — existing callers that never look at the new field keep working unchanged. Renaming a tool, promoting an optional argument to required, or reshaping a result that clients already parse is a major change in every meaningful sense, and because there is no version field to bump, that change has to be expressed the only way the protocol actually supports: a new tool name, published and listed alongside the old one for a deprecation window, rather than an in-place rewrite of what an existing name means. The old tool keeps answering exactly as it always did; the new one answers under its own name; and the caller crosses the boundary once, deliberately, the way a client crosses a protocol-version boundary rather than having the ground shift under a name it already trusted.

Prove it against the caller that will actually call it

A unit test that drives a server with a hand-rolled JSON-RPC client proves the server obeys its own schema. It proves nothing about whether a model calls that server correctly, because the failure modes that matter in production — an ambiguous parameter name, a tool description that reads differently out of context, a namespacing choice that collides with another server’s tools — are properties of the interaction between the schema and a model reading it, not properties the schema alone can fail on.

The reference tool for closing that gap is MCP Inspector, described as “the reference developer tool for testing and debugging MCP servers,” shipped as a single package that puts three interfaces behind one binary: a full graphical web client, an interactive terminal client, and — the one that matters for a production pipeline — “a scriptable, machine-readable client for CI, shell pipelines, and coding agents,” invoked as --cli and able to list a server’s tools or call one and pipe the structured result into something like jq [10]. That CLI mode is what turns “does this server behave” from a manual, occasional check into an assertion a build can run on every change.

A mechanical trial counter mounted on the workshop bench beside a scripted test-sequencer appliance, its digit wheel caught mid-turn between two numbers while a row of small pass lamps above it holds mostly lit
Figure 5. Shipping a tool means running it many times against the kind of caller it will actually face, and counting how often the outcome, not just the reply, comes out right.Image prompt and art direction by Brecht Corbeel; image generated to that direction.

What to run through that harness is the harder half of the question, and Anthropic’s own evaluation guidance is specific about the shape of a test that actually finds problems: build “realistic, multi-step tasks” grounded in actual workflows, where strong evaluations “might require multiple tool calls — potentially dozens” rather than a single isolated invocation, and track more than pass or fail — “runtime, tool call counts, token consumption, and error rates” all reveal patterns a bare accuracy number hides [4]. A model choosing the wrong tool, retrying unnecessarily, or padding its call count under an ambiguous description will often still land on a correct final answer; a metric that only checks the answer will not catch any of that.

There is a statistical floor underneath all of this worth making explicit, because it exposes an assumption a five-run smoke test quietly violates. A model’s behaviour on a given prompt is not deterministic across runs, so a single trial is one draw from a Bernoulli process with some true success probability pp, and the uncertainty in an observed rate p^\hat{p} from nn independent trials shrinks only as n\sqrt{n}. To bound that uncertainty to a half-width ee at a chosen confidence level zz, the standard requirement is

n    z2p(1p)e2, n \;\geq\; \frac{z^{2}\, p(1-p)}{e^{2}},

which, for a rate near one half and a 95% confidence interval within five percentage points, already calls for several hundred independent trials rather than five or ten. The practical conclusion is not that every team needs a statistics department before shipping a tool; it is that “it passed when I tried it a few times” and “it passes at a known, bounded rate” are different claims, and only a CI-integrated harness run at real scale can make the second one.

Predictions, and what would falsify them

These are forecasts, kept separate from the sourced analysis above. Horizon: 12 August 2028. Assumption throughout: MCP or a close successor remains the dominant open tool-calling interface, and production usage continues to grow rather than being displaced by closed, single-vendor tool formats.

One. A caller-supplied idempotency mechanism, deduplicated server-side, will be formalised as a documented convention or extension for MCP tool calls, because server authors are already re-implementing Stripe’s pattern ad hoc rather than inventing something worse. Disconfirmed if by the horizon the specification and its extensions still carry no standard idempotency-key convention and public servers show no converging pattern either.

Two. Structured, machine-readable reason codes inside structuredContent on isError results will become common practice among widely used MCP servers, rather than error information living only in the human-readable text block. Disconfirmed if a survey of popular public MCP servers at the horizon still shows free-text-only error content as the dominant pattern.

Three. Explicit versioned tool names or an equivalent deprecation-window convention will converge into a documented house style across major MCP server registries, echoing how SemVer converged for package ecosystems. Disconfirmed if major registries at the horizon still show ad hoc, inconsistent tool-renaming practice with no shared convention.

Four. CI-integrated, statistically powered tool-calling test suites — hundreds of trials, not a handful of manual spot checks — will become a stated expectation in MCP server publishing guidance. Disconfirmed if the leading publishing guidelines at the horizon still recommend only manual Inspector checks with no sample-size guidance.

None of these requires a change in the underlying model technology. Each follows from constraints already visible in the specification and the production systems that already solve pieces of the same problem.

What to take away

A production-grade MCP server is not distinguished from a demo by anything in its core function — it is distinguished by what happens at five edges the protocol leaves open. A schema should resolve every ambiguity a validator can catch, and a description should resolve the ones it cannot, because the caller reading it cannot ask a follow-up question mid-call. A retried call should be safe because a key makes it idempotent and jitter keeps the retries themselves from becoming an incident, not because the operation happens to be harmless to repeat. An error should name the constraint that was broken and the value that would satisfy it, in a form a program can match on as well as a form a person can read. A breaking change to a tool’s shape should arrive under a new name, answered alongside the old one for a deprecation window, the same way the protocol itself now crosses a version boundary — one caller at a time, never underfoot. And none of the first four claims should be trusted until a harness built for exactly this kind of scriptable, repeated, adversarial checking has run the tool enough times to say so with a number attached, not an impression.