Agent Native
Back to Archive

13 min read

MCP 2026-07-28 Migration Guide: Stateless Core, Tasks Extension, and What Breaks in Production

•August 25, 2026

The July 28, 2026 revision of the Model Context Protocol is the first one that changes the shape of a deployment rather than the shape of a message.

Sessions are gone from the protocol layer. The initialize handshake is gone. Every request now carries what the server needs to answer it, so any request can land on any instance. Long-running work moved into an extension. Three capabilities most servers never implemented well are deprecated.

If your MCP servers were demo glue, none of this hurts. If they are in the request path of a product, several of your assumptions just expired. This is the guide I wanted when I started migrating ours.

TL;DR

Effort: A day for a simple server, a sprint if you leaned on session state
  • Sessions and the initialize exchange are retired. Requests are self-describing and any server instance can answer any request.
  • Gateways route on two new headers, Mcp-Method and Mcp-Name, so they stop parsing JSON bodies.
  • Server-initiated elicitation and sampling are replaced by multi round-trip requests: the server answers input_required and the client retries with the inputs.
  • List responses carry ttlMs and cacheScope. Long-running work is now the io.modelcontextprotocol/tasks extension, polled with tasks/get.
  • Dynamic Client Registration is deprecated in favour of Client ID Metadata Documents. Roots, Sampling, Logging and the legacy HTTP+SSE transport have a 12-month window.

What actually changed

The release notes name each change by its proposal number. The ones that matter for production are these.

Stateless core. The initialize and initialized exchange and the Mcp-Session-Id header are retired. Each request now carries the protocol version, the client identity and the client capabilities in _meta. The stated goal is that a request can land on any server instance behind a plain round-robin load balancer with no shared storage.

Server discovery. A new server/discover call lets a client ask a server what it offers before doing anything else. It is optional. A client can send a tools/call cold and the server has to cope.

Header routing. HTTP requests must carry Mcp-Method and Mcp-Name. A gateway, a rate limiter or a WAF can route and meter on those headers instead of opening the JSON body.

Multi round-trip requests. The server-initiated flows that needed a held-open stream, elicitation/create, sampling/createMessage and roots/list, are replaced by a request that returns resultType: "input_required" with the inputs it wants. The client answers by retrying the same request with inputResponses filled in.

Cacheable lists. tools/list, prompts/list, resources/list and resources/read responses now carry ttlMs and cacheScope, so a client can stop re-listing on every turn.

Tasks as an extension. Tasks leave experimental status and become io.modelcontextprotocol/tasks, polled with tasks/get and updated with tasks/update. Change notifications collapse into one subscriptions/listen stream.

Authorization hardening. Authorization servers must return iss and clients must validate it before redeeming a code. Client credentials are bound to the authorization server that issued them. Client ID Metadata Documents become the standard registration path; Dynamic Client Registration still works but is formally deprecated.

Deprecations with a 12-month minimum window. Roots, Sampling and Logging capabilities, the legacy HTTP+SSE transport, and DCR.

All four tier-one SDKs, TypeScript, Python, Go and C#, support the revision. Rust is in beta.

The production breakage map

Reading the changelog tells you what moved. It does not tell you what breaks. This is what broke for us and for the teams I compared notes with, in the order it surfaced.

What breaks when you upgrade a server that was in production

Server keeps per-session state in memory and the load balancer no longer pins

high

Trigger: The client drops Mcp-Session-Id, requests spread across instances, and the second request cannot find the state the first one created.

Detection: Intermittent 'unknown resource' or 'no such handle' errors that correlate with instance count, not with input.

Mitigation: Mint an explicit handle from a tool and make the model pass it back as an argument. The spec authors say exactly this. State lives in your store, keyed by the handle, not in the transport.

A tool that asked the user a follow-up question stops working

high

Trigger: The tool relied on elicitation/create over a held-open stream. The stream is gone.

Detection: Tool calls that used to pause now return an error, or hang until the client times out.

Mitigation: Return input_required with the fields you need. Make the tool idempotent so the client retry with inputResponses does not double-apply the first half of the work.

