Migrate MCP 2025-11-25 to 2026-07-28
Treat the move to MCP 2026-07-28 as a wire migration: replace initialization and session assumptions with discovery and per-request metadata, move callbacks to MRTR and list changes to subscriptions/listen, and stop extending deprecated roots, sampling, and logging. Test each protocol era separately, pin strict paths, and record every fallback.
Treat the revision as a wire migration
The move from MCP 2025-11-25 to 2026-07-28 changes the messages that clients and servers exchange. It is not a configuration toggle around the same lifecycle. The 2026-07-28 specification changelog removes the initialize/notifications/initialized handshake and protocol-level sessions, requires each request to carry its protocol revision and client capabilities in _meta, and adds server/discover for up-front discovery.
That change reaches connection setup, request serialization, server routing, cross-call state, server-to-client interaction, change notifications, logging, tests, and rollout controls. Upgrading only the SDK package can therefore leave a deployment running 2025 behavior through a newer API. Conversely, switching a server to modern-only behavior can break clients that still begin with initialize.
Treat the two revisions as separate wire eras. Inventory every place that assumes an initialization phase, reads connection-scoped capabilities, stores an Mcp-Session-Id, sends a request from server to client, emits an unsolicited list-change notification, or changes a session-wide log level. Then test the replacement behavior on raw requests and responses, not only through application-level success tests.
The connection succeeds only through the old lifecycle
Symptom. A newly upgraded client still starts with initialize, a modern-only server rejects the first exchange, or a request reaches the handler without the protocol revision and client capabilities the handler expected. Another version of the same failure is state disappearing when two calls land on different server instances because the application had treated transport session state as application state.
Likely cause. One side still implements the 2025 initialization lifecycle while the other expects the 2026 request model. In 2025-11-25, initialization establishes the protocol revision, identities, and capabilities for later traffic. A sessionful Streamable HTTP implementation may also use Mcp-Session-Id to associate later calls with that connection state. In 2026-07-28, there is no initialization exchange or protocol-level session: every request identifies its protocol revision and client capabilities in _meta. Clients should also identify themselves per request, and servers should identify themselves in result metadata.
server/discover does not recreate a session. It lets a client ask which revisions, capabilities, and server identity are available before choosing how to communicate. A 2026 server must implement it, while a client may skip it when the intended revision is already known. The distinction matters: discovery informs a choice, but the chosen revision still travels with each request. The resulting behavior is the stateless request model, not initialization under a new method name.
Check. Capture a complete staging transcript beginning before the connection attempt. Do not start the capture at tools/list or tools/call; the first exchange distinguishes the eras.
- For a legacy run, confirm that the client sends
initializeand completes the initialized exchange before ordinary operations. - For a modern run, confirm that an optional
server/discoverprobe is followed by requests carryingio.modelcontextprotocol/protocolVersionandio.modelcontextprotocol/clientCapabilitiesin_meta. - If the request is Streamable HTTP, compare its declared revision with the revision in the request metadata rather than trusting an application setting.
- Send two related calls through different server instances. If the second call requires hidden state from the first, identify exactly where that state was attached to the old session.
- Record the discovered revision and the revision actually used. A successful discovery call alone does not prove that all subsequent requests use modern framing.
These checks should produce wire evidence: method names, request metadata, selected revision, instance identity, and any explicit application handle. A test that merely says the tool returned a value cannot distinguish modern behavior from legacy fallback.
Fix. Implement server/discover on the modern server path and make the client attach the modern metadata envelope to every request. Move capability and identity lookups out of initialization-scoped objects and into the current request context. Where the application genuinely needs state across calls, mint an explicit server handle and pass it back as an ordinary tool argument. That preserves application state without reviving transport sessions.
The cost is a real serialization and state-management change. Request builders, middleware, gateways, test fixtures, and handlers may all need updates. Explicit handles also need their own validation and lifetime rules. This is still the correct boundary: a switch that changes a version constant but leaves initialization, connection capabilities, or hidden session state in place has not completed the migration. Use server discovery and its cache hints when the client must learn support dynamically; pin the revision when it already knows the contract it requires.
A tool hangs while waiting for a server-to-client reply
Symptom. A tool begins successfully but stalls when the server sends roots/list, sampling/createMessage, or elicitation/create. The client may never answer, the server may wait on a bidirectional stream that the modern path does not maintain, or the result parser may reject an interim result it mistakes for a completed call.
Likely cause. The server still uses the 2025 server-initiated request pattern. The maintainers’ 2026-07-28 release explanation describes the replacement: Multi Round-Trip Requests, or MRTR. Instead of opening a request in the reverse direction, the server returns an interim InputRequiredResult. Its resultType is input_required, and its inputRequests say what additional input is needed. The client obtains that input and retries the original request with inputResponses attached.
The mechanism preserves a request/response transport. Each additional exchange remains tied to the original operation through a retry rather than requiring the server to initiate an independent RPC. Ordinary final results carry resultType: complete; a client reading an older server result without resultType must treat it as complete.
Check. Exercise every operation that can pause for user input, model output, elicitation, or filesystem information. Capture the point where the extra information is requested.
- Search the transcript for direct server-to-client JSON-RPC requests, especially
roots/list,sampling/createMessage, andelicitation/create. - On the modern path, assert that the handler returns
resultType: input_requiredwith the requiredinputRequestsrather than writing a reverse request to the transport. - Assert that the client retries the same operation with
inputResponsesand that the server can resume from the information carried by the retry. - Test a final result and an interim result separately. A parser that treats every successful JSON-RPC result as final will hide the continuation instead of completing it.
- Test an older result without
resultTypeand confirm that the client treats it as complete rather than inventing another round trip.
Fix. Rewrite each reverse-call branch as an explicit continuation. Return the requested inputs in inputRequests, preserve only the state needed to resume, and consume inputResponses when the client retries. Keep the ordinary complete path separate so a handler that needs no further input finishes in one response. The detailed MRTR request sequence is the contract to test.
The cost is handler control flow: a single in-memory call stack can no longer wait for an unrelated inbound response. State needed between attempts must be reproducible from the retried request or represented explicitly. MRTR is the wrong answer when the operation does not need additional input; returning input_required merely to divide normal work adds protocol turns without solving a lifecycle problem. It also does not preserve the old Roots or Sampling features indefinitely. It replaces the reverse-request mechanism; the feature deprecations still require separate design decisions.
Tool, prompt, or resource changes stop arriving
Symptom. Calls still work after the upgrade, but clients do not learn that a tool, prompt, resource list, or subscribed resource changed. A second failure mode is that progress or request-specific log messages appear on a global change stream, where the client cannot reliably associate them with the operation that produced them.
Likely cause. List-change delivery changed along with the stateless lifecycle. The 2026 revision replaces the old HTTP GET notification endpoint and the resources/subscribe and resources/unsubscribe methods with subscriptions/listen. This is a single long-lived POST-response stream. The client opts into named notification types, including tool-, prompt-, and resource-list changes and resource subscriptions; the server acknowledges the subscription and tags its notifications with a subscription identifier.
That stream is not a new home for every server message. Request-scoped notifications, including progress and messages associated with a running operation, remain on that request’s response stream. The separation is by lifetime: list and resource changes outlive one request, while progress belongs to one request.
Check. Change one advertised tool, prompt, and resource in a controlled environment, one at a time. Before each change, verify that the client has opened subscriptions/listen and opted into the corresponding notification type. Record the acknowledgement, subscription identifier, notification type, and receiving stream. Then run a separate long operation and confirm that its progress remains on the operation’s response stream.
Repeat the test after deliberately closing the listen stream. The point is not to assume delivery from a registered callback; it is to show that an active subscription exists when the change occurs and to observe what the client does when that stream ends. Also inspect the server for legacy unsolicited list_changed delivery or resource subscription methods. Those paths can make a legacy test pass while the modern subscription remains absent.
Fix. Add a modern subscriptions/listen path, require clients to state which change types they want, and route only those opted-in changes through the acknowledged subscription. Keep request-scoped progress and messages on their originating response. Replace tests that wait for an unsolicited legacy notification with tests that first establish the subscription and then trigger the change. See the subscriptions/listen delivery model for the modern sequence.
The operational cost is one maintained response stream for change delivery plus explicit subscription bookkeeping and reconnect behavior. The wrong fix is to move every server-originated event onto that stream. Doing so erases the distinction between a change subscription and a request response, and it does not repair clients that never opted into the relevant change type.
Basic calls pass, but deprecated features still shape the design
Symptom. The migrated server can list and call tools, yet new code still asks the client for Roots, delegates model calls through Sampling, or depends on MCP Logging as the application’s logging channel. Teams may also remove these features immediately because they read “deprecated” as “removed,” breaking a legacy client that still uses them.
Likely cause. Roots, Sampling, and Logging are deprecated in the 2026 revision, not removed. They remain functional during the deprecation window, but new implementations should not adopt them. At the same time, parts of their old wire behavior have changed: server-initiated roots/list and sampling/createMessage move to the MRTR pattern, logging/setLevel is removed, and the requested log level is supplied per request in metadata. A server must not emit request messages when that request did not opt in with the log-level field.
The migration therefore has two time horizons. Wire compatibility must be fixed now for the 2026 path. Product dependencies on the deprecated features should be reduced deliberately, without pretending that existing 2025 clients stopped needing them on release day.
Check. Build an inventory from code and captured traffic rather than package names. Search handlers and transcripts for Roots capability checks, roots/list, Sampling capability checks, sampling/createMessage, Logging capability declarations, logging/setLevel, and emitted log messages. For every match, record whether it belongs to a legacy compatibility path, a modern MRTR path, or new feature code.
For Roots, identify the exact directory or file information the operation needs and where the trust decision currently occurs. For Sampling, identify which component owns the provider call and what response the tool expects. For Logging, send one modern request with a per-request log level and one without it; verify that request messages are filtered accordingly. This separates a working compatibility path from an accidental new dependency.
Fix. Keep a tested legacy adapter only where current clients require it, but do not expose Roots, Sampling, or Logging as the foundation of new features. Pass required directories or files through tool parameters, resource URIs, or server configuration instead of introducing a new Roots dependency. Integrate with the chosen model provider directly instead of adding new Sampling behavior. Send stdio diagnostics to stderr or use OpenTelemetry for application observability instead of treating protocol Logging as the durable log pipeline.
Each replacement has a concrete cost. Explicit file parameters move validation and authorization into the tool contract. Direct provider integration makes the server responsible for provider credentials and behavior. stderr is local to the process, while OpenTelemetry requires an observability path outside the protocol. Those costs should be assigned rather than hidden. Immediate deletion is the wrong answer while supported legacy clients still exercise the old features; adding more business logic to a deprecated surface is also the wrong answer.
The rollout behaves differently across clients or environments
Symptom. A client succeeds in staging but fails in production, a modern feature disappears without an error, or two requests to the same endpoint appear to follow different lifecycle rules. The common diagnostic trap is to classify the deployment as modern because one request contained the new metadata envelope, even though the client later fell back to the 2025 handshake.
Likely cause. Compatibility behavior was enabled without making the selected era observable. The TypeScript SDK guide for supporting revision 2026-07-28 documents three materially different client modes: legacy behavior with no discovery probe, automatic negotiation through server/discover with legacy fallback, and a pin to 2026-07-28 that rejects a legacy-only server. Automatic negotiation costs one extra round trip. Its probe already carries the modern metadata envelope, so seeing that envelope does not prove that modern negotiation won.
A server may intentionally serve both eras, but it must keep their contracts distinct. A request cannot safely borrow initialization state from the legacy era while using modern methods, or use modern per-request classification to smuggle an era-mismatched method through a legacy codec. Compatibility means choosing one coherent contract for an exchange, not accepting whichever fields happen to arrive.
Check. Run separate suites against separate fixtures before testing automatic negotiation:
- Against a
2025-11-25-only fixture, assert the initialization exchange and the legacy behavior required by that client. Assert that modern-only methods do not appear. - Against a
2026-07-28-only fixture, assert discovery when enabled, per-request protocol metadata, MRTR continuations, andsubscriptions/listen. Assert that no initialization or protocol session dependency remains. - Against an automatic compatibility endpoint, record the selected era after connection and the reason for any fallback. Test a modern peer, a legacy peer, and a failed probe as different cases; do not collapse them into “connect succeeded.”
- Pin
2026-07-28and connect to the legacy-only fixture. The test should fail rather than falling back. This proves that the strict path is actually strict. - Run the legacy and modern clients independently through the same user-visible operation. Compare wire methods and lifecycle, not only the final payload.
Add rollout counters or structured records for discovery attempts, selected revision, legacy fallback, strict-pin rejection, and revision-mismatch errors. Keep those records free of credentials and tool arguments that should not enter telemetry. If a gateway routes the eras differently, record the chosen route with the negotiated revision. This is the evidence needed to tell a planned compatibility fallback from a broken modern deployment.
Fix. Pin the intended revision wherever strict behavior matters: conformance tests, modern-only endpoints, features that require MRTR or subscriptions, and internal callers whose deployment order you control. A pin turns an unsupported peer into an explicit failure instead of quietly removing modern behavior.
Use automatic negotiation only where one client genuinely must reach both eras. When it falls back, expose the selected era to logs, metrics, and the calling layer. Keep separate modern and legacy test cases even if one production endpoint serves both. On the server, route each request into the handler for its detected era and reject malformed or mismatched traffic rather than merging fields from both contracts.
The rollout cost is a doubled compatibility matrix for as long as both eras are supported, plus the discovery round trip for automatic negotiation. The wrong answer is a fleet-wide modern pin before every required client and server path has passed the modern-only suite. The other wrong answer is indefinite automatic fallback with no selected-era signal: it makes deployment look healthy while modern-only behavior is never exercised.
Roll out in stages: prove the two era-specific suites, deploy observable automatic negotiation where compatibility is required, watch actual fallback, migrate the remaining callers, and then tighten selected paths with a revision pin. Remove the legacy path only after its observed use reaches the condition your team set in advance. That sequence preserves compatibility without silently mixing the lifecycle, interaction, and notification rules of two different wire contracts.
Sources
- 2026-07-28 specification changelogmodelcontextprotocol.io
- 2026-07-28 release explanationblog.modelcontextprotocol.io
- TypeScript SDK guide for supporting revision 2026-07-28ts.sdk.modelcontextprotocol.io
See also
How to run upload moderation from an agent through Cloudinary's Analysis MCP server: two thresholds, held delivery, human review, sampling and a decision log.
How to create and update Cloudinary named transformations through an MCP server, and why the update step is both the point and the risk.
How hostile instructions enter through MCP results, and why tool restriction, scoped credentials, approval, and complete call logs contain them.
How to turn on cloudinary-embed-headers so every MCP tool result carries the rate-limit ceiling, remaining allowance, reset time and request ID.