MCP in Production: What the 2026-07-28 Stateless Spec Actually Means for Your Platform

Sources

Every platform team that shipped a Model Context Protocol server in the last two years shipped it against a protocol that no longer exists. Revision 2026-07-28 of the MCP specification deleted the initialize handshake, deleted protocol-level sessions, deleted the SSE GET stream, deprecated Sampling, Roots, and Logging, and moved version negotiation into the body of every request. If your mental model of MCP is “JSON-RPC with an init dance and a session header,” that model is now dead and everything you deploy on top of it will behave strangely in production.

We rebuilt a small tool server against the new spec using the official Python SDK (v2.2.0, released 2026-09-07), drove it over Streamable HTTP, and captured the actual wire traffic with raw POSTs. This guide is the result: what changed, why the platform-engineering implications are bigger than the diff suggests, and the manifests, OAuth wiring, and failure modes you need to run MCP servers as production infrastructure rather than desktop-app curiosities.

TL;DR

The Structural Shift: From Chat-with-Docs to Stateless Agent Infrastructure

The first era of MCP — the one most tutorials and most production deployments still target — was architecturally a desktop-app protocol. A host like Claude Desktop or VS Code launched local servers over stdio, performed an initialize handshake, and kept a long-lived session per server. That design was fine for one developer’s laptop. It was hostile to everything a platform team actually needs: horizontal scaling, load balancing across replicas, transparent retries, and clean multi-tenancy. Sticky sessions on a stateful protocol means every replica holds per-connection state, every load balancer needs session affinity, and every network blip is a session-resumption problem.

Revision 2026-07-28 reworks MCP into what the Streamable HTTP transport now plainly states: the server operates as an independent process handling multiple clients, every JSON-RPC message is its own HTTP POST, and a broken stream just means the client re-issues the request with a new ID. Sessions, event replay, and the GET-side channel are gone. List endpoints no longer vary per connection. Cross-call state, when a server genuinely needs it, moves into explicit server-minted handles passed as ordinary tool arguments — state by value, not by session.

That is the structural shift: MCP stopped being a conversation protocol and became a request/response protocol with an agreed capability vocabulary. For platform engineers this lands in three places:

There is a second shift worth naming before anyone schedules a migration. The client side of the old protocol — servers asking the host to sample an LLM (sampling/createMessage), clients advertising filesystem roots — is being wound down. Roots, Sampling, and Logging are all Deprecated as of 2026-07-28, with earliest removal at the first revision on or after 2027-07-28. The migrations the spec prescribes are blunt: pass paths via tool parameters or config instead of Roots; call LLM provider APIs directly instead of Sampling; log to stderr or OpenTelemetry instead of the MCP Logging feature. The protocol is narrowing to “server exposes tools/resources/prompts; client calls them” — and that narrowing is exactly what makes it deployable as platform infrastructure.

Architectural Blueprint

Here is the topology this guide builds toward: a fleet of stateless MCP tool servers behind an ingress, OAuth 2.1 in front, per-request identity and trace context, and a host-side gateway that handles discovery, caching, and progressive tool loading.