The API gateway rejects or misroutes MCP traffic

medium

Trigger: Routing rules parse the JSON body for the method name; the new clients send the method in the Mcp-Method header and some gateways are configured to strip unknown headers.

Detection: 403s or wrong-backend routing for a subset of methods, usually the ones the gateway was never told about.

Mitigation: Allow-list Mcp-Method and Mcp-Name at the edge, route on them, and delete the body-parsing rule. Rate limits move to the same headers.

Long-running tools time out at the edge

high

Trigger: A tool that ran for minutes inside one held connection now has to survive a gateway with a 30 second idle limit.

Detection: Work completes server-side but the client never sees the result.

Mitigation: Return a task from the tool and let the client poll tasks/get. Persist task state where an instance restart cannot lose it.

Desktop and CLI clients fail OAuth

medium

Trigger: DCR with a localhost redirect is rejected, or the authorization server does not return iss.

Detection: Login loops on developer machines only.

Mitigation: Publish a Client ID Metadata Document, set application_type for native clients, and validate iss on the callback.

Clients re-list tools on every turn and blow the token budget

low

Trigger: The client ignores ttlMs and cacheScope and behaves as before.

Detection: tools/list dominates the request log and the model's context.

Mitigation: Honour ttlMs. If your catalog rarely changes, set a long TTL and notify through subscriptions/listen when it does.

The first and the fourth are the ones that page you at night. Both come from the same root cause: the old protocol let you hide state in a connection, and the new one does not.

Stateless does not mean no state. It means visible state.

The tempting migration is to bolt a session store onto the server and rebuild what the transport used to do. Do not. You would be rebuilding sticky sessions above a protocol that was redesigned to remove them, and you would keep the failure mode where a handle exists in one instance's memory and nowhere else.

The pattern that survives is small. A tool that needs to remember something across calls returns a handle. The model carries the handle in the next call as an ordinary argument. Your server resolves the handle against a store any instance can reach.

Code
typescript
// Before: state hidden in the session
server.tool("open_dataset", async ({ path }, ctx) => {
ctx.session.dataset = await load(path);       // dies with the instance
return { content: [{ type: "text", text: "opened" }] };
});

// After: state behind an explicit handle
server.tool("open_dataset", async ({ path }) => {
const handle = await datasets.create(path);   // durable, any instance can resolve it
return { content: [{ type: "text", text: JSON.stringify({ handle }) }] };
});

server.tool("query_dataset", async ({ handle, sql }) => {
const dataset = await datasets.resolve(handle); // throws a clear error if unknown
return { content: [{ type: "text", text: await dataset.query(sql) }] };
});

Two details make this work in practice. Handles should be opaque and unguessable, because the model will happily invent one if it has seen the format. And resolving an unknown handle should fail loudly with a message the model can act on, such as "handle expired, call open_dataset again", rather than returning an empty result.

Multi round-trip requests are a contract, not a convenience

Elicitation used to feel like a conversation: the tool paused, the user answered, the tool continued. MRTR turns that into two ordinary requests, which means the tool runs twice.

That changes how you write it. The first run must do nothing irreversible before it returns input_required. The second run, with inputResponses present, does the work. If the first run created a draft record and the second run creates it again, you have a duplicate that nobody asked for.

Code
typescript
server.tool("send_invoice", async ({ customerId, inputResponses }) => {
const draft = await invoices.prepare(customerId);   // read-only preview, safe to repeat

if (!inputResponses?.confirm) {
  return {
    resultType: "input_required",
    inputs: [{ name: "confirm", type: "boolean", prompt: `Send ${draft.total} to ${draft.email}?` }],
  };
}

const sent = await invoices.send(draft.id, { idempotencyKey: draft.id });
return { content: [{ type: "text", text: `sent ${sent.id}` }] };
});

The idempotency key is doing real work there. The client is allowed to retry, gateways are allowed to retry, and the model is allowed to be confused. All three will happen.

Tasks: the part that fixes long-running agents

