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-28What you use instead
initialize / notifications/initialized handshake_meta per request: io.modelcontextprotocol/protocolVersion, clientInfo, clientCapabilities
Protocol-level sessions, Mcp-Session-IdServer-minted handles passed as ordinary tool arguments, or the opaque requestState field
Server-initiated roots/list, sampling/createMessage, elicitation/createMulti Round-Trip Requests: server returns inputRequests, client retries with inputResponses
The GET stream endpoint, resources/subscribe and unsubscribesubscriptions/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_changedPer-request io.modelcontextprotocol/logLevel in _meta
Experimental Tasks in the core protocolThe 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.


HTTP - a 2026-07-28 tools/call against a warehouse MCP server
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.



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.


JSON - gating a MERGE behind input_required (annotated)
// 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.



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.


SQL - warehouse guardrails for an agent role
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.



SQL - role separation and one MCP server per privilege tier
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.



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.


FeatureStateSuggested migration
RootsDeprecatedPass directories or files via tool parameters, resource URIs, or server configuration
SamplingDeprecatedIntegrate directly with LLM provider APIs
LoggingDeprecatedLog to stderr on stdio, or use OpenTelemetry
HTTP+SSE transport (2024-11-05)DeprecatedStreamable HTTP
includeContext values "thisServer" and "allServers"DeprecatedOmit the field or use "none"
OAuth 2.0 Dynamic Client Registration (RFC 7591)DeprecatedClient 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.



SQL - weekly review of what the agent roles executed
-- 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.


Pranay Vatsal, Founder & CEO

Pranay Vatsal is the Founder & CEO of CelestInfo with deep expertise in Snowflake, data architecture, and building production-grade data systems for global enterprises.

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.