MCP 2026-07-28 Migration: Ship a Dual-Era Server

Migrate an MCP server to 2026-07-28 without breaking legacy clients. Use dual-era routing, state handles, conformance tests, and rollout gates.

Saturday, July 11, 2026Omid Saffari
MCP 2026-07-28 Migration: Ship a Dual-Era Server

Do not switch production MCP traffic to 2026-07-28 yet. Build and test a dual-era adapter now, keep legacy clients on 2025-11-25, and cut over only after the final specification and a production-supported SDK release pass your own conformance and workload gates.

The production decision: prepare now, cut over after final

The safe move is to separate migration work from production activation. The 2026-07-28 release candidate was locked on May 21, 2026, but the final specification is scheduled for July 28, 2026 after a ten-week validation window. It contains breaking changes, so treating the release candidate as a routine dependency bump is the wrong risk model.

The official TypeScript SDK makes that line explicit. Its v2 packages are beta and implement 2026-07-28; v1.x remains the supported production release until stable v2 ships. Stable v2 is expected alongside the final specification on July 28, 2026, and v1.x is scheduled to keep receiving bug fixes and security updates for at least 6 months after v2 ships. Check the live TypeScript SDK status when you execute the cutover, not when you open the migration branch.

Official MCP TypeScript SDK repository showing v2 beta and v1 production support status
The official TypeScript SDK keeps v1.x as the production line while v2 is beta.

For a platform team, that produces a clean environment split:

EnvironmentProtocol pathSDK posturePurpose
Production2025-11-25 legacySupported production linePreserve current client behavior
StagingDual-eraProduction line plus isolated beta adapterExercise both lifecycle paths
Conformance2026-07-28 modernBeta or source-pinned test buildFind protocol defects before traffic

What changes at the server boundary

The migration belongs in the protocol adapter, not in every tool implementation. 2026-07-28 removes the transport session and handshake, then makes each request carry enough information to stand alone. Your domain services should still receive an authenticated principal, validated tool arguments, and an execution context regardless of which protocol era arrived at the edge.

The draft changelog turns that boundary into a concrete diff:

Concern2025-11-25 legacy path2026-07-28 modern pathAdapter responsibilityProduction signal
Lifecycleinitialize and notifications/initializedNo handshakeSelect the correct lifecycle before dispatchprotocol_era
Version and capabilitiesNegotiated into session stateRequired in request _metaReject missing or unsupported metadataprotocol_version, client_capabilities
HTTP routingBody inspection and optional session affinityMCP-Protocol-Version, Mcp-Method, Mcp-NameValidate header and body agreementmcp_method, mcp_name, mismatch reason
Cross-call stateOften hidden behind Mcp-Session-IdExplicit server-minted handlesResolve handles after authorizationHandle type, tenant, outcome
Additional inputServer-initiated request patternsinput_required result and client retryPersist or sign retry stateInput kind, decision, retry outcome
Long workExperimental Tasks or held connectionOpt-in Tasks extensionStore task state durablyTask status, age, cancellation outcome

A conforming modern HTTP request includes the protocol version in both the header and _meta, plus client identity and capabilities. Mcp-Method is required for every request, and Mcp-Name is also required for tools/call, resources/read, and prompts/get. This request is a useful first smoke test against a staging endpoint:

Bash
curl -sS "$MCP_URL" \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/call' \
  -H 'Mcp-Name: health/read' \
  --data-binary @- <<'JSON'
{
  "jsonrpc": "2.0",
  "id": "migration-canary",
  "method": "tools/call",
  "params": {
    "name": "health/read",
    "arguments": {},
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": {
        "name": "migration-canary",
        "version": "candidate"
      },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}
JSON

The request is deliberately boring. A health tool with no side effects tells you whether routing, metadata validation, authentication, response framing, and tracing work before a business tool complicates the diagnosis. If you are still defining the domain boundary, start with the production MCP server pattern for internal APIs and keep version-specific behavior outside the tool handlers.

Build one dual-era protocol adapter

One endpoint can serve both eras, but the adapter must make the choice deterministically. The official versioning specification calls an implementation that supports both modern and legacy revisions dual-era. A request carrying the required modern _meta selects stateless semantics; an initialize request selects legacy semantics. Both may run concurrently on the same endpoint or process.

The classification layer should be small enough to test exhaustively. This TypeScript module captures the minimum boundary without coupling it to a particular server framework:

TypeScript
type RpcRequest = {
  method: string;
  params?: Record<string, unknown> & {
    name?: string;
    uri?: string;
    _meta?: Record<string, unknown>;
  };
};