Tasks are the reason enterprise vendors were quoted in the release post. AWS contributed the extension and called it the piece that "brings support for reliable, long-running agents". The shape is simple: a tool returns a task instead of a result, the client polls tasks/get, and the server can push progress with tasks/update.

What the shape does not give you is durability. If your task state lives in the process, an instance restart loses the task and the client polls forever. Treat task state as you treat the handle store above: durable, keyed, readable by any instance, with a terminal state that never disappears.

A useful habit is to record the model, harness and policy versions on the task record when it is created. When a task finishes three hours later under a different deploy, you will want to know which code did the work.

The migration order that worked

Server-side migration, in the order that keeps production stable

  1. 1. Phase 0: inventory
    • - List every server, its transport, and whether any tool reads session state.
    • - Grep for elicitation/create, sampling/createMessage and roots/list; each is a rewrite.
    • - List which clients talk to each server and which SDK version they pin.
    • - Record every gateway rule that inspects an MCP request body.

    Gate: A table of servers with a yes/no for session state, held-open flows and long-running tools.

  2. 2. Phase 1: make state explicit
    • - Replace session-held objects with handles backed by a shared store.
    • - Give every mutating tool an idempotency key derived from its inputs or a handle.
    • - Add a TTL and an explicit expiry error to every handle.

    Gate: Run the server as three instances behind round-robin with no pinning. Every integration test passes.

    Rollback: Handles are additive; the old session path can stay behind a flag until the gate passes.

  3. 3. Phase 2: upgrade the SDK and the edge
    • - Pin the 2026-07-28 SDK and the exact client versions you tested against, not only the protocol date.
    • - Allow-list Mcp-Method and Mcp-Name at the gateway; route and rate-limit on them; delete body parsing.
    • - Convert elicitation and sampling flows to input_required responses with idempotent second runs.
    • - Move long-running tools to the Tasks extension with durable task state.

    Gate: A real SDK client and server smoke test on the deployed stack, including one input_required flow and one task that outlives a rolling restart.

    Rollback: Keep the previous server image; the header allow-list is harmless to the old clients.

  4. 4. Phase 3: auth and caching
    • - Publish a Client ID Metadata Document for each client; set application_type for native clients.
    • - Validate iss on every authorization callback.
    • - Set ttlMs on list responses to match how often your catalog changes; emit subscriptions/listen on change.

    Gate: Developer-machine login works without DCR. tools/list volume in the request log drops to near zero.

  5. 5. Phase 4: retire the deprecated surface
    • - Remove Roots, Sampling and Logging from capability advertisements once no pinned client depends on them.
    • - Turn off the legacy HTTP+SSE transport.
    • - Turn off DCR.

    Gate: Inside the 12-month window, with a client inventory that shows zero users of each retired path.

Where the policy layer goes now

The stateless core has a side effect that security teams will like. Because every request is self-describing, a policy proxy in front of the server can make a complete decision from one request: who is calling, which method, which tool, with which arguments. It no longer needs to reconstruct a session to know what is going on.

That is where the local agent firewalls fit. A proxy that evaluates every tools/call against an allow, deny or ask policy sits cleanly in front of a stateless server, and the Mcp-Method header means it can make the cheap decisions without parsing the body at all. That layer deserves its own piece.

The AgentOps consequence is equally direct. With no session to attach to, tracing has to attach to the request. Put the run id, the task id and the model version in _meta on the way in, and on the task record on the way out, or your traces will be a pile of unrelated requests by the end of the week.

A worked example: one server, end to end

The changelog is abstract until you migrate something. Here is a small server, the kind most teams have: a handful of tools over a database, one of which asks the user a question and one of which runs for minutes. The code is illustrative TypeScript against the shape of the 2026-07-28 spec, not a specific SDK's API, so treat the method names as the contract and the helper names as yours.

Every request carries its own metadata. The spec reserves three keys in _meta for this: io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities are required on every request, and io.modelcontextprotocol/clientInfo should be there unless the client is configured not to send it. A request missing a required field is malformed and the server must answer with JSON-RPC error -32602, which on HTTP is a 400. If the server needs a capability the client did not declare, it returns -32021 with the missing capabilities in data.requiredCapabilities.

