SmartGateSmartGate

MCP Protocol Versions and Transports: HTTP, SSE, stdio

MCP protocol versions and transports compared—stdio, SSE, and Streamable HTTP—and what breaks when clients, servers, and gateways fall out of sync on deploy.

Short answer: MCP speaks JSON-RPC 2.0 over one of two transports: Streamable HTTP for remote servers and stdio for local processes, with the older two-endpoint HTTP+SSE transport kept only for backwards compatibility. Version and capability negotiation happens once, in the initialize exchange — the client proposes a dated protocol revision, the server answers with the revision it will actually speak plus a capability map — and after that a session is optional, so a stateless server is free to answer every POST independently.

Key takeaways

  • Two transports are current. Streamable HTTP is one endpoint that accepts POST and GET; stdio is newline-delimited JSON-RPC over a child process's stdin and stdout. The 2024-era HTTP+SSE pair is legacy, and clients still send it.
  • initialize is the only negotiation in the protocol. If the server supports the revision the client asked for it must answer with that same revision; otherwise it answers with one it does support, and a client that cannot speak the answer is expected to disconnect.
  • Sessions are optional over HTTP. A server may hand out an Mcp-Session-Id on the initialize response, and the client must then echo it on every later request — but a server that never issues one is still conformant, and it survives a restart without a reconnect dance.
  • Clients must carry the agreed revision forward in an MCP-Protocol-Version header; a server that receives an unsupported value answers 400 instead of guessing.
  • The gateway work is small and specific: mount one endpoint, infer the transport for the audit row, and normalize the request body before a strict parser ever sees it.
  • Read tools/list from a live server before writing an integration. The transport decides whether your client can even reach that response; verifying both together is one test run, not two.

Who this is for: the transport decision before the wire format

If you are choosing how a host reaches a server, this page answers that, not how a message is shaped. The decisions are narrow: remote or local, session or stateless, today's transport or the legacy pair an older client still speaks. Get them right and the tool layer is invisible; get them wrong and you get timeouts that look like model problems, 404s that look like routing problems, and handshake races that look like bugs in your tools. The measurements are worth knowing because they tell you how the audience phrases the question: model context protocol carries about 12,100 monthly US searches, a2a protocol about 3,600, llm gateway about 1,600, streamable http about 880, and the narrower mcp sse, mcp transport and mcp stdio between 140 and 260 each. The transport layer is where engineers actually land when they search, which is why it gets its own page instead of a section inside the message-format reference. Everything below is written from a gateway's side of the wire: what the server must expose, and what has to be tolerated in front of it.

What the MCP protocol specifies: versions, transports, capabilities

MCP is JSON-RPC 2.0 carried by a transport, and the specification names exactly two of them — stdio and Streamable HTTP — while explicitly allowing a server to be stateless, so session management is optional (MCP transports). The server-side HTTP surface is therefore one mounted application on one path:

# backend/smartgate/api/mcp.py — source lines 399–406 (mount_mcp_routes)
def mount_mcp_routes(app: FastAPI) -> None:
    """Expose POST /mcp (Streamable HTTP, stateless)."""
    apply_mcp_session_compat()

    streamable_app = mcp.streamable_http_app()
    streamable_app.router.lifespan_context = _noop_starlette_lifespan(streamable_app)
    app.mount("/mcp", streamable_app)
    logger.info("MCP Streamable HTTP at POST /mcp")

The docstring is the whole design decision: POST /mcp, Streamable HTTP, stateless. Two ordering details matter more than they look. Session compatibility is applied before mounting, because the patches must already be in place when the first request arrives rather than when the first session is created. And the mounted app's lifespan context is replaced with a no-op, so that mounting an MCP application inside a larger FastAPI process does not start a second lifecycle — the failure mode that reads as "the tools work locally but not in production". The log line at the end is deliberate: the transport a deployment is really serving should be greppable without reading code. How the document itself is laid out — which chapters are normative and which are optional — is the subject of the MCP specification walkthrough.

Streamable HTTP replaced the two-endpoint transport

The two transports differ in ways a client can observe directly, which is why the gateway records which one a request arrived on:

# backend/smartgate/core/audit_enrichment.py — source lines 29–32 (infer_transport)
def infer_transport(path: str) -> str:
    if path.startswith("/mcp"):
        return "mcp_sse"
    return "rest"

The inference is a path prefix check, and it is applied per request so every audit row can say whether the call came in over MCP or over the ordinary REST surface. That is the difference between "the agent did something odd" and "someone hit the wrong endpoint with the right key". Streamable HTTP itself is the simpler of the two MCP transports: one endpoint path that supports POST for client messages and GET for a server-initiated stream, with the response to a POST allowed to be either a JSON body or an SSE stream. Sessions are optional in this transport — a server may assign an identifier at initialization and include it in an Mcp-Session-Id response header, and if it does, clients must send that header on every subsequent request. A server that assigns none is not a degraded server; it is the stateless case the specification anticipates, and it is the case that survives deploys, restarts, and horizontal scaling without any reconnect logic.

The MCP SSE transport is legacy, and clients still send it

The older transport used two endpoints — an SSE stream to receive, a POST endpoint to send — and it is the reason a compatibility shim exists at all. The patches are applied in one guarded function:

# backend/smartgate/api/mcp_session_compat.py — source lines 89–103 (apply_mcp_session_compat)
def apply_mcp_session_compat() -> None:
    """Idempotent patches applied before mounting MCP SSE."""
    global _PATCHED
    if _PATCHED:
        return

    ServerSession._received_request = _compat_received_request  # type: ignore[method-assign]
    ServerSession._received_notification = _compat_received_notification  # type: ignore[method-assign]

    if not hasattr(_stateless_server_run, "_orig"):
        _stateless_server_run._orig = lowlevel_server.Server.run  # type: ignore[attr-defined]
        lowlevel_server.Server.run = _stateless_server_run  # type: ignore[method-assign]

    _PATCHED = True
    logger.info("MCP session compat enabled (stateless SSE + relaxed init gate)")

Two things get replaced: the session class's request and notification handlers, and the low-level server's run method — wrapped, with the original kept on _orig so the wrapper delegates instead of reimplementing the loop. The _PATCHED module flag is what makes calling this safe from anywhere: the mount path calls it unconditionally during startup, and a second call is a no-op rather than a double patch. The honest naming matters too. This is a compatibility layer for clients that exist, not a claim that the specification was wrong; the specification moved on, and the shim's job is to keep a dated client working without forking the tool code. Note that the statelessness is a property of both the run loop and the transport: the compatibility run method does not require a completed handshake before answering a tools/list, because hosts connect, list, and disconnect in whatever order their transport allows. The revision history behind that shim — which chapters the first implementer wrote, and why the single endpoint displaced the pair — is told in Anthropic MCP clients and servers.

Choosing an MCP transport: stdio, SSE, or HTTP

The three transports you will meet in the wild answer different questions, and the choice is usually made for you by where the server runs:

Transport Reach Session Who uses it
stdio One client, one child process Process lifetime is the session Local servers, desktop hosts, editors, CI
Streamable HTTP Many clients, one endpoint Optional Mcp-Session-Id Hosted servers, gateways, anything multi-tenant
HTTP+SSE (legacy) Two endpoints (stream + POST) Server-assigned session Clients written before the current revision

The trade is not performance; it is identity and reach. stdio has no authentication problem because it has no network: the parent process starts the server and the OS handles the boundary, which is why desktop hosts default to it. Streamable HTTP has to solve authentication, tenant separation, and timeouts, and it gets an optional session identifier to do the middle one — a session id is a routing hint, not an authorization token, and treating it as one is a security bug rather than a design choice. Authentication is the part of that the message itself never carries, and auth for MCP traffic is what turns a POST body into a call the server can attribute. The legacy pair is strictly more moving parts than Streamable HTTP for the same capability, which is why it stayed behind as compatibility instead of becoming the norm. Practically: pick stdio when the server is local and single-user, Streamable HTTP when it is remote or shared, and support the legacy transport only for the clients you can name. What the server side of that split owns — the tool surface, the session it may or may not keep, and where the process runs — is set out in the what how to evaluate a server asks about in order.

MCP stdio for local servers, one URL for a hosted endpoint

Where a hosted server has to be reachable from a host that expects to spawn a process, the adapter is a single upstream URL rather than a second implementation:

# lib/connect/mcp-proxy.ts — source lines 8–11 (mcpStreamableUpstreamUrl)
function mcpStreamableUpstreamUrl(base?: string): string {
  const root = (base ?? smartgateBackendBase()).replace(/\/$/, "");
  return `${root}/mcp/`;
}

This is the whole shape of a stdio-to-HTTP bridge: derive the root, strip a trailing slash, append the MCP path, and hand that to a proxy that speaks stdin/stdout on the host side. Keeping it to four lines is the point — a bridge that grows business logic becomes a second server with its own bugs, and the tools behind it drift from the tools your remote clients see. The one trap worth naming: stdout is the protocol channel for a stdio server, so anything a bridge prints to stdout that is not a JSON-RPC message corrupts the stream. Diagnostics belong on stderr, always. The second trap is version skew — the local bridge and the hosted endpoint must agree on a protocol revision at initialize, and that agreement is per connection, not per installation, so upgrading one side is enough to move both.

MCP version negotiation, then body normalization

Negotiation is one exchange, and it has rules rather than a handshake loop. In the initialize request the client must send a protocol version it supports; if the server supports that revision it must answer with the same one, and if it does not, it answers with a revision it does support — the latest it can speak, in practice (MCP lifecycle). Because the answer can differ from the request, a client that cannot handle the returned revision should disconnect rather than continue and hope. In front of that exchange sits the normalization that keeps strict parsers from rejecting legal-but-awkward bodies:

# backend/smartgate/api/mcp_sse_compat.py — source lines 67–99 (normalize_jsonrpc_body)
def normalize_jsonrpc_body(body: bytes) -> bytes:
    """Coerce non-object JSON-RPC params (e.g. []) to {} for pydantic validation."""
    if not body:
        return body
    try:
        data: Any = json.loads(body)
    except (json.JSONDecodeError, UnicodeDecodeError):
        return body
    if not isinstance(data, dict):
        return body

    changed = _rewrite_direct_tool_method(data)

    params = data.get("params")
    if params is None:
        data["params"] = {}
        changed = True
    elif isinstance(params, list):
        # Cursor: tools/list, notifications/initialized with "params": []
        data["params"] = {}
        changed = True
    elif isinstance(params, dict):
        if _normalize_params_object(params):
            changed = True

    if not changed:
        return body
    logger.info(
        "Normalized JSON-RPC body: method=%s params_type=%s",
        data.get("method"),
        type(data.get("params")).__name__,
    )
    return json.dumps(data, separators=(",", ":")).encode("utf-8")

Three cases are handled, and each one is a client you can name. A message with no params gets an empty object. A message whose params is a list — the "params": [] that one widely used client sends for tools/list and notifications/initialized — also gets an empty object, with the comment naming the client so the reason survives refactoring. A message whose params is an object goes to the nested-field fixer. The changed flag is what makes the function idempotent: when nothing was rewritten the original bytes are returned untouched, which keeps signatures and ordering stable. The log line records the method and the resulting params type — the first thing worth grepping when a host's handshake fails for reasons the error message does not explain. The wider picture around that exchange — the three actors, the capabilities each side declares, and the transport the session runs on — is described in the protocol's actors and lifecycle.

A Streamable HTTP example: where normalization sits in the stack

Normalization is middleware, and that placement is a deliberate answer to "where can this possibly run?" — before the framework's own parser touches the body:

# backend/smartgate/api/mcp_sse_compat.py — source lines 102–138 (NormalizeJsonRpcMiddleware)
class NormalizeJsonRpcMiddleware:
    """ASGI middleware: fix params: [] before MCP sse.handle_post_message parses body."""

    def __init__(self, app: ASGIApp) -> None:
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] != "http" or scope.get("method") != "POST":
            await self.app(scope, receive, send)
            return

        path = scope.get("path", "")
        if "messages" not in path:
            await self.app(scope, receive, send)
            return

        chunks: list[bytes] = []
        while True:
            message = await receive()
            if message["type"] != "http.request":
                await self.app(scope, receive, send)
                return
            chunks.append(message.get("body", b""))
            if not message.get("more_body", False):
                break

        body = normalize_jsonrpc_body(b"".join(chunks))
        sent = False

        async def replay_receive() -> dict[str, Any]:
            nonlocal sent
            if sent:
                return {"type": "http.disconnect"}
            sent = True
            return {"type": "http.request", "body": body, "more_body": False}

        await self.app(scope, replay_receive, send)