const PROTOCOL = "io.modelcontextprotocol/protocolVersion";
const CLIENT = "io.modelcontextprotocol/clientInfo";
const CAPABILITIES = "io.modelcontextprotocol/clientCapabilities";
const NAMED_METHODS = new Set(["tools/call", "resources/read", "prompts/get"]);

export function selectMcpEra(
  headers: Headers,
  rpc: RpcRequest,
): "modern" | "legacy" {
  const meta = rpc.params?._meta;
  const modern = Boolean(meta?.[PROTOCOL] && meta?.[CLIENT] && meta?.[CAPABILITIES]);

  if (modern) {
    const headerVersion = headers.get("mcp-protocol-version");
    if (headerVersion !== meta?.[PROTOCOL]) throw new Error("HeaderMismatch");
    if (headers.get("mcp-method") !== rpc.method) throw new Error("HeaderMismatch");

    if (NAMED_METHODS.has(rpc.method)) {
      const expectedName = rpc.params?.name ?? rpc.params?.uri;
      if (headers.get("mcp-name") !== expectedName) throw new Error("HeaderMismatch");
    }

    return "modern";
  }

  if (rpc.method === "initialize") return "legacy";
  throw new Error("UnrecognizedProtocolEra");
}

This classifier is not the whole compliance layer. The modern adapter must also implement server/discover, return UnsupportedProtocolVersionError with code -32022 and its supported versions, and map malformed required metadata to -32602 with 400 Bad Request. An unsupported method returns 404 Not Found with JSON-RPC code -32601. Keep those errors typed: clients use the body to distinguish a modern rejection from a legacy endpoint.

  1. Separate era detection

    Parse enough JSON-RPC to identify the method and _meta, then select the adapter before creating any protocol session. Do not let a legacy session cookie or Mcp-Session-Id override a valid modern request.

  2. Share domain handlers

    Normalize both adapters into one internal call shape: principal, tool name, validated arguments, trace context, approval state, and an abort signal. Tool code should not know whether initialize ran.

  3. Keep errors explicit

    Return recognized modern errors for version, capability, header, and metadata failures. Do not convert them into a generic 400, because dual-era clients need the JSON-RPC body to decide whether to retry or fall back.

  4. Instrument both paths

    Log the chosen era, requested version, client identity, method, name, auth issuer, trace ID, result type, and typed failure. Never log access tokens, raw authorization codes, or unrestricted tool arguments.

Dual-era MCP request routing from modern metadata or legacy initialize into shared domain handlers
Classify the protocol era at the edge, then converge both adapters on the same authorized domain handlers.

The dangerous fallback is automatic downgrade after any 400. A dual-era HTTP client should inspect the response body. If it contains a recognized modern JSON-RPC error, correct the request or retry a supported version. Fall back to initialize only when the body is empty or not a recognized modern error. Otherwise a metadata bug, unsupported capability, or header mismatch silently turns into a legacy request and hides the fault you need to fix.

Move hidden session state into explicit handles

Stateless MCP removes protocol state, not application state. The replacement is an explicit server-minted handle returned by one tool and passed back as an ordinary argument on later calls. That handle can identify a browser, deployment, basket, workflow, or other durable domain object.

Consider an illustrative deployment tool. deploy/start authorizes the caller, creates a durable record, queues the work, and returns deployment_id: "dep_alpha". A later deploy/status call must supply that handle:

JSON
{
  "jsonrpc": "2.0",
  "id": "deployment-status",
  "method": "tools/call",
  "params": {
    "name": "deploy/status",
    "arguments": {
      "deployment_id": "dep_alpha"
    },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": {
        "name": "release-console",
        "version": "candidate"
      },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

The server resolves dep_alpha only after authenticating the request and proving that the current principal may access it. Any replica can continue the workflow because ownership and progress live in the application store, not in load-balancer affinity or a process-local MCP session.

Treat the handle as an untrusted reference, not as authorization. Make it opaque, bind it to a tenant or principal in storage, apply expiry where the domain requires it, and make repeated reads idempotent. Log the handle type and an internal correlation value, but avoid exposing sensitive state in the handle itself.

This architecture also makes failures easier to recover. A worker restart loses no hidden conversation state. A retry can resolve the same durable object. A support engineer can trace the handle from the MCP edge through the queue and downstream API without reconstructing which server instance minted a session.

Replace callbacks and long connections deliberately

Use input_required for bounded additional input on an active operation, and use the Tasks extension for work that must survive disconnection or process restart. They solve different lifecycle problems and should not share an improvised queue abstraction.

For a destructive tool, the modern server can pause before execution and return an input request. Use input_required, with an underscore. The live changelog, schema, and tools specification use that form; inputRequired appears in the original release-candidate example and should not enter a production contract.

JSON
{
  "jsonrpc": "2.0",
  "id": "delete-request",
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "approval": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "Approve the requested deletion?",
          "requestedSchema": {
            "type": "object",
            "properties": {
              "approved": { "type": "boolean" }
            },
            "required": ["approved"]
          }
        }
      }
    },
    "requestState": "opaque_signed_state"
  }
}