Code
typescript
const META = "io.modelcontextprotocol/";

function readRequestMeta(params: { _meta?: Record<string, unknown> }) {
const meta = params._meta ?? {};
const version = meta[META + "protocolVersion"];
const caps = meta[META + "clientCapabilities"];
if (typeof version !== "string" || typeof caps !== "object" || caps === null) {
  throw jsonRpcError(-32602, "missing required _meta fields");
}
if (!SUPPORTED_VERSIONS.includes(version)) {
  throw jsonRpcError(-32022, "Unsupported protocol version", {
    supported: SUPPORTED_VERSIONS,          // the client picks one and retries
    requested: version,
  });
}
return { version, caps: caps as ClientCapabilities, clientInfo: meta[META + "clientInfo"] };
}

The spec is explicit that clientInfo and the server's serverInfo are self-reported and unverified. They are for display, logging and debugging, and must not drive behaviour or security decisions. Put them in your logs and nowhere else.

Discovery is a method, not a handshake. server/discover is mandatory to implement and optional to call. A client may send tools/call cold and handle -32022 if the version is wrong. The response tells the client what the server speaks and which extensions it supports; extensions are advertised in a capabilities.extensions map keyed by their identifier, so a server that supports Tasks advertises io.modelcontextprotocol/tasks with an empty settings object.

The list is cacheable. tools/list now returns ttlMs and cacheScope alongside the tools. If your catalog changes only on deploy, a long TTL is honest, and you tell clients about a change through the subscription stream rather than by making them poll.

The question-asking tool becomes two independent requests. This is the one that changes how you write code. Under MRTR the server returns resultType: "input_required" with an inputRequests map, whose keys are server-assigned identifiers and whose values are ordinary elicitation/create, sampling/createMessage or roots/list requests. The client gathers the answers, then retries the original request with a different JSON-RPC id, an inputResponses map with the same keys, and the requestState string echoed back byte for byte.

Code
typescript
async function sendInvoice(args: Args, meta: RequestMeta): Promise<ToolResult | InputRequired> {
const draft = await invoices.prepare(args.customerId);          // read-only, safe to repeat

if (!args.inputResponses?.confirm) {
  if (!meta.caps.elicitation) throw jsonRpcError(-32021, "elicitation required", { requiredCapabilities: ["elicitation"] });
  return {
    resultType: "input_required",
    inputRequests: {
      confirm: {
        method: "elicitation/create",
        params: {
          mode: "form",
          message: `Send ${draft.total} to ${draft.email}?`,
          requestedSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
        },
      },
    },
    // Everything the second run needs, sealed so a client cannot edit it.
    requestState: seal({ principal: meta.principal, draftId: draft.id, exp: now() + 600, req: digest("send_invoice", args) }),
  };
}

const state = unseal(args.requestState, meta.principal);      // throws on tamper, expiry, wrong principal, wrong request
const answer = args.inputResponses.confirm as ElicitResult;
if (answer.action !== "accept" || !answer.content?.ok) return text("not sent");
const sent = await invoices.send(state.draftId, { idempotencyKey: state.draftId });
return text(`sent ${sent.id}`);
}

Three rules from the spec are doing work in that function. The server must not include an inputRequests entry for a capability the client did not declare, hence the check before asking. requestState is attacker-controlled input as far as the server is concerned: if it influences authorisation or business logic it must be integrity-protected, and the spec recommends sealing the authenticated principal, a short expiry and a digest of the originating request inside it, then verifying each on receipt. And the server must not assume the client will ever retry, which is why the first run does nothing irreversible.

Note the last line of the spec's guidance: those measures bound the replay window and stop cross-user and cross-request reuse, but they do not make the state single-use. If a requestState must be consumed at most once, that is your invariant to enforce server-side. The idempotency key on the send is how this example does it.

The long-running tool returns a task. With the Tasks extension the tool returns a task reference, the client polls tasks/get, and the server can push tasks/update. The task record is your durable state, keyed by an identifier the client passes back, exactly as the spec says all cross-request state must be. Record the model and policy versions on it when it is created.

