# How MCP schema drift breaks long-running agents

Long-running agents make one bad assumption very expensive: that the tool description loaded at session start remains true for the rest of the run. It often does not. A server can deploy a new build, enable an account-specific capability, rotate an upstream API, or correct a result contract while an agent is still planning from yesterday's tool shape.

That is MCP schema drift. It is not an exotic protocol edge case. It is a contract change between a client that has already formed a plan and a server that has moved on. If you test only a fresh connection after a deployment, you are testing the easy case and leaving the dangerous one untouched.

The Model Context Protocol gives servers a way to announce tool-list changes. It does not turn an old client cache into a new contract, repair tool calls already proposed by a model, or decide whether an old request remains safe. Those are engineering decisions. Make them deliberately, then test them while a session is alive.

## A tool description is part of the session state

A tool schema is executable context. An agent uses the name, description, input schema, annotations, and sometimes output schema to decide what action to request. Many clients also parse `tools/list` into local structures, compile validators, or place a compact tool description in model context. None of those copies change merely because the server deploys another version.

That matters even when the wire protocol is behaving exactly as designed. Suppose a client started with this tool:

```json
{
  "name": "deploy_preview",
  "description": "Deploy the current branch to a preview environment.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "branch": { "type": "string" }
    },
    "required": ["branch"],
    "additionalProperties": false
  }
}
```

An hour later, the server changes the operation so that it requires an explicit `region` field. A newly connected client sees the new schema and can supply it. The older client still believes that `{"branch":"fix-login"}` is a complete request.

There are three separate states here, and teams routinely blur them:

1. The **advertised schema** is what the server returns from `tools/list` now.
2. The **client schema snapshot** is what a particular client retained when it last listed tools.
3. The **execution contract** is what the server will accept and do when `tools/call` arrives.

A server can update the first state immediately. It cannot assume the second state has updated. It must choose how to handle the third.

Calling this a cache invalidation problem is incomplete. Cache invalidation sounds like stale display data. A tool schema can define destination accounts, write scope, confirmation fields, and result meanings. When an agent holds the old version, the mismatch can create failed work, repeated retries, or a request that now means more than the model intended.

The safest default is simple: keep a deployed tool's accepted input contract backward compatible for a transition window unless accepting old input would make the action unsafe. When safety and compatibility conflict, reject the old shape clearly and require a fresh decision.

## `tools/list_changed` announces change but does not synchronize it

The MCP specification defines `notifications/tools/list_changed` for servers whose tool list changes. The server sends a notification, and a client can refresh its tools with `tools/list`. The specification also models support for list-change notifications in the server's tool capability negotiated during initialization.

That is useful, but the word "can" does a lot of work. A notification has no response. The server cannot tell from the notification alone whether a client received it, whether the client refreshed, whether its tool cache updated, or whether a model has already drafted a call from the prior description.

Treat the notification as an invalidation signal, not a synchronization barrier.

A client that handles drift well should do four things after it receives the notification:

- Request `tools/list` again and replace the corresponding local definitions atomically.
- Preserve the old snapshot long enough to associate an already-planned call with the schema that produced it.
- Revalidate any queued call against the refreshed schema before sending it.
- Give the model a repairable error when a call built from old context can no longer run.

The third item is where clients often cut a corner. They refresh the visible tool list but allow an already queued call to leave with the old argument object. That creates a race: the user interface says one thing, the agent sends another, and the server has to clean up the mismatch.

Servers need a parallel discipline. When a tool changes, send the notification after the new `tools/list` response is ready. Do not announce a new contract and then leave an old process handling calls for an arbitrary interval. If your deployment topology makes that possible, put an explicit contract revision in the server response and reject calls that land on a worker with incompatible behavior.

The notification also does not solve clients that do not support it, disconnect during the event, or call through an intermediary that has its own cache. Compatibility at `tools/call` remains necessary. If the server only works after every client responds perfectly to a notification, it does not work in production.

## Input changes carry different kinds of breakage

Adding a field is not one category of change. The risk depends on whether the field changes validation, meaning, or authority.

Adding an optional display preference is usually safe. An older caller omits it, and the server chooses a stable default. Adding an optional filter can also be safe if omission returns the same result it returned before.

Adding a required `region` to `deploy_preview` is different. The old caller no longer satisfies the validator. You can either reject the request or supply a default. The first option interrupts the agent but states the truth. The second option is acceptable only if the default has always been the intended region for that repository and cannot redirect a deployment into a more sensitive environment.