The client presents the decision, then retries the original method with inputResponses and the echoed requestState. The retry uses a different JSON-RPC ID. Sign or encrypt requestState if the client carries it, bind it to the principal and original operation, reject replays after completion, and do not perform the side effect until the accepted response reaches the server.

The MCP Tasks extension is the durable path. Both client and server opt in with io.modelcontextprotocol/tasks. A server may then return resultType: "task" with a taskId, TTL, and suggested polling interval. It must create the task durably before returning the handle. Clients use tasks/get, tasks/update, and tasks/cancel; cancellation is cooperative and the task may still reach another terminal state.

Work shapeUsePersistHuman controlFailure behavior
Immediate tool needs one answerinput_requiredSigned retry state if neededResolve before side effectRetry original request with a new ID
Long-running jobTasks extensionTask record before returning handleMove task to input_required and use tasks/updateResume polling with the same taskId
Fast read or writecomplete resultDomain state onlyApply the normal approval policyReturn final result on the request

The production test is not whether the happy path completes. Restart the client, restart the worker, route the next poll to another replica, and verify that the same task can continue without duplicate side effects. Then cancel during each meaningful phase and confirm that the terminal state reflects what actually happened.

Make routing, caching, and traces enforceable

The new metadata is useful only when gateways and observability systems verify it. Treat the body as the source of truth, validate every mirrored header against it, and reject disagreements before routing or rate limiting. MCP-Protocol-Version must match request _meta; Mcp-Method and, where required, Mcp-Name must match the JSON-RPC request.

That gives the gateway safe policy dimensions without parsing tool arguments. You can route read-heavy resource calls separately, apply a stricter policy to mutation tools, and produce method-level saturation metrics. Do not trust mirrored headers on traffic that does not identify a protocol version requiring header/body validation.

Caching also becomes explicit. The draft requires ttlMs and cacheScope on tool, prompt, and resource list/read results. cacheScope is public or private; set it from the authorization semantics of the result, not from endpoint naming. A per-user tool list is private even if every user calls the same URL. A public resource still needs invalidation behavior that respects ttlMs.

Trace propagation uses the reserved _meta keys traceparent, tracestate, and baggage. Preserve them through the adapter, task store, worker, and downstream API so a single trace shows where the tool waited, retried, requested approval, or failed. A minimal structured event can look like this:

JSON
{
  "protocol_version": "2026-07-28",
  "protocol_era": "modern",
  "mcp_method": "tools/call",
  "mcp_name": "deploy/start",
  "auth_issuer": "https://identity.example",
  "trace_id": "trace_alpha",
  "result_type": "complete",
  "outcome": "accepted"
}

Add typed events for discovery, unsupported versions, missing capabilities, header mismatches, fallback decisions, input requests, task transitions, cache scope, and downstream side effects. That event model tells you whether a rollout problem is protocol negotiation, authorization, domain behavior, or infrastructure.

Close the authorization and schema breaks

Authorization and schema compatibility deserve separate release gates because both can fail after the transport looks healthy. Reusing legacy session assumptions in either layer creates the most expensive kind of migration bug: a request that reaches the correct tool under the wrong identity or with subtly different validation.

For authorization, the draft says clients must validate a present iss value against the recorded issuer before redeeming the authorization code. Persisted client credentials are issuer-bound: key them by issuer, never reuse them with another authorization server, and re-register when the authorization server changes. Clients must also set an appropriate OpenID Connect application_type during Dynamic Client Registration. The deeper flow and threat boundaries are covered in MCP authorization for production servers.

For schemas, the draft expands tool input and output schemas to JSON Schema 2020-12 and allows structuredContent to be any JSON value. Do not automatically dereference external $ref URIs, and bound schema depth and validation time. Run your golden tool corpus through the new validator and compare accepted inputs, rejected inputs, normalized outputs, and model-visible error messages. The change from resource-not-found code -32002 to -32602 also needs an explicit client regression test if anything branches on the literal code.