The guards carry as much weight as the fix: it only acts on POST, and only on paths containing messages, so tool responses and unrelated routes pay nothing. The middleware buffers the body until more_body is false, normalizes the joined bytes once, and then hands the application a receive callable that replays the rewritten body. That replay is the standard ASGI idiom for "the bytes are consumed, here is what the app should see instead", and it is also where careless implementations break: the closure returns the body on the first call and http.disconnect on every call after that, because the application may ask again and there is genuinely no more data. Getting that wrong produces a server that looks like it hangs on a second read, followed by a client timeout that points at the network. The single-normalization detail is the other half: because the rewrite happens once in the middle of the stack, no tool ever has to know that some hosts send an empty list where the schema wanted an object. The request and result envelopes the rewrite protects are walked through in Model Context Protocol Example.

HTTP transport details a client has to get right

Beyond the endpoint path, a handful of details break integrations in ways that are hard to diagnose from an error message, so they are worth a checklist:

  1. Accept both content types. A POST to the MCP endpoint should advertise that the client can consume either application/json or text/event-stream, because the server may answer a request with a single JSON body or a stream.
  2. Echo the session header. If initialize came back with an Mcp-Session-Id, every later request must carry it; a server is entitled to reject requests that omit it.
  3. Send the negotiated revision. Clients using HTTP include the agreed protocol version in an MCP-Protocol-Version request header, and a server that receives an unsupported value is expected to answer 400 Bad Request rather than fall back silently (MCP transports).
  4. Expect a bare acknowledgment. Notifications and responses sent to the server may be answered with 202 Accepted and no body at all. A client that insists on parsing a JSON-RPC payload from that response will report a protocol error where nothing went wrong.
  5. Reconnect deliberately. The GET stream can drop; resumption semantics and session expiry are where proxy timeouts surface, so a client should re-initialize rather than assume the old session survived a long idle period.

None of this is exotic, and all of it is invisible until it fails. A gateway compresses the problem by owning these details once, in front of every tool.

A2A protocol: a different layer of the stack

A2A — the agent-to-agent protocol — is not a competing transport for MCP, and the confusion costs people weeks. MCP answers "how does a model call a capability with a schema attached?"; A2A answers "how does one autonomous agent hand a task to another agent that may be run by a different team, vendor, or platform?" An A2A interaction is task-shaped: a request has a lifecycle, may be long-running, may return artifacts, and may be delegated further. An MCP tool call is much smaller than that — it is one request, one result, and annotations that describe risk rather than a workflow. The transport question is answered separately in both protocols, which is precisely why arguing about "which protocol is better" is unproductive: they sit at different layers of the same stack. A realistic 2026 system uses MCP for the tool layer, A2A for the delegation layer, and a gateway in between for identity, budget, and audit. The measured demand underlines that this is a real question and not a naming quirk: a2a protocol carries about 3,600 monthly US searches, on the same order as the narrower MCP transport terms.

MCP vs A2A protocol: what each one actually carries

The cleanest way to hold the difference in mind is to ask what a message carries:

Question MCP A2A
What travels A JSON-RPC request naming a tool, a resource, or a prompt A task, its state, and its artifacts
Lifetime One request, one response A task lifecycle, possibly hours
Who the peer is A capability (server) and its client Another agent, with its own identity
Who typically asks The model, through its host A planner, a scheduler, or a human
What governance attaches to Budget, rate limit, and audit per call Authorization, delegation, and completion

Both rely on capability discovery, and both are transport-agnostic in principle, but the governance surface is where they differ for an operator: an MCP tool call is cheap enough to meter per request, while an A2A task is something you authorize and then observe. A gateway that meters tool calls and ignores delegation is leaving the expensive path ungoverned — the loop that costs money is usually the one that keeps re-planning, not the single fetch. That asymmetry is the reason the two belong in one architecture diagram and two different control planes.

Agentic RAG over MCP: what the gateway adds