Changing a field's meaning is worse than adding a required field. Consider a tool that originally accepts `project` as a human-readable project slug. The server later decides that `project` should be an opaque organization identifier. An older agent may still send `payments`, and the server may resolve that string in an unexpected namespace. Validation passes, the request succeeds, and the action is wrong. This is a semantic break, which is more dangerous than a clean validation error.

Removing an input field requires the same care. JSON Schema's `additionalProperties: false` makes the break visible. An old client sends a formerly valid argument and receives a failure. If the server silently ignores the removed field, the call may succeed with a different interpretation than the model expected.

The popular advice to "be liberal in what you accept" is poor advice for action tools. It became popular because it keeps integrations running through sloppy changes. For a read-only formatting preference, that tolerance may be harmless. For a credential-bearing HTTP request, SSH command, deploy, deletion, or payment action, permissive parsing turns an ambiguous request into server-side guesswork.

Use a compatibility adapter only when you can state its behavior precisely. For example:

```ts
function normalizeDeployArgs(raw: unknown) {
  if (!isPlainObject(raw)) {
    throw executionError("Expected an object for deploy_preview.");
  }

  if (typeof raw.branch !== "string" || raw.branch.length === 0) {
    throw executionError("The branch field must be a non-empty string.");
  }

  if (raw.region === undefined) {
    return { branch: raw.branch, region: "us-east-preview", schemaRevision: 1 };
  }

  if (raw.region !== "us-east-preview" && raw.region !== "eu-preview") {
    throw executionError("region must be us-east-preview or eu-preview.");
  }

  return { branch: raw.branch, region: raw.region, schemaRevision: 2 };
}
```

This adapter has an acceptable property: the old request produces the same preview destination it produced before. It would not be acceptable if `us-east-preview` were merely a convenient guess after a change in account ownership.

For an incompatible change, reject with an error that an agent can use. Say which tool revision the server expects, name the missing or obsolete field, and ask the client to refresh its tools. Do not return a vague message such as "invalid input." Models retry vague errors with minor variations. Clear errors create a chance of repair.

## Result-shape changes can poison the next decision

Teams pay attention to input validation because a bad request stops at the server. They give result changes less scrutiny because the action already completed. That is backwards for agents. The result is often the evidence that drives the next call.

Imagine an original `create_issue` result:

```json
{
  "issue": {
    "id": "I-482",
    "url": "https://tracker.example/issues/I-482",
    "state": "open"
  }
}
```

An agent may extract `issue.id`, save it in working memory, and later call `add_comment` with that identifier. If a revised server renames `id` to `issueId`, wraps the result under `data`, or changes `state` from a string to an object, the agent's next action can fail far from the original call. Worse, a text-only fallback might still contain a plausible sentence, so the model improvises an identifier from prose.

MCP tool results can include content for the model and structured content for programmatic use. If you publish an output schema, make structured output the authoritative machine contract. Keep the text concise and useful for a human reading a transcript, but do not expect clients to scrape it reliably.

The MCP schema work around JSON Schema 2020-12 is relevant here. The protocol's later schema guidance makes the dialect explicit, and output schemas can describe more than an object-shaped subset of JSON. That improves expressiveness, but it does not grant license to reshape a live result casually. A client may validate results with a specific draft, generated type, or decoder that has no tolerance for your new union or array form.

For result evolution, follow these rules:

1. Add fields before renaming or removing them.
2. Keep field meanings stable, especially identifiers, status values, and timestamps.
3. Put a `schema_revision` or `result_version` field in structured output when multiple interpretations must coexist.
4. Return a complete structured error object when execution fails, rather than changing a success result into an unstructured apology.
5. Remove the older shape only after you have ended old sessions or completed a published migration window.

A result revision field is not decoration. It lets a client distinguish "the server returned an incomplete old response" from "the server returned a new response whose optional field is absent." That distinction matters when an agent decides whether to retry, ask the user, or continue to a consequential action.

Do not turn every output into a versioned envelope just because you can. Put a revision marker where multiple independently deployed consumers need it. For a small private server and a single bundled client, an additive stable shape may be enough. For a tool shared by several agent hosts, workers, and plugins, explicit revision information saves days of guesswork during an incident.

## The failure worth testing is an old plan against a new server

A fresh client against a new server tells you that the new schema is valid. It says nothing about drift. The test you need has a client take a snapshot, let the server change, and then exercise calls that originate from the snapshot.