Talking to servers and clients from the other era

Most migrations are not a flag day. You will have modern clients talking to legacy servers and the reverse for a while, and the spec has a name for each situation.

A modern implementation conveys version, identity and capabilities per request. A legacy one establishes a session with initialize. A dual-era implementation supports both, and the compatibility matrix in the versioning section of the spec is the table to pin above your desk. Two rows matter most.

A modern client against a legacy server fails, and not cleanly: the server may reject with an implementation-defined error, stay silent, or process an era-ambiguous method under legacy semantics. On stdio the spec's advice is to send server/discover first so the failure is deterministic. A legacy client against a modern server also fails, because initialize is an unknown method and the request lacks the required _meta fields; the spec asks modern-only servers to name the versions they support in that error, since legacy clients have no way to fall forward and the message may be the only diagnostic a user sees.

The way through is a dual-era server for the transition. It selects behaviour from how the client opens: a request carrying modern _meta is served statelessly; an initialize request selects legacy semantics scoped to the process or the HTTP session. A dual-era server may serve both eras concurrently on the same endpoint. Clients cache the era determination per server process or origin and re-probe if the cached assumption later fails.

One caution on stdio specifically. The spec is clear that an open connection is not a session: clients may interleave unrelated requests on the same process, and a server must not treat process identity as conversation continuity. If your stdio server kept per-process state on the assumption that one process meant one conversation, that assumption is now wrong by definition.

Subscriptions replace the GET stream

The old HTTP GET endpoint for server-to-client notifications and the old resources/subscribe RPC are both gone. In their place is one request, subscriptions/listen, which opens a long-lived notification stream scoped to that request rather than to the connection.

The client says what it wants with a notifications filter: toolsListChanged, promptsListChanged, resourcesListChanged, and resourceSubscriptions, an array of resource URIs to watch. The server must not send notification types the client did not ask for. Its first message on the stream must be notifications/subscriptions/acknowledged, carrying io.modelcontextprotocol/subscriptionId in _meta and the subset of the filter it agreed to honour. Every later notification on that stream carries the same subscription id, which is the JSON-RPC id of the subscriptions/listen request, so a client with several subscriptions on one stdio channel can demultiplex them.

For the migration this means two things. The cacheable list and the subscription are a pair: set a long ttlMs and emit notifications/tools/list_changed on deploy, and clients stop polling. And on stdio, if the process restarts, the server holds no subscription state, so the client must re-send subscriptions/listen. A graceful server-side close is signalled by a normal result to the original listen request; an abrupt transport drop has no response, which the client may treat as a cue to reconnect.

Questions that came up

Do I have to implement server/discover? Yes. Servers must implement it. Clients may skip calling it.

What happens to my per-tool progress notifications? progressToken in _meta still opts a request into progress notifications. It is one of the reserved _meta keys, alongside the protocol fields, logLevel, subscriptionId, and the OpenTelemetry trio.

How do I propagate traces? Put traceparent, tracestate and baggage in _meta. They are the one exception to the prefixed-key rule, reserved so that the OpenTelemetry semantic conventions for MCP keep working. If your AgentOps tracing already attaches W3C trace context to outbound calls, it now has a standard home on the wire.

Can I keep using DCR for a while? Yes, within the twelve-month window; it is deprecated, not removed. Client ID Metadata Documents are the standard path and the one to build for. Native clients that hit the localhost redirect rejection should set application_type during registration in the meantime.

Is a stdio local server exempt from all this? No. Statelessness applies to every transport. Authorization is the part that differs: HTTP transports should follow the authorization framework, stdio transports should not and should take credentials from the environment instead.

Our gateway strips unknown headers. Then it will strip Mcp-Method and Mcp-Name and modern requests will fail validation with a 400. Allow-listing the two headers is the first change to make at the edge, before any server is upgraded.

The checklist

Before you call the migration done

0/9

The 2026-07-28 revision is a good one. It removes a whole class of production bugs that came from hiding state in a connection. The cost is that the state you were hiding is now your problem to design, which it always was.