MCP Streamable HTTP in Practice: From Local stdio to a Remote MCP Server

Deploy MCP Streamable HTTP with the 2026-07-28 protocol and Python SDK, covering stateless requests, proxies, auth, Origin checks, timeouts, and legacy compatibility.

Published · 2026-06-069 min readXBSTACK
Evidence 12 cited sources GitHub artifact
On this page

If you are deploying a new remote MCP server today, do not start with transport="sse", a /sse + /messages pair, or “one Mcp-Session-Id per client.” MCP 2026-07-28 changed the core protocol to a stateless request model, and the official Python SDK recommends streamable-http for deployed servers. Modern requests target the MCP HTTP endpoint and carry their protocol/client/capability metadata with the request itself.

This guide focuses on one job: moving a local stdio MCP server into a remote Streamable HTTP deployment that can be authenticated, scaled, observed, and debugged. For the authorization flow, see MCP OAuth authentication. For the protocol-session migration itself, see MCP 2026-07-28 stateless migration.

2026-10-16 protocol check: validate routing headers against the body

The official 2026-07-28 Streamable HTTP specification requires MCP-Protocol-Version and Mcp-Method for new-style POST requests, plus Mcp-Name for tools/call, resources/read and prompts/get. A reverse proxy must preserve these headers. Their values must match protocol metadata in the JSON-RPC body; the server is required to reject mismatches rather than silently trusting one representation.

Version boundary: do not apply new 2026 request requirements blindly to legacy 2025-era clients. Diagnose 400s by checking the actual client protocol version, SDK release, forwarded headers and request body. See the official release notes and the XBSTACK stateless migration guide.

stdio, legacy SSE, and Streamable HTTP are different transports

stdio remains a good fit for local tools. The host launches a child process and speaks MCP over stdin/stdout. The OS user, process boundary, and filesystem permissions naturally become part of the security boundary.

The old SSE transport is a legacy HTTP design with separate SSE and message endpoints. The current official Python SDK documentation explicitly says SSE has been superseded by Streamable HTTP and should not be used for new deployments. Therefore this legacy example:

mcp.run(transport="sse")

and the old /sse plus /messages architecture should not be presented as Streamable HTTP.

A new remote endpoint typically looks like:

https://mcp.example.com/mcp

The client sends MCP requests to that endpoint. Responses can be plain JSON or use a streaming response mode supported by the protocol/SDK. You do not need to invent a separate “POST messages endpoint + SSE subscribe endpoint” layer yourself.

The 2026-07-28 change: protocol requests no longer depend on a session

A legacy 2025-era Streamable HTTP flow often looked like this:

initialize
→ notifications/initialized
→ server assigns or accepts Mcp-Session-Id
→ later requests carry the session ID

MCP 2026-07-28 removes that core handshake and the protocol-level Mcp-Session-Id. A modern client can call server/discover first or simply send its first real request. Each request carries the protocol/client/capability metadata needed by the server.

Operationally, that means:

  1. Protocol correctness no longer requires sticky routing to one worker.
  2. A server must not infer modern client capabilities from old in-memory session state.
  3. Round-robin multi-replica deployment becomes easier at the protocol layer.
  4. Application state must be designed separately from protocol lifecycle state.

Stateless MCP does not mean your application is stateless. Approvals, carts, upload jobs, pagination cursors, and long-running work still need state. Store that state as explicit domain records or protected handles instead of hiding it inside protocol-session memory.

Minimal Python server

The current official Python SDK exposes Streamable HTTP directly:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP(
    "RemoteToolHub",
    stateless_http=True,
    json_response=True,
)

@mcp.tool()
def get_remote_status() -> dict:
    return {"status": "ok"}

if __name__ == "__main__":
    mcp.run(
        transport="streamable-http",
        host="127.0.0.1",
        port=8000,
    )

Verify it locally first at:

http://127.0.0.1:8000/mcp

Connect with the current MCP Inspector or a compatible client before adding Nginx, Caddy, Cloudflare, or another gateway. First prove the protocol works without the proxy; then introduce network variables.

If you mount the MCP server inside an existing Starlette/FastAPI application, the SDK also exposes streamable_http_app(). Pay attention to the host application’s lifespan requirements. The SDK may still have an object named session_manager for runtime/background work; that name does not mean the 2026-07-28 wire protocol has restored Mcp-Session-Id.