flowchart TB
    subgraph HOST["MCP Host (Agent Runtime)"]
        GW["Host-side MCP client(s)"]
        DISC["server/discover probe
(version + capabilities, optional)"] GW --> DISC end subgraph EDGE["Platform Edge"] ING["Ingress / Gateway
TLS, Origin validation, rate limit"] AUTHN["OAuth 2.1 resource server
RFC 9728 PRM discovery
token: audience-bound, issuer-keyed"] ING --> AUTHN end subgraph FLEET["Stateless MCP server fleet (n replicas)"] S1["Replica 1
POST /mcp"] S2["Replica 2
POST /mcp"] S3["Replica n
POST /mcp"] OTEL["OpenTelemetry middleware
traceparent in _meta"] end subgraph BACKING["Backing systems"] TOOLS["Databases, CI, cloud APIs
(per-tool scoped credentials)"] REG["MCP Registry
(server.json metadata, preview)"] end GW -->|"POST tools/call
_meta: protocolVersion 2026-07-28
headers: Mcp-Method, Mcp-Name"| ING AUTHN --> S1 AUTHN --> S2 AUTHN --> S3 S1 --> TOOLS S2 --> TOOLS S3 --> TOOLS OTEL -.-> S1 REG -.->|"publish server metadata"| S1

Three properties of this diagram are the whole story. First, there are no session arrows: every path from host to replica is a self-contained POST, so the fleet scales horizontally and survives replica churn without draining sessions. Second, identity is enforced at the edge (OAuth 2.1 per the authorization model), not inside the tool. Third, discovery and caching live host-side: the host probes server/discover once, caches list results using ttlMs/cacheScope, and only injects tool definitions into context on demand — the progressive discovery pattern the docs now recommend for fleets of hundreds of tools.

The New Wire Format, Captured Live

Everything below is real traffic from our test rig: the incident-ops server (two tools: list_oncall, ack_incident) built on Python SDK v2.2.0, served over Streamable HTTP, probed with raw POSTs. No SDK client involved in these captures — this is the wire.

First, capability discovery. server/discover is the new mandatory RPC: no handshake, no session, one POST in, one JSON response out:

{
  "jsonrpc": "2.0",
  "id": "d1",
  "method": "server/discover",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": { "name": "probe", "version": "0.0.1" },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}
{
  "jsonrpc": "2.0",
  "id": "d1",
  "result": {
    "cacheScope": "private",
    "capabilities": {
      "prompts": { "listChanged": true },
      "resources": { "listChanged": true, "subscribe": true },
      "tools": { "listChanged": true }
    },
    "resultType": "complete",
    "supportedVersions": ["2026-07-28"],
    "ttlMs": 0,
    "_meta": {
      "io.modelcontextprotocol/serverInfo": { "name": "incident-ops", "version": "" }
    }
  }
}

Two production details in that response. The resultType field is new and mandatory on every result — "complete" or "input_required" (more on that below). And ttlMs/cacheScope are the SEP-2549 cache hints that let hosts cache list responses instead of re-listing tools every turn; this server returns ttlMs: 0 (no freshness hint) with cacheScope: private.

Now the workhorse. A tool call is one POST. The headers are required, and the Mcp-Name header must name the tool being called — not the client:

POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
Mcp-Method: tools/call
Mcp-Name: list_oncall
MCP-Protocol-Version: 2026-07-28

{
  "jsonrpc": "2.0",
  "id": "c1",
  "method": "tools/call",
  "params": {
    "name": "list_oncall",
    "arguments": { "team": "sre" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": { "name": "probe", "version": "0.0.1" },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}
{
  "jsonrpc": "2.0",
  "id": "c1",
  "result": {
    "content": [
      { "text": "{\n  \"primary\": \"carol\",\n  \"secondary\": \"dave\"\n}", "type": "text" }
    ],
    "isError": false,
    "resultType": "complete",
    "_meta": {
      "io.modelcontextprotocol/serverInfo": { "name": "incident-ops", "version": "" }
    }
  }
}

And the error path — a tool raising ToolError returns HTTP 200 with isError: true, not an HTTP error. This distinction matters enormously for your alerting: a fleet of MCP servers can be perfectly green on HTTP 5xx metrics while every tool call fails semantically. Monitor isError rates, not just status codes:

{
  "jsonrpc": "2.0",
  "id": "c2",
  "result": {
    "content": [
      { "text": "Error executing tool list_oncall: Unknown team: does-not-exist. Known teams: platform, sre", "type": "text" }
    ],
    "isError": true,
    "resultType": "complete",
    "_meta": {
      "io.modelcontextprotocol/serverInfo": { "name": "incident-ops", "version": "" }
    }
  }
}

What the SDK rejects at the door

The spec’s new enforcement points are not decorative. We probed them deliberately:

Probe (raw POST against /mcp)Actual responseSpec reference
No MCP-Protocol-Version header400 “Bad Request: Missing session ID” — the SDK routes header-less requests to the legacy 2025-11-25 session transport (era routing)Streamable HTTP: protocol version header
Header version ≠ body _meta version400 {"code": -32020, "message": "mcp-protocol-version header does not match the request envelope's protocol version"}SEP-2243, HeaderMismatch
Mcp-Name ≠ tool name param400 {"code": -32020, "message": "mcp-name header does not match the request body's 'name' parameter"}SEP-2243, HeaderMismatch
Unsupported version in _meta400 with UnsupportedProtocolVersionError semantics; client retries or surfaces error; server lists supported versionsserver/discover + versioning

Read the first row twice if you operate a fleet. The Python SDK ships two HTTP stacks behind one endpoint and routes by the presence and value of the MCP-Protocol-Version header: header absent (or a legacy value) lands you on the 2025-11-25 session-based transport with its Mcp-Session-Id machinery; header present with a modern value lands you on the single-exchange 2026-07-28 path. That is backward-compatibility done right, but it means mixed-version traffic in the same fleet silently gets different semantics, different failure modes, and different observability. Pin your fleet, pin your clients, and alert on era-mixing.

Build the Server: Real Code, Real Run

Our test server is deliberately boring — an incident-operations server with two tools — because boring is what you want when validating a protocol revision. The entire implementation on SDK v2.2.0:

import logging
import sys

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

logger = logging.getLogger(__name__)

mcp = MCPServer("incident-ops")


@mcp.tool()
def list_oncall(team: str) -> dict:
    """Return the current on-call rotation for a team.

    Args:
        team: Team slug, e.g. platform or sre.
    """
    rotations = {
        "platform": {"primary": "alice", "secondary": "bob"},
        "sre": {"primary": "carol", "secondary": "dave"},
    }
    if team not in rotations:
        raise ToolError(f"Unknown team: {team}. Known teams: {', '.join(rotations)}")
    return rotations[team]


@mcp.tool()
def ack_incident(incident_id: str, note: str) -> dict:
    """Acknowledge an incident.

    Args:
        incident_id: Incident identifier, e.g. INC-1234.
        note: Acknowledgement note to record.
    """
    if not incident_id.startswith("INC-"):
        raise ToolError("incident_id must look like INC-1234")
    logger.info("ack_incident called for %s", incident_id)  # stderr, never stdout
    return {"incident_id": incident_id, "acked": True, "note": note}


def main():
    logging.basicConfig(stream=sys.stderr, level=logging.INFO)
    mcp.run(transport="streamable-http")


if __name__ == "__main__":
    main()

Run it with uv run --with "mcp[cli]>=2.2.0" python server.py and it serves on 127.0.0.1:8000/mcp. Two things we confirmed that the docs tell you but experience makes you believe: stdio servers must never print to stdout (the SDK’s own quickstart warns this corrupts the JSON-RPC stream — log to stderr), and the run() signature takes transport="stdio" | "sse" | "streamable-http" with host, port, streamable_http_path, json_response, stateless_http, event_store, max_request_body_size, and transport_security kwargs on the HTTP path.

The client side is equally short. A URL means Streamable HTTP; mode="auto" probes server/discover and falls back to the legacy handshake for older servers:

import asyncio
import json

from mcp import Client


async def main() -> None:
    async with Client("http://127.0.0.1:8000/mcp") as client:
        tools = await client.list_tools()
        print("TOOLS:", [t.name for t in tools.tools])

        result = await client.call_tool("list_oncall", {"team": "platform"})
        print("ONCALL:", result.content[0].text)

        err = await client.call_tool("list_oncall", {"team": "nope"})
        print("ERROR-PATH isError:", err.is_error, "|", err.content[0].text[:120])


asyncio.run(main())
TOOLS: [
  "list_oncall",
  "ack_incident"
]
ONCALL: {
  "primary": "alice",
  "secondary": "bob"
}
ERROR-PATH isError: True | Error executing tool list_oncall: Unknown team: nope. Known teams: platform, sre

The client worked first-try against the stateless server because the SDK handles the new headers (Mcp-Method, Mcp-Name, MCP-Protocol-Version) and per-request _meta automatically. The failure we hit was our own: the result attribute is is_error (snake_case), not isError — the SDK speaks Python, the wire speaks camelCase. Your hand-rolled client will not have that convenience; use the SDK.

Deploy It: Kubernetes Manifests for a Stateless Fleet

Stateless requests collapse the deployment story. No session affinity, no drain rituals, no sticky-session config on your ingress. One Deployment, one Service, one Ingress with the one non-obvious requirement the spec mandates: Origin header validation to prevent DNS-rebinding attacks against local servers, and authentication on every connection.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mcp-incident-ops
  labels:
    app: mcp-incident-ops
    protocol: mcp-2026-07-28
spec:
  replicas: 3
  selector:
    matchLabels:
      app: mcp-incident-ops
  template:
    metadata:
      labels:
        app: mcp-incident-ops
        protocol: mcp-2026-07-28
      annotations:
        # OTel context flows via _meta keys; export via OTLP
        instrumentation.opentelemetry.io/inject-python: "true"
    spec:
      serviceAccountName: mcp-incident-ops
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: server
          image: registry.internal/platform/mcp-incident-ops:2.2.0
          args: ["server.py"]
          env:
            - name: MCP_TRANSPORT
              value: streamable-http
            - name: OTEL_EXPORTER_OTLP_ENDPOINT
              value: http://otel-collector.observability:4318
          ports:
            - name: http
              containerPort: 8000
          readinessProbe:
            # server/discover is the health check for the modern era:
            # self-contained POST, no session state required
            httpGet:
              path: /mcp
              port: http
            initialDelaySeconds: 2
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /mcp
              port: http
            periodSeconds: 10
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: "2"
              memory: 512Mi
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
---
apiVersion: v1
kind: Service
metadata:
  name: mcp-incident-ops
spec:
  selector:
    app: mcp-incident-ops
  ports:
    - name: http
      port: 80
      targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mcp-incident-ops
  annotations:
    # Any replica can serve any request — no session affinity anywhere
    nginx.ingress.kubernetes.io/enable-session-affinity: "false"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "360"
    # Long-lived subscription streams need generous read timeouts
spec:
  rules:
    - host: mcp-incident-ops.internal.example.com
      http:
        paths:
          - path: /mcp
            pathType: Prefix
            backend:
              service:
                name: mcp-incident-ops
                port:
                  number: 80

Two deliberate choices in those manifests. The probes hit /mcp without the modern headers, so they land on the legacy transport path and get its 400 — which is still proof the process is alive and routing; if you want a spec-pure probe, send server/discover with the MCP-Protocol-Version header from a sidecar or use the SDK’s json_response mode against a dedicated health path. And proxy-read-timeout: 360 matters: subscriptions/listen is a long-lived POST-response SSE stream, and a proxy that idle-times-out at 60 seconds will silently kill change notifications for every connected host.

For multi-tenancy, per-request identity now travels as headers. The spec’s x-mcp-header mechanism (SEP-2243) lets tool parameters carry custom headers on the request — here is the pattern for a tenant-scoped call:

{
  "jsonrpc": "2.0",
  "id": "c4",
  "method": "tools/call",
  "params": {
    "name": "list_oncall",
    "arguments": { "team": "platform" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": { "name": "host-gw", "version": "1.0.0" },
      "io.modelcontextprotocol/clientCapabilities": {},
      "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
    }
  }
}

The traceparent key is not decoration — the changelog documents OpenTelemetry context propagation conventions for exactly these _meta keys, and the SDK’s OpenTelemetryMiddleware consumes them. With sessions gone, this is how a request stays traceable across host → ingress → replica → backing system.

Lock the Door: OAuth 2.1, PRM, and the Confused Deputy

The 2026 security story is the part most teams will skip and regret. MCP authorization follows OAuth 2.1 conventions, and the mechanics are now standard-web: an unauthenticated request gets 401 with a WWW-Authenticate header pointing at a Protected Resource Metadata (RFC 9728) document at /.well-known/oauth-protected-resource, the client fetches it, discovers the authorization server and scopes, and runs the code flow. On top of that, this revision hardens specifics: authorization servers SHOULD include iss per RFC 9207 and clients MUST validate it against the recorded issuer before redeeming codes; credentials are keyed by issuer identifier and MUST NOT be reused across authorization servers; Dynamic Client Registration (RFC 7591) is deprecated in favor of Client ID Metadata Documents.

For platform fleets, though, per-user consent is usually wrong. The Enterprise-Managed Authorization extension puts the corporate IdP in the decision seat: the client exchanges a corporate ID token for an Identity Assertion JWT Authorization Grant (ID-JAG), and the MCP authorization server validates the ID-JAG and issues the access token. Onboarding and offboarding become IdP policy, not a consent screen per server per employee.

And read the security best practices doc before you deploy a proxy-style server, because it documents a real confused-deputy attack against exactly the architecture most teams build first: an MCP proxy that talks to third-party APIs with a static client ID, allows dynamic client registration, and does not enforce per-client consent before forwarding. Under those conditions an attacker registers a malicious client with redirect_uri: attacker.com, gets the user’s browser to hit the proxy’s auth flow (consent cookie already set from a previous legitimate approval), and receives a code without the user ever seeing a fresh consent screen. Mitigations: no static client IDs to third-party AS without per-client consent, pin redirect URIs, and audit every registration. Tool poisoning and rug pulls (a server changing tool behavior after review) are the other two attack classes the doc names — the reason your registry and your audit trail matter.

Day-2 Operational Warnings

Who Should Skip This

If your “agents” are a single chat UI calling one OpenAI endpoint, you do not need MCP infrastructure — the protocol’s value appears at N tools × M hosts, not at one. If you are all-in on a single vendor’s closed tool-calling format and never plan to swap hosts or servers, MCP’s portability pitch is theoretical for you. And if you were about to build an MCP proxy to third-party APIs with a static client ID and no per-client consent — stop; the spec’s own security doc explains how that architecture leaks authorization codes.

Verdict

Revision 2026-07-28 is the revision that made MCP deployable. Stateless requests, header-enforced request identity, per-request trace context, cacheable list responses, and a narrowed (Sampling/Roots/Logging out the door) core turn what was a desktop-app integration protocol into something a platform team can actually operate: horizontal scale, standard OAuth, standard tracing, boring HTTP. The migration cost is real — every hand-rolled client and every proxy that assumed header-less JSON-RPC needs the SEP-2243 headers, and every sampling-dependent server needs a new plan — but the direction is unambiguous. Build new servers on 2026-07-28 semantics, put OAuth 2.1 with PRM in front of them, watch isError not status codes, and inventory your deprecated-feature usage before the 2027 removal window opens.

The MCP GitHub organization holds the spec, SDKs (TypeScript, Python, C#, Go, Rust at Tier 1), the Inspector, and the reference servers; the registry preview and the MCP Apps extension for interactive UI are the pieces still in motion. Commercial support paths are forming around the ecosystem — Anthropic’s Claude platform being the largest host-side example — but the protocol itself is vendor-neutral by design, which is the entire point.

References & Further Reading