An agentic RAG loop calls the model repeatedly, and the bill is driven by what is re-sent rather than by what is new. The gateway's contribution is one call that shrinks the context before the next model call:

# backend/smartgate/api/mcp.py — source lines 150–174 (smart_context_gate)
@server.tool(
        name="smart_context_gate",
        description=TOOL_DESCRIPTIONS["smart_context_gate"],
        annotations=tool_annotations("smart_context_gate"),
    )
    async def smart_context_gate(
        text: str = Field(description="Long text to compress before the host LLM call."),
        ratio: float = Field(
            default=0.5,
            description="Target compression ratio (e.g. 0.3–0.7).",
        ),
        purpose: str | None = Field(
            default=None,
            description="Optional goal to pre-filter paragraphs (step intent, user query).",
        ),
    ) -> str:
        _, registry = _app_state()
        module = registry.get("context_gate")
        ctx = _tool_ctx()
        return await _run_with_audit(
            "compress",
            ctx,
            module.process(ctx, text=text, ratio=ratio, purpose=purpose),
            {"ratio": ratio, "purpose": purpose},
        )

The tool is declared like any other — a name, a description pulled from a shared table, and annotations derived from the tool name — and the parameters are ordinary: the text to compress, a target ratio, and an optional purpose string that pre-filters paragraphs toward the current step's intent. The return statement is the interesting part. It does not answer directly; it routes through the audited call path (_run_with_audit) with the ratio and purpose recorded, so a compression call is visible in the same activity log as every other tool call. That is what makes an agentic loop governable: not a special compression endpoint, but the same metering, the same budget checks, and the same audit row as everything else. The default ratio of 0.5 is a starting point rather than a policy — a retrieval step that re-reads a document it already summarized can compress far harder than a first read, and the audit row is where you find out which of those two happened.

How an LLM gateway classifies MCP traffic

Once MCP and REST traffic share a deployment, every audit row needs to say which surface a request came in on, and that classification is deliberately tiny:

# backend/smartgate/core/audit_enrichment.py — source lines 35–40 (infer_route)
def infer_route(path: str, route_hdr: str) -> str:
    if route_hdr:
        return route_hdr
    if path.startswith("/mcp"):
        return "mcp"
    return "rest"

Three lines: honour an explicit route header if the caller supplied one, otherwise treat anything under the MCP path as MCP, and call everything else REST. Two properties make this worth keeping small. It is explicit-first — an operator or an upstream proxy can override the inference without a code change, which is the escape hatch you want during a migration. And it is total: there is no third answer and no failure mode, so aggregation over the audit table never has to handle a null route. For an LLM gateway the interesting consequence is that transport, route, and tool are three separate dimensions in the log. A spike in REST requests is a client problem; a spike in MCP requests with a single tool name is a loop; and neither would be visible if the gateway recorded only the model and the token count.

How SmartGate handles transports and versions

Transport Session What you get What it costs
SmartGate (hosted) Streamable HTTP, stateless, one POST endpoint (/mcp) None required; no session id to carry Seven smart_* tools with annotations, budget and rate controls, audit rows per call. Free: 2M tokens/mo, 120 req/min/key Platform fee; the savings-share only starts after $15 saved (pricing)
Local stdio server stdin/stdout in a spawned process The process lifetime Whatever that server implements, and nothing at the gateway layer Your own process management
Legacy HTTP+SSE server Two endpoints, server-assigned session Required, and expiring Works with older clients; more moving parts than the current transport Reconnect logic you have to maintain
Hand-rolled JSON-RPC shim Your own HTTP handling Yours to invent Bugs proportional to how much of the protocol you re-implement The protocol's own edge cases, rediscovered

The honest reading: if you run a single local server for a single host, stdio is the right answer and you need nothing from this page. The transport work only pays for itself when many hosts you do not control talk to one endpoint you do — which is exactly the situation a stateless Streamable HTTP mount with a normalization layer in front is built for.

