MCP Stateless Spec Migration: What 2026-07-28 Breaks
Quick answer: The 2026-07-28 revision removes the initialize / notifications/initialized handshake and the Mcp-Session-Id header, so every request carries its own protocol version, client identity and capabilities in _meta. An MCP stateless spec migration is three jobs: move per-connection state into server-minted handles or the opaque requestState field, add the required Mcp-Method and Mcp-Name headers, and replace server-initiated requests with Multi Round-Trip Requests.
Last updated: August 2026
If you shipped an MCP server over a warehouse, a catalog or an orchestrator in 2025, you built against a stateful protocol: an initialize / notifications/initialized handshake, an Mcp-Session-Id header pinning a client to one instance, and a standalone GET stream the server used to push its own requests back at you. The 2026-07-28 revision removes all three.
This is not a version bump you absorb by bumping a dependency. The spec calls the removal of server-initiated requests a breaking change. If your server minted a session id and stashed anything behind it, that code has nowhere to live. If your gateway routed on Mcp-Session-Id, it routes on nothing.
Most of the replacement is better for data teams, and one addition lets you make an agent stop and ask before it runs a MERGE. Here is the MCP stateless spec migration, then the part that matters more.
What the 2026-07-28 revision actually removed
The core is one line from the changelog: MCP is now stateless. Every request carries its protocol version and client capabilities in _meta, so a server never remembers a client between calls.
| Removed in 2026-07-28 | What you use instead |
|---|---|
initialize / notifications/initialized handshake | _meta per request: io.modelcontextprotocol/protocolVersion, clientInfo, clientCapabilities |
Protocol-level sessions, Mcp-Session-Id | Server-minted handles passed as ordinary tool arguments, or the opaque requestState field |
Server-initiated roots/list, sampling/createMessage, elicitation/create | Multi Round-Trip Requests: server returns inputRequests, client retries with inputResponses |
The GET stream endpoint, resources/subscribe and unsubscribe | subscriptions/listen, one long-lived POST-response stream, opt in by type |
SSE resumability (Last-Event-ID, event IDs) | Nothing. A broken stream loses the request; re-issue with a new request ID |
ping, logging/setLevel, notifications/roots/list_changed | Per-request io.modelcontextprotocol/logLevel in _meta |
| Experimental Tasks in the core protocol | The io.modelcontextprotocol/tasks extension: tasks/get polling, tasks/update |
One field is easy to miss and will break your client. Every result now carries a required resultType, "complete" or "input_required". Earlier-protocol servers omit it; clients must treat that as "complete".
The handshake is gone: what a request looks like on the wire
There is no connection setup. The first thing a client sends is the thing it wants. Version negotiation is optional, through a new server/discover RPC that servers must implement but clients need not call.
POST /mcp HTTP/1.1
Host: mcp.internal.example.com
Content-Type: application/json
Accept: application/json, text/event-stream
Authorization: Bearer <oauth-access-token>
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: revenue-analyst
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "revenue-analyst",
"arguments": { "query": "net revenue by segment, last 4 quarters" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": { "elicitation": {} }
}
}
}
Three headers are now obligations. MCP-Protocol-Version must match the protocolVersion in the body. Mcp-Method mirrors method on all requests. Mcp-Name mirrors params.name or params.uri on tools/call, resources/read and prompts/get.
The gotcha is validation. Any server that processes the body must reject a request whose headers do not match it, with HTTP 400 and JSON-RPC error -32020 (HeaderMismatch). A gateway that rewrites tool names for routing will now produce -32020 on calls that used to work. Non-ASCII values use the sentinel =?base64?VALUE?=, decoded before comparison. An intermediary enforcing policy on those headers should first check that MCP-Protocol-Version names a revision that requires header-body validation, or it is trusting values nothing validated.
Why stateless is good news if your MCP server sits on a warehouse
Session affinity was the worst property of the old transport for anyone running MCP as real infrastructure: sticky routing, a shared state store, or both. The spec's framing of the replacement pattern is that it works without a shared storage layer across server instances and without stateful load balancing, so any request can land on any instance.
- Autoscaling stops being a special case. Scale to zero, scale out, roll a deploy mid-conversation. No instance holds anything the next one needs.
- Your gateway authorises without deserialising JSON-RPC. The announcement is direct: a gateway or rate limiter can route and meter on those headers instead of parsing JSON bodies. Rate-limit
tools/callwithMcp-Name: execute_sqlper principal, payload untouched. - Tool parameters can be promoted to headers. Annotate a parameter in the tool's
inputSchemawithx-mcp-headerand conforming clients mirror it intoMcp-Param-{Name}. Mirror a tenant id and the gateway routes without reading the SQL. - Tool ordering is now a prompt-cache concern. Servers should return tools from
tools/listin a deterministic order, to improve LLM prompt cache hit rates. If yours builds that list from an unordered map, sort it.
On a Snowflake-managed MCP server this lands in your infrastructure, since Snowflake operates the endpoint. It matters most for whatever you built yourself.
How do you gate a MERGE now? Multi Round-Trip Requests
This addition matters most for data work. A server needing a human decision used to send its own elicitation/create down the SSE stream and block. Now it returns an InputRequiredResult: resultType: "input_required", an inputRequests map keyed by server-assigned identifiers, and an optional opaque requestState. The client answers and retries the original call with inputResponses under the same keys.
That is a confirmation gate with no server-side session, what you want in front of a destructive operation: a MERGE touching more rows than expected, a DDL change on a table with dependents, a query whose plan says tens of terabytes.
// Round 1. The server prices the change and returns instead of running it.
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "input_required",
"inputRequests": {
"approve_merge": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "MERGE into CUSTOMER_DIM will touch 412803 rows (batch B-2026-08-04). Apply?",
"requestedSchema": {
"type": "object",
"properties": {
"confirm": { "type": "string", "enum": ["apply", "cancel"] }
},
"required": ["confirm"]
}
}
}
},
"requestState": "<AEAD-protected blob: principal, batch id, row count, short TTL>"
}
}
// Round 2. The client retries the ORIGINAL call. New JSON-RPC id, state echoed byte for byte.
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "apply_customer_merge",
"arguments": { "batch_id": "B-2026-08-04" },
"inputResponses": {
"approve_merge": { "action": "accept", "content": { "confirm": "apply" } }
},
"requestState": "<the exact string the server sent in round 1>",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": { "elicitation": {} }
}
}
}
The spec is blunt here: servers must treat requestState as attacker-controlled input. Where it influences authorization, resource access or business logic it must be integrity-protected with HMAC or AEAD, and state that fails verification must be rejected. To bound replay, put the authenticated principal, a short expiry and an identifier for the originating request inside the protected payload. Read the row count back out of that verified state, not out of whatever the client just sent.
- The JSON-RPC
idmust differ between the initial request and the retry. They are independent requests, not a continuation. - Do not send an input request the client cannot serve. Servers must not include
elicitation/createif the client did not declareelicitationsupport. - Assume the client never comes back. Servers must not assume clients will fulfil the requests or retry. Do not leave a half-applied transaction waiting on it.
- Responses replace, they do not accumulate. If round three needs round one's answer, it goes in signed
requestState.
Cacheable list results, and the leak you can create with cacheScope
tools/list, prompts/list, resources/list, resources/read and resources/templates/list now return a CacheableResult. ttlMs is a freshness hint in milliseconds so clients cache instead of poll. cacheScope, "public" or "private", controls whether shared intermediaries may cache it.
Here is the trap. The same revision states list endpoints no longer vary per connection. Plenty of 2025-era warehouse MCP servers did vary them, returning a refund_ledger tool to finance and hiding it from everyone else. That was never access control, but it was an information boundary. Keep varying by principal, mark it cacheScope: "public", and a shared gateway may serve one tenant's list to another.
Default conservatively: cacheScope: "private" and a short ttlMs until you have a reason to widen. Move to "public" only for a list identical for every caller, and enforce visibility with grants at execution time rather than with what the list happens to show.
The cost trap: a dropped stream on a very large query
Removing SSE resumability is the change most likely to show up on a bill rather than in a stack trace. A broken response stream loses the in-flight request, and clients must re-issue it with a new request ID. There is no Last-Event-ID to resume from.
On a warehouse, re-issuing means re-executing. People assume Snowflake's result cache absorbs this. It usually will not: reuse requires an exact syntactic match including case and table aliases, unchanged data, unchanged micro-partitions, no non-deterministic functions, no external functions, no hybrid tables, and a role with privileges on every referenced object. An agent that rewrites its own SQL between attempts misses on the first condition alone.
USE ROLE ACCOUNTADMIN;
-- Cap a single agent statement. A runaway scan should die, not bill.
-- Set it on the warehouse so it applies to every session that runs here,
-- rather than trusting session parameters the agent controls.
ALTER WAREHOUSE agent_read_wh SET
WAREHOUSE_SIZE = 'SMALL'
AUTO_SUSPEND = 60
STATEMENT_TIMEOUT_IN_SECONDS = 120
STATEMENT_QUEUED_TIMEOUT_IN_SECONDS = 30;
-- Cap the day as well as the statement, because retries are now unbounded.
CREATE RESOURCE MONITOR IF NOT EXISTS agent_rm
WITH CREDIT_QUOTA = 50
FREQUENCY = DAILY
START_TIMESTAMP = IMMEDIATELY
TRIGGERS ON 75 PERCENT DO NOTIFY
ON 100 PERCENT DO SUSPEND
ON 110 PERCENT DO SUSPEND_IMMEDIATE;
ALTER WAREHOUSE agent_read_wh SET RESOURCE_MONITOR = agent_rm;
For genuinely long work, stop holding a stream open. Tasks moved into the io.modelcontextprotocol/tasks extension and now polls with tasks/get. A ten-minute backfill belongs behind a task handle, not an SSE connection an idle timeout kills at minute nine.
Tool poisoning: why read-only access is not a safety argument
OWASP catalogues the failure mode that actually gets data teams into trouble as MCP Tool Poisoning: indirect prompt injection against agents connecting to external tool servers over MCP, mapped to LLM01 and LLM06 in the OWASP Top 10 for LLM Applications.
The mechanism is a trust asymmetry that survives the protocol rewrite untouched: tool descriptions are validated at connection time, tool responses are not. OWASP's worked example is a get_compliance_status tool returning a response that impersonates a SOC2 directive and instructs the agent to read a sensitive file and post it to an attacker endpoint.
Translate that to a warehouse and the attacker never needs a malicious MCP server. Poisoned text sits in a ticket body, a customer-supplied name field, a column comment, a dbt model description. Your read-only tool is doing its job when it returns that row, and the row becomes instructions the moment it lands.
That is the specific reason "just give the agent read-only access" is not a safety argument. Read-only bounds tool A and says nothing about tool B. If the same context holds a write tool or a shell, the read tool is the vector and the other is the payload. OWASP names the enabling conditions: unvalidated responses, equal privilege for internal and external tools, and system prompts standing in for access controls.
Mitigations that map onto things data engineers already do
The five mitigations OWASP lists are not new disciplines. You already have primitives for all of them, now applied to a new caller.
- Enforce restrictions server-side. OWASP's wording is to implement access controls at the tool execution layer so injected instructions cannot override them. "Never write to production" in a prompt is a suggestion; a role without INSERT is a fact.
- Isolate privileged tools. Run high-privilege operations in a separate agent context external servers cannot reach: separate role, warehouse, MCP server object, service user.
- Constrain tool response format. Require structured output with fixed schemas rather than free text. 2026-07-28 loosened
inputSchemaandoutputSchemato any JSON Schema 2020-12 keyword and letsstructuredContentbe any JSON value, so the protocol will not be strict for you. - Maintain an allowlist of approved MCP servers. Vet servers before allowing connections. It is the mitigation most often skipped, because adding a server is a two-line config edit.
- Require explicit user confirmation for sensitive operations. Prompt outside the LLM context. That now has a protocol shape:
resultType: "input_required".
USE ROLE SECURITYADMIN;
-- Read path and write path are different roles on different warehouses.
CREATE ROLE IF NOT EXISTS agent_reader;
CREATE ROLE IF NOT EXISTS agent_writer;
GRANT USAGE ON WAREHOUSE agent_read_wh TO ROLE agent_reader;
GRANT USAGE ON DATABASE analytics TO ROLE agent_reader;
GRANT USAGE ON SCHEMA analytics.marts TO ROLE agent_reader;
GRANT SELECT ON ALL VIEWS IN SCHEMA analytics.marts TO ROLE agent_reader;
GRANT SELECT ON FUTURE VIEWS IN SCHEMA analytics.marts TO ROLE agent_reader;
-- The writer gets exactly one callable object, never the base tables.
GRANT USAGE ON WAREHOUSE agent_write_wh TO ROLE agent_writer;
GRANT USAGE ON DATABASE analytics TO ROLE agent_writer;
GRANT USAGE ON SCHEMA analytics.ops TO ROLE agent_writer;
GRANT USAGE ON PROCEDURE analytics.ops.apply_customer_merge(VARCHAR) TO ROLE agent_writer;
-- Separate service users, so a read session can never assume the write role.
GRANT ROLE agent_reader TO USER svc_mcp_reader;
GRANT ROLE agent_writer TO USER svc_mcp_writer;
-- Two MCP server objects, not one server with a mixed toolbelt.
USE ROLE data_platform_admin; -- holds CREATE MCP SERVER on analytics.ai
USE SCHEMA analytics.ai;
CREATE OR REPLACE MCP SERVER analyst_read
FROM SPECIFICATION $$
tools:
- name: "revenue-analyst"
type: "CORTEX_ANALYST_MESSAGE"
identifier: "analytics.marts.revenue_semantic_view"
title: "Revenue semantic view"
description: "Answer revenue questions from the governed semantic view."
$$;
CREATE OR REPLACE MCP SERVER ops_write
FROM SPECIFICATION $$
tools:
- name: "apply_customer_merge"
type: "GENERIC"
identifier: "analytics.ops.apply_customer_merge"
title: "Apply customer merge"
description: "Apply a reviewed customer merge batch."
config:
type: "procedure"
warehouse: "AGENT_WRITE_WH"
input_schema:
type: "object"
properties:
batch_id:
type: "string"
description: "Reviewed batch identifier"
$$;
GRANT USAGE ON MCP SERVER analytics.ai.analyst_read TO ROLE agent_reader;
GRANT USAGE ON MCP SERVER analytics.ai.ops_write TO ROLE agent_writer;
Splitting into two server objects is not tidiness. GRANT USAGE ON MCP SERVER decides whether a principal can discover the write tool, and an agent cannot be talked into calling a tool it cannot see.
Authorization: RFC 9207 and Client ID Metadata Documents
These are smaller, but they touch anything binding an MCP server to Okta or Entra ID, which on Snowflake means the OAUTH_AUTHORIZATION_SERVER configuration.
- Issuer validation. Authorization servers should include the
issparameter per RFC 9207, and clients must validate a presentissagainst the recorded issuer before redeeming the code. - Credentials are bound to their issuer. Clients must key persisted credentials by issuer identifier, must not reuse them with a different authorization server, and must re-register when it changes.
- Dynamic Client Registration is deprecated. RFC 7591 DCR gives way to Client ID Metadata Documents, kept only for authorization servers without CIMD support. Clients still using DCR must specify an
application_typeto avoid OpenID Connect redirect URI conflicts.
Deprecations and the twelve-month clock
This revision adopts a formal feature lifecycle: Active, Deprecated and Removed states, a minimum twelve-month deprecation window, and a published registry. Deprecated features still work; new implementations should not adopt them.
| Feature | State | Suggested migration |
|---|---|---|
| Roots | Deprecated | Pass directories or files via tool parameters, resource URIs, or server configuration |
| Sampling | Deprecated | Integrate directly with LLM provider APIs |
| Logging | Deprecated | Log to stderr on stdio, or use OpenTelemetry |
| HTTP+SSE transport (2024-11-05) | Deprecated | Streamable HTTP |
includeContext values "thisServer" and "allServers" | Deprecated | Omit the field or use "none" |
| OAuth 2.0 Dynamic Client Registration (RFC 7591) | Deprecated | Client ID Metadata Documents |
If you are giving up Logging, wire tracing instead. The revision documents OpenTelemetry trace context conventions for the _meta keys traceparent, tracestate and baggage, which suits a protocol with no session id to correlate on.
How to sequence an MCP stateless spec migration
All four Tier 1 SDKs speak 2026-07-28 as of release per the MCP announcement: TypeScript, Python, Go and C#, with Rust in beta. The same post reports close to half a billion downloads a month across them, which is the installed base you are migrating against.
- Treat the SDK major upgrade as its own change. The TypeScript v2 line was released alongside
2026-07-28, so the dependency bump and the protocol behaviour change arrive together. Ship and verify the upgrade before you also change what your gateway and clients expect on the wire. - Find every read of the session id. Grep for
Mcp-Session-Id, session-keyed caches, and sticky-routing rules in your load balancer config. Each becomes a handle or signedrequestState. - Add the headers and the validation together. Emitting
Mcp-MethodandMcp-Namewithout validating them against the body is the configuration the spec warns about: gateway and server trusting different sources of truth. - Convert server-initiated requests to
inputRequired, destructive tools first. That is where a confirmation gate earns its keep. - Cap the blast radius before you ship. Statement timeout, queued timeout, resource monitor, a role that cannot write.
- Then look weekly at what it actually ran. It is the only source of truth that does not depend on the agent telling you the truth.
-- What did the agent roles actually do this week?
-- Anything that is not a plain read, or any read over 1 TB, gets eyeballed.
SELECT
qh.start_time,
qh.role_name,
qh.query_type,
qh.execution_status,
qh.bytes_scanned / POWER(1024, 4) AS tb_scanned,
qh.total_elapsed_time / 1000 AS elapsed_seconds,
qh.query_tag,
LEFT(qh.query_text, 200) AS query_head
FROM snowflake.account_usage.query_history AS qh
WHERE qh.role_name IN ('AGENT_READER', 'AGENT_WRITER')
AND qh.start_time >= DATEADD('day', -7, CURRENT_TIMESTAMP())
AND (
qh.query_type NOT IN ('SELECT', 'DESCRIBE', 'SHOW')
OR qh.bytes_scanned > POWER(1024, 4)
)
ORDER BY qh.start_time DESC;
The protocol got simpler, which is genuinely good. It did not get safer. A stateless request is still a request whose arguments were assembled by a model that read a row somebody else wrote.
Related Articles
Frequently Asked Questions
Q: Do I have to rewrite my MCP server for the 2026-07-28 spec?
Only if you want to serve the new revision. The spec calls the removal of server-initiated requests a breaking change, so code that sent elicitation/create or sampling/createMessage mid-request must move to Multi Round-Trip Requests. Earlier revisions remain implementable, and the transport spec documents how a server keeps answering older clients while you migrate.
Q: What replaced the Mcp-Session-Id header in MCP?
Nothing at the protocol level. Sessions were removed outright. Servers needing cross-call state use server-minted handles passed as ordinary tool arguments, or the opaque requestState field on a multi round-trip result. Because that state travels through the client, the spec requires HMAC or AEAD integrity protection wherever it influences authorization.
Q: Is read-only access enough to make an AI agent safe?
No. OWASP's MCP Tool Poisoning entry describes poisoned tool responses being treated as trusted input by the model. A read-only tool returning a poisoned support ticket is a working injection vector, and any write-capable tool in the same context is the payload. Read-only bounds one tool, not the context.
Q: How do I require human confirmation before an agent runs a MERGE?
Return an InputRequiredResult with resultType: "input_required" and an elicitation/create entry in inputRequests, carrying the row count or estimated cost in the message. The client shows a human and retries the original call with inputResponses plus your echoed requestState, which you sign and bind to a short TTL.
Q: Is the HTTP+SSE transport still supported in MCP?
It is deprecated, not removed. The 2024-11-05 HTTP+SSE transport has been deprecated since protocol version 2025-03-26 and is now classified as Deprecated under the formal feature lifecycle policy, which carries a minimum twelve-month window before removal. New implementations should use Streamable HTTP instead.
Q: Which MCP SDKs support the stateless 2026-07-28 spec?
The MCP project's announcement states all four Tier 1 SDKs speak 2026-07-28 as of release: TypeScript, Python, Go and C#, with Rust in beta. In TypeScript that is the v2 line, published as @modelcontextprotocol/server and @modelcontextprotocol/client and released alongside the new revision, with v1.x still receiving fixes for a limited window.