Build the test around two server fixtures. Fixture A advertises the old tool definition. Fixture B advertises the new definition and controls how it handles old arguments. The client stays connected through the handoff. If your server cannot change behavior without a restart, place a deterministic test switch behind the tool registry rather than trying to make your deployment system reproduce the timing.

This is the minimum useful transcript:

```text
1. Client initializes and receives tools.listChanged capability.
2. Client calls tools/list and stores deploy_preview revision 1.
3. Client prepares arguments: {"branch":"fix-login"}.
4. Server switches to revision 2, where region is required for new clients.
5. Server emits notifications/tools/list_changed.
6. Client sends the already prepared revision 1 call.
7. Client refreshes tools/list.
8. Client retries only if its repair policy permits it.
9. Client calls revision 2 with {"branch":"fix-login","region":"us-east-preview"}.
```

The test should inspect more than success or failure. Capture the actual JSON-RPC messages, the server's normalized arguments, external-effect stub calls, and the client's event log. A server that returns a neat error but already started an external deployment has failed the test.

Use a fake downstream service with an append-only request log. It should record method, path, headers that matter to authorization, request body, and a test correlation ID. Then assert that the stale call made no downstream request when it should have failed closed.

A compact table makes expected behavior reviewable:

| Drift event | Old client behavior | Server behavior | Downstream effect |
| --- | --- | --- | --- |
| Add optional `label` | Calls without `label` | Applies prior default | One expected request |
| Add required `region` with safe historical default | Calls without `region` | Normalizes to stable default | One expected request |
| Add required approval scope | Calls without scope | Returns repairable execution error | No request |
| Rename `project` semantics | Calls old `project` | Rejects as incompatible | No request |
| Add output field | Parses prior fields | Returns old fields plus new field | No extra action |
| Remove output identifier | Attempts next dependent call | Client stops and reports contract error | No dependent request |

Do not test only the happy retry. Agents are good at retries, and that is exactly why they can amplify a bad migration. Test repeated stale calls, a notification that arrives after a call has entered the queue, a refresh that fails, and a client reconnecting halfway through the transition.

The awkward case is an in-flight tool call. A server should execute each call against one coherent contract revision. Do not start validation under revision 1, reload configuration, then build the downstream request under revision 2. Snapshot the handler configuration at call admission. If the operation can take long enough that the target contract itself changes, expose a durable job or reject the call before the irreversible phase. Do not splice two revisions together in one action.

## Client repair logic needs a boundary around retries

When a server rejects a stale request, the client has several choices: refresh, ask the model to repair arguments, retry with a mapped request, or stop for user input. The correct choice depends on whether the change affects only syntax or the authority of the action.

Automatically refresh and retry only when all of these are true:

- The server explicitly identifies an obsolete schema revision or a missing field.
- The refreshed tool definition supplies an unambiguous, non-sensitive default or deterministic mapping.
- The original action remains within the same target and permission boundary.
- The first attempt produced no external side effect.

Anything else needs a new decision. If a revision adds `environment`, `account_id`, `repository`, `host`, `user`, or confirmation text, an automatic retry can broaden or redirect the action. Even if the model can infer a likely answer, it should obtain fresh context or human approval.

Keep request identity separate from retry identity. If a tool call may reach an external system before the client receives its response, the client must not blindly resend it after a schema refresh. Use an idempotency token where the downstream API supports one. For SSH, where a generic idempotency mechanism is not available, design commands so that repeat execution is either safe or detectable. A schema migration is a bad time to discover that a timeout creates duplicate work.

A good client error gives the model information without handing it a false instruction. For example:

```json
{
  "isError": true,
  "content": [
    {
      "type": "text",
      "text": "deploy_preview rejected this request because its input contract changed. Refresh tools before retrying. The current schema requires branch and region. No deployment was started."
    }
  ],
  "structuredContent": {
    "error_code": "STALE_TOOL_SCHEMA",
    "tool": "deploy_preview",
    "required_action": "refresh_tools",
    "side_effect_started": false,
    "current_revision": 2
  }
}
```

The exact error envelope is your design, but the facts are not optional. State whether an effect started. State whether a refresh can help. State the current revision if your server exposes revisions. A model can use those facts. A generic transport error cannot.

Do not mislabel a schema validation problem as a transport failure. The MCP specification distinguishes protocol-level failures from tool execution failures, and current guidance favors tool execution errors for invalid tool input so the model has a chance to self-correct. Use the distinction. An unknown JSON-RPC method is not the same event as a known tool rejecting stale arguments.