How to get started

  1. Start with the endpoint, not the code. The Connect page generates the exact client block for Cursor, Claude Desktop, Windsurf, OpenClaw, or a generic client, and the MCP endpoint reference documents the path and headers.
  2. Ask for tools/list and read the annotations. Seven tools come back, each with a title and a read-only hint — that response is the contract your host can rely on. The tools reference lists them with their parameters.
  3. Then call one tool and watch the audit row. If the row is missing, the request never reached the gateway — a transport problem, not a tool problem.
  4. Compare against the layers around it. If you are deciding between a tool call and a delegated task, read the MCP vs A2A breakdown; if you are wiring a shared deployment, start from what an MCP gateway does.
  5. Then read the tool surface. The MCP tools reference documents the tools/list schemas and annotations your client will consume, and standing up an MCP server covers the server half of the same connection.

Start on Free — 2M tokens/month, all seven tools, 120 MCP requests/min per key: start free, then compare limits and log retention on the pricing page.

Frequently Asked Questions

What are the MCP protocol versions?

MCP does not enumerate versions in the message flow; each revision is a dated string. The client proposes one it supports in the initialize exchange, and the server answers with the revision it will actually speak — the latest it can speak when it cannot match the request. The client then carries the agreed revision forward in the MCP-Protocol-Version header.

Which MCP transport should I use?

Pick stdio when the server is local and single-user, Streamable HTTP when it is remote or shared, and support the older HTTP+SSE pair only for clients you can name. Streamable HTTP replaced the two-endpoint transport, and that pair is kept for backwards compatibility rather than as the norm.

Does MCP have more than one transport?

Two are current: Streamable HTTP and stdio. The earlier HTTP+SSE pair is legacy that exists for backwards compatibility, and clients written against it still work because servers and gateways keep the session shim in place.

Which protocol version should a client send?

The latest one it supports. If the server supports that revision it must answer with the same one; otherwise it answers with a revision it does support, and a client that cannot speak the answer should disconnect rather than continue.

Do I need a session id to use Streamable HTTP?

No. Sessions are optional in that transport. A server may assign an identifier at initialization and expect it back on later requests, but a stateless server that assigns none is conformant and does not need any reconnect logic after a deploy or restart.

What happens if I send the wrong protocol version?

An HTTP server can reject the request outright when the MCP-Protocol-Version header names something it does not support, answering with a 400 rather than negotiating silently. That is a designed failure, not a bug to work around.

Can I use MCP and A2A in the same system?

Yes, and most realistic systems do. MCP covers the tool layer where one request maps to one result, and A2A covers task delegation between agents. They are different layers, so the governance is different too: meter tool calls, authorize tasks.

Why does my integration work locally but not against a remote endpoint?

Check the header and session details before the tools. Most remote failures are a missing session header, an Accept header that does not allow an event stream, or a handshake race against a server that resets its session state on deploy.

Is the older SSE transport deprecated enough to ignore?

Deprecated is not the same as absent. If your users have clients configured against it, support it or document the upgrade — the compatibility layer exists precisely because ignoring it breaks real installations.

What MCP transports exist?

Common transports are stdio for local process servers, older HTTP+SSE streaming setups, and Streamable HTTP as the current remote transport shape.

Why do MCP protocol versions matter operationally?

Version and transport mismatches break handshakes, capability negotiation, and streaming behavior before any tool call succeeds.

Should production gateways still support SSE?

Many fleets still see legacy SSE clients during migration; plan for coexistence, but new remote paths should target Streamable HTTP per current MCP docs.

Where does SmartGate sit relative to transports?

As an MCP tool gateway it terminates or forwards tool traffic under policy; it does not replace the protocol specification or your model provider.

Limitations and what this does not do

  • Stateless means per-request identity. A stateless gateway must re-establish what it knows about a caller from the key and headers on every request; anything you relied on a session to remember has to be passed explicitly or stored server-side.
  • Compatibility layers are compatibility layers. They make dated clients work; they do not repair a malformed body, and they add a small amount of code that must be revisited when a client fixes its own bug.
  • Transport choice does not fix an expensive loop. Choosing Streamable HTTP over SSE changes how a request arrives, not how much it costs; budgeting, dedup, and compression are separate layers.
  • Version support moves. Supported revisions are maintained in code and can change between releases, so treat a specific revision string as an implementation detail of the deployment you are talking to.
  • This page is not a message-shape reference. Request and result envelopes, and the tool-call path, are documented separately in /industry/mcp-protocol-message-format-explained.

Sources