Roots, Sampling, and Logging are deprecated, not immediately removed. The documented replacements are tool parameters, resource URIs, or server configuration for Roots; direct provider APIs for Sampling; and stderr on stdio or OpenTelemetry for Logging. The normal feature policy provides at least twelve months before removal eligibility. Keep existing compatibility where clients need it, but do not add new dependencies on deprecated capabilities.

Gate the rollout with conformance and production telemetry

The rollout is ready when the modern adapter passes protocol conformance and produces the same authorized domain outcomes as the legacy path. Either gate alone is insufficient: conformance cannot prove your business semantics, and workload tests can miss a protocol edge that another client will exercise.

Run the draft suite explicitly. The default active server suite excludes draft-spec scenarios, so a green default run does not validate 2026-07-28:

Bash
npx @modelcontextprotocol/conformance \
  server \
  --url "$MCP_URL" \
  --suite draft

The official MCP Conformance Test Framework writes server results to checks.json. Unexpected failures exit 1, normal passes exit 0, and a stale expected-failure baseline also exits 1. That last behavior matters: when a previously expected failure starts passing, CI forces you to remove the stale exception instead of letting the baseline grow forever.

Official MCP conformance test framework repository and server test commands
The draft suite must be selected explicitly while 2026-07-28 is not the current release.
  1. Freeze the legacy baseline

    Capture a golden corpus of discovery, list, read, prompt, and tool calls with their authorization decisions, normalized results, errors, and side-effect records. This is semantic evidence, not a packet replay full of secrets.

  2. Exercise both eras

    Run the corpus through 2025-11-25 and 2026-07-28 adapters against the same domain handlers. Differences must be intentional protocol changes, not drift in permissions or tool behavior.

  3. Test failure transitions

    Send missing metadata, mismatched headers, unsupported versions, unknown methods, expired handles, rejected approvals, dropped streams, worker restarts, and duplicate retries. Assert the typed protocol error and the absence or idempotence of side effects.

  4. Shadow modern requests

    For safe read operations, translate selected legacy traffic into modern requests in a non-authoritative environment. Compare outputs and traces without allowing the shadow path to mutate production state.

  5. Canary known clients

    Enable modern semantics only for clients whose SDK version and capability set you can identify. Keep the legacy path live and make rollback a routing change, not a redeploy.

  6. Retire by evidence

    Remove legacy behavior only after the client inventory is complete, modern errors are understood, semantic parity holds, tasks survive disruption, and the rollback window has closed under your change policy.

Use a release scorecard that forces an owner to answer each failure mode:

GateEvidenceStop condition
ProtocolDraft conformance checks.jsonAny unexplained failure or stale exception
CompatibilityDual-era golden corpusUnauthorized or unexplained semantic difference
NegotiationDiscovery and fallback logsRecognized modern error triggers legacy fallback
StateCross-replica handle testsProcess or affinity dependence
TasksRestart, retry, approval, and cancellation testsLost task, duplicate side effect, or false terminal state
OperabilityCorrelated gateway, adapter, worker, and downstream tracesRequest cannot be followed end to end
RollbackRehearsed routing reversalCutover requires a code rollback under pressure

The decision rule is blunt: do not turn on the modern path because the calendar reached the final release date. Turn it on when the supported SDK you actually deploy passes conformance, your client set negotiates correctly, and your production controls make every failure visible and reversible.

Frequently asked questions

How do I migrate an MCP server from 2025-11-25 to 2026-07-28 without breaking clients?

Deploy a dual-era adapter. Select modern semantics from the required per-request _meta, preserve initialize for legacy clients, route both into shared authorized domain handlers, and canary the modern path behind conformance and workload gates.

Is MCP 2026-07-28 final?

No. The release candidate was locked on May 21, 2026, and the final specification is scheduled for July 28, 2026. The current TypeScript SDK v2 line is beta, while v1.x remains the supported production release until stable v2 ships.

Does stateless MCP mean the application cannot keep state?

No. The protocol session disappears, but application state remains in durable domain storage. Return an opaque handle from one tool call, require later calls to pass it back, and authorize every resolution against the current principal.

Should a production TypeScript MCP server use SDK v2 beta now?

Use v2 beta in a migration branch, staging environment, and conformance lane. Keep production on the supported v1.x line until the final specification and stable v2 release pass your workload, authorization, and rollback gates.

Last Updated

Jul 11, 2026

CategoryMCP
Newsletter

One letter, every week. Working systems — not hot takes.

Build logs, agentic engineering decisions, agent failures, evals, and what survives real users. Sent weekly, never more.

Weekly. No spam. Unsubscribe anytime.