## Security review has to cover meaning, not only secrets

Schema drift becomes a security issue when an old description authorizes a newer action by implication. This happens through defaults, renamed fields, added scopes, and overly helpful server adapters.

Consider a tool originally called `run_report` with `{"team":"sales"}`. The server changes it to accept a `target` string that can name a team, a saved report, or a raw query. An old agent still sends `team`. If the adapter turns that into `target: "sales"`, what did it authorize? A team? A report named sales? A query alias? The server has created ambiguity at an action boundary. Reject it and publish a distinct tool or explicit migration path.

Credentials raise the stakes. An agent that holds API keys directly can mix schema repair with secret handling in its own process and logs. That gives a stale plan more room to cause harm. Sallyport keeps HTTP and SSH credentials in its encrypted vault and executes the action rather than handing the credential to the agent. That separation does not make a changed tool contract safe by itself, but it makes the actual request, authorization point, and audit record easier to inspect.

Approval must attach to the concrete action that will happen now, not a remembered tool description. If a tool changes from one host to a flexible host selector, per-session approval based on an earlier code identity is not enough for the new target. Require a fresh per-call approval for the sensitive action, or use a new tool name that makes the increased authority visible.

This is also where audit records earn their keep. Record the tool name, declared schema revision when available, raw arguments received, normalized arguments used, server build or handler revision, approval decision, and downstream target. Do not overwrite the raw arguments with normalized ones. During an incident, you need to see whether the client sent an old shape, whether the adapter changed it, and whether the external request matched the adapter's promise.

Sallyport's activity journal and sessions journal are useful examples of separating an agent run from individual external calls. For drift tests, you want both views: one record that the run was authorized and another record for each HTTP or SSH action that did or did not leave the machine.

## Use compatibility windows, then remove them on purpose

Backward compatibility should have an end date, even if the end date is tied to a release train or session lifetime rather than a calendar. Otherwise every obsolete input mapper remains forever, and the server becomes a museum of assumptions no one can safely edit.

Start by classifying the change.

An additive change keeps the old call valid and preserves its meaning. Keep the same tool name, announce the list change, and accept both shapes while active sessions drain.

A constrained migration changes syntax but has a safe deterministic mapping. Keep the tool name only if you can test the mapper exhaustively and log when it is used. Tell new clients the current schema; accept old input only for the short window.

A semantic or authority change needs a new tool name. `deploy_preview` and `deploy_environment` may share implementation, but they should not share a contract if one selects a known preview destination and the other can select production. This may feel verbose in a tool list. It is still less costly than having an agent believe it called the narrower operation.

A removal should fail plainly. Return an execution error that names the replacement tool or says the capability is gone. Do not retain a tool name that does nothing. Silent success is poison for automated work because the agent records completion while the intended effect never happened.

Use telemetry to decide when to remove an adapter, but do not collect only aggregate success counts. Count calls normalized from each old revision, rejected stale calls, automatic client repairs, and calls that required human intervention. A low volume of old input may still matter if those calls come from the longest-running or most privileged agent jobs.

Before removal, run your drift test in reverse: start a client on the old version, update the server past the compatibility window, and confirm that the failure is clear, side-effect free, and recoverable through a reconnect or tool refresh. A clean break is better than a quiet reinterpretation.

## A release gate that catches drift before users do

Put schema drift in the release gate for every action-capable MCP server. It does not need a giant matrix on day one. It needs one disciplined fixture for each contract change class.

For every changed tool, answer these questions in the pull request or release review:

1. Can an existing session still submit the previous valid arguments?
2. If yes, do those arguments retain exactly the prior action meaning?
3. If no, does rejection happen before any external effect?
4. Does the server emit `notifications/tools/list_changed` only after the replacement list is available?
5. Can the client explain the repair path without inventing missing authority?

Then make the answers executable. Store old and new `tools/list` fixtures beside the test. Run the sequence with an old client snapshot. Validate downstream requests, not just MCP responses. Keep a regression fixture after the migration ships, because the next refactor may remove a compatibility branch without anyone remembering why it existed.

The first test worth adding is brutally small: list a tool, change one required parameter, issue the old call, and prove that the server either preserves the old safe behavior or performs nothing. That test forces the question most deployments avoid: what exactly does an already-running agent have permission to mean after you change the tool beneath it?