Put authentication and network controls in front of the tool server

A realistic minimal topology is:

MCP Client
   ↓ HTTPS
Reverse Proxy / Gateway
   ↓ authenticated request
MCP Streamable HTTP app
   ↓ least-privilege credentials
Tools / Database / Filesystem / SaaS API

A remote MCP endpoint should not be public simply because “only an AI client will call it.” Define at least:

  • TLS termination;
  • the authentication authority;
  • OAuth/token scope mapping;
  • Host and Origin validation;
  • whether browser clients are allowed;
  • which Mcp-* headers must survive the proxy;
  • request-size, concurrency, timeout, and rate limits;
  • downstream credentials used by each tool;
  • audit fields for principal, tool, resource, and trace.

Authentication and tool authorization are separate. A valid access token identifies a principal and grants some resource-server scope; it does not automatically authorize every tool or every resource. High-risk writes still need resource-level authorization, schema validation, idempotency, and—where appropriate—human approval.

What to configure in Nginx, Caddy, or another proxy

Older MCP guides often blame every HTTP problem on proxy_buffering. Troubleshooting should instead start from the symptom.

Connection fails immediately: check Host, Origin, and auth first

For 401/403/421-style failures, inspect:

  • whether the public hostname is allowed;
  • whether the proxy rewrites Host unexpectedly;
  • whether Origin matches server policy;
  • whether Authorization is preserved;
  • whether MCP-Protocol-Version and required Mcp-* headers survive the hop.

Plain JSON works but streaming stalls: then inspect buffering and timeouts

Buffering matters when the server actually returns text/event-stream or keeps a response stream open. Test:

  • whether the proxy/CDN buffers or caches the stream;
  • whether idle/read timeouts are shorter than normal tool execution;
  • whether the CDN supports the response mode;
  • whether disconnects cancel backend work;
  • whether very long work should become a durable task rather than one indefinitely open HTTP request.

There is no evidence for a universal “80% of MCP failures come from buffering” rule.

Serverless hits its timeout only on subscriptions/listen: inspect capabilities and disconnect propagation

MCP 2026-07-28 uses subscriptions/listen as a long-lived change-notification stream. The stream is supposed to remain open until the client or server closes it. The deployment problem appears when an application never intends to publish tool/resource changes, but the SDK still advertises tools/prompts/resources.listChanged=true and resources.subscribe=true; a compatible client can then open the listen stream automatically. If a serverless adapter fails to propagate client disconnects back to ASGI as http.disconnect, an otherwise idle subscription can pin one invocation until the platform timeout.

XBSTACK reproduced the lifecycle locally on 2026-09-20 with the official Python SDK, without emulating Lambda billing:

Python SDKserver/discoversubscriptions/listen
mcp 2.1.1completes and advertises listChanged/subscribe=truestill open after 1 second
mcp 2.2.0completes and advertises listChanged/subscribe=truestill open after 1 second
mcp 2.2.0 + diagnostic removal of the listen handleradvertises listChanged/subscribe=falseimmediately returns -32601 Method not found

This matches the lifecycle reported in Python SDK #3493. October 8 update: the public opt-out requested in Python SDK #3357 was merged in PR #3626 and explicitly shipped in v2.3.0: MCPServer(subscriptions=False) disables subscriptions/listen and advertises listChanged / subscribe as false. The separate TypeScript SDK #2650 reports serverless invocation cost from long-lived subscriptions. The 2.1.1 and 2.2.0 table remains a September reproduction; XBSTACK has not independently rerun the full Serverless path on 2.3.0.

For production troubleshooting:

  1. break latency down by MCP method and confirm whether subscriptions/listen is the only request that never ends;
  2. inspect server/discover for capabilities the application does not actually use;
  3. verify that the gateway/serverless adapter forwards client disconnects to the ASGI app;
  4. do not treat a larger function timeout as a real fix—it only extends the resource hold;
  5. on official Python SDK 2.3.0 and later compatible releases, test MCPServer("static-server", subscriptions=False) and verify server/discover, Method not found for a stray listen call, and real client reconnects; use private handler removal only as historical diagnostic containment. Check the v2.3.0 compatibility notes, including httpx2>=2.10.0, before upgrading.

An enabled protocol-defined subscription stream is intentionally long-lived; the engineering question is whether the service needs subscriptions at all and whether its hosting runtime can safely carry such responses. A static server should explicitly disable unused subscriptions rather than masking them with an increased function timeout.

Legacy Streamable HTTP lifecycle regressions: incomplete body, GET Accept, and stale-session 404

On September 24, 2026, XBSTACK locally reproduced the Python SDK #3494 and #3503 reports. Both live on the legacy 2025-era initialize / standalone GET compatibility path, so they should not be generalized into “the MCP 2026-07-28 stateless core is broken.”

The first check targets Python SDK #3494. On macOS 26.6.2 arm64 with Python 3.13.15, FastMCP 4.0.3, mcp 2.2.0, uvicorn 0.52.4, httpx 0.28.1, and anyio 4.14.2, I started 20 fresh uvicorn servers and sent one legacy initialize POST to each. Starting with the fourth attempt, the run repeatedly produced:

ASGI callable returned without completing response.
RemoteProtocolError: peer closed connection without sending complete message body

The final result was 17 failures out of 20 attempts. That matches the upstream failure shape and the reported pattern that repeated server start/stop cycles increase failure probability. It reproduced here without the upstream Linux one-CPU container constraint. The supported conclusion is still narrow: this legacy initialize + repeated fresh-server lifecycle can produce an incomplete ASGI body on the tested stack; it does not prove every Streamable HTTP request is affected.

The second check targets Python SDK #3503. With Python 3.14.7, mcp 2.2.0, and uvicorn 0.52.4, the legacy client’s standalone GET actually sent:

Accept: application/json, text/event-stream
Content-Type: application/json

That channel is then consumed only through the SSE reader. A diagnostic wrapper deliberately honored the advertised JSON representation; the GET stream disconnected and retried while an ordinary tools/call still returned hi. Operationally, that is more subtle than a hard session failure: normal tool traffic can look healthy while the notification/back-channel is already gone. The boundary matters: a compliant MCP server should answer that standalone GET with SSE or 405. The JSON response is a diagnostic probe of what the client advertises but cannot consume, not a recommendation for server behavior.

The third check targets Python SDK #3556. On September 25, 2026, XBSTACK used a deterministic transport-level fixture to isolate the 404 recovery branch on mcp 1.28.1 / Python 3.11.15 and mcp 2.2.0 / Python 3.14.7. The fixture installed the known stale session id stale-session-xbstack and made two consecutive tools/list POSTs receive HTTP 404. Both SDK lines produced the same behavior:

request 1 -> Session terminated
request 2 -> Session terminated
session id after both 404s -> stale-session-xbstack
outbound methods -> tools/list, tools/list
automatic initialize -> none

In the tested legacy client path, the 404 does not discard the old Mcp-Session-Id; the next request reuses the same stale id and no replacement InitializeRequest is emitted. The fixture deliberately tests the transport recovery contract rather than every real proxy, deployment restart, idle timeout, or network timing condition. The supported conclusion is narrower and more useful: the client’s current 404 branch itself does not perform automatic recovery.

Add three regression/recovery cases to legacy compatibility testing:

  1. do not test only one long-lived server; repeatedly start/stop fresh servers on the same event loop and initialize each one;
  2. capture the actual standalone GET / DELETE request headers and avoid advertising representations the client cannot consume or sending Content-Type: application/json on bodyless requests;
  3. when a request carrying an old Mcp-Session-Id gets 404 / Session terminated, treat the protocol session as lost until the upstream client recovers automatically: discard the dead client/session, initialize a fresh one, and only replay operations that are safe and idempotent. External writes and tool side effects need their own idempotency protection.

The reproducible scripts, raw logs, and version matrix are public in xbstack/mcp-streamable-http-lifecycle-regressions. It remains a versioned regression asset for this deployment guide rather than becoming another short-lived XBSTACK bug URL.

Large tool results fail: check size limits and result design

Large JSON payloads, images, documents, and logs can hit gateway limits, upstream memory pressure, client result limits, or simply waste model context. Prefer pagination, filtering, search, summaries, and explicit resource/attachment references instead of increasing every body limit.

How user isolation works without a protocol session

Do not model modern isolation as:

/sse handshake → generate session_id → global dict[session_id]

Start from a trusted principal and attach application state to explicit tenant/resource ownership:

access token
→ principal / tenant
→ tool authorization
→ business handle / record id
→ external durable store

For example:

approval_id = apr_123
owner = tenant_a:user_42
state = pending
expires_at = ...

When a later request supplies approval_id, the server re-checks owner, state, expiry, and permission. A handle is not trusted merely because it existed before. If a 2026 SDK feature such as requestState is used, treat the round-tripped value as untrusted input and integrity-protect it, bind it to the principal/method where appropriate, and expire it.

Multi-replica deployment: protocol statelessness is only one layer

At the protocol layer, a 2026-07-28 request can be handled by any compatible replica without a protocol session. Your application can still accidentally become worker-local by:

  • storing uploaded files only in one pod’s /tmp;
  • keeping approvals in a process-global dictionary;
  • storing refresh state only in memory;
  • assuming Tool B can see temporary data created by Tool A on the same worker;
  • running background jobs without a durable task/result store.

So “stateless MCP” solves protocol-level affinity, not every application-level scaling problem.

Supporting legacy clients

The official Python SDK’s modern line is designed to support both protocol eras: modern clients use the 2026 path while legacy clients can still use initialize/session behavior where supported.

Choose explicitly:

  • 2026-07-28 only: simplest architecture, but older clients may fail;
  • dual-era SDK compatibility: one deployment supports both, while tests and monitoring distinguish the lifecycle;
  • gateway routing: larger platforms can route protocol generations to dedicated compatibility layers.

Do not let legacy session behavior dictate the architecture of the modern path. Regression-test the same tool authorization and result semantics on every protocol generation you promise to support.

Pre-launch verification checklist

Before release, verify at least:

  1. The current Inspector/client reaches /mcp directly without the proxy.
  2. Host, Origin, Authorization, and protocol headers survive the proxy.
  3. Unauthenticated requests are rejected.
  4. Authenticated principals can access only authorized tools/resources.
  5. A model selecting a high-risk tool does not itself grant execution permission.
  6. 429/5xx/timeout retries cannot duplicate writes.
  7. Streaming responses are not cached, buffered incorrectly, or cut off too early.
  8. Large results use limits/pagination instead of unlimited body sizes.
  9. Critical application state is not trapped inside one worker’s memory.
  10. Modern 2026-07-28 and every promised legacy client path have separate automated tests.

Final decision

If a tool only serves a local IDE, stdio may still be the simpler and lower-exposure choice. Remote is not automatically more production-grade than local. Choose Streamable HTTP when you actually need cross-device access, team sharing, centralized authentication, gateway auditing, or horizontal scale.

The real 2026 migration is three separations at once:

  • transport: local subprocess → remote /mcp;
  • protocol state: legacy session → per-request modern metadata;
  • application state: process memory → durable state with identity, authorization, and expiry.

That is what turns a remote MCP endpoint into a production-capable service rather than an old SSE tutorial behind a new title.

More to Explore

Topic path / MCP

Continue from protocol details to production MCP governance

The MCP hub connects protocol fundamentals, transports, authentication, security, JSON-RPC debugging and production deployment without splitting the search intent across isolated guides.

Next step: harden the execution boundary

Full decision path →

Continue with the control that should be enforced before the next tool call, credential handoff, or production rollout.

Continue troubleshooting the runtimeMCP StreamableHTTPClientTransport POST Stays Pending After SSE Disconnect: Issue #2739 ReproducedA v1 MCP StreamableHTTPClientTransport POST can remain pending until request timeout after request-scoped SSE error/EOF. XBSTACK reproduced Issue #2739 on SDK 1.29.0 and 1.30.0.Harden permissions and securityMCP Filesystem Server in Practice: Enabling Claude / Cursor to Securely Read Local FilesBuild a secure MCP Filesystem Server for Claude or Cursor with Roots, path allowlists, read-only tool scope, symlink/path checks, Prompt Injection defenses, and audit boundaries.Harden permissions and securityMCP 2026-07-28 Security: Tool Scope, allowedRoots, OAuth, and AuditMCP 2026-07-28 security: per-request authorization, Tool Scope, server-enforced allowedRoots, Tool Poisoning, rug pulls, Tool Result injection, OAuth/CIMD, HITL, and audit logs.
Comments & evidence

DISCUSSION

Questions, verification and corrections

Sign in to comment. Every new comment is reviewed before publication; while pending, it is visible only to you and the administrator.

Sign-in required Reviewed before public
Loading the discussion…