Skip to content

Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project

Transports

Audience:engineerarchitectsecurityMCP spec 2026-07-28

Deprecation notice (SEP-2596): the HTTP+SSE transport from protocol revision 2024-11-05 is formally classified as Deprecated under the MCP feature lifecycle policy and is eligible for removal in a future revision. New implementations SHOULD NOT adopt it; migrate to Streamable HTTP. The legacy section below stays because deleting it would strand readers whose stacks still speak it.

Plain-language explanation

TL;DR: MCP is a JSON-RPC application protocol that runs over a transport: the actual pipe the bytes travel on. A transport is a binding, not a dialect; the message patterns are identical on every one. The spec defines two standard transports. The Streamable HTTP transport reaches a server over the network, and it is the transport that matters on Vercel, because a Vercel Function is invoked by HTTP requests and is not a resident process. The stdio transport is for a server the host launches as a local subprocess; on this stack it belongs to local development and CLI contexts. The 2026-07-28 revision reshaped Streamable HTTP around exactly the request/response grain a function already has: every message is its own POST, replies are a JSON body or a stream scoped to that one request, and there are no protocol sessions, no server-push GET channel, and no resumable streams. There is also a legacy HTTP+SSE transport from protocol revision 2024-11-05; it is formally deprecated, and on Vercel it drags a Redis dependency with it.

Wire status: the pinned stack (mcp-handler 2.1.1 on @modelcontextprotocol/server 2.0.0) serves the 2026-07-28 contract natively over Streamable HTTP and falls back to stateless 2025-11-25 Streamable HTTP for legacy clients; the SDK Client defaults to that legacy handshake unless you opt in to modern version negotiation, so the examples’ in-memory test suites exercise only the legacy path.

Think of it like the difference between piping two local programs together and calling a web service: the messages are the same, but launching a subprocess and talking HTTP have different rules, failure modes, and threats. This page covers the wire mechanics; Serverless sessions covers where cross-call state lives now that the protocol no longer defines sessions at all.

Formal protocol perspective

All JSON-RPC messages MUST be UTF-8 encoded, on every transport. Only two message directions exist: the client sends requests and notifications, and the server sends responses and notifications. Servers never initiate JSON-RPC requests; server-to-client interactions (sampling, elicitation, roots) are carried inside results as MRTR input requests. Custom transports are permitted as long as they preserve JSON-RPC framing, the message patterns, and the per-request metadata model; ones built on a reliable byte stream SHOULD reuse the stdio framing.

Streamable HTTP

The server MUST expose a single MCP endpoint (one URL path) that accepts POST. With mcp-handler that endpoint is a Next.js route handler at app/api/mcp/route.ts, built with createMcpHandler and reachable at /api/mcp; the route also exports the handler as GET and DELETE, but under 2026-07-28 only POST carries MCP traffic, and a modern-only server answers GET or DELETE with 405 Method Not Allowed (they existed in earlier revisions; see backward compatibility).

  • POST, client to server. Every JSON-RPC request or notification is a new HTTP POST to the endpoint, and the client MUST send an Accept header listing both application/json and text/event-stream. The client MUST NOT send JSON-RPC responses.
    • If the POST body is a notification the server accepts, it MUST return 202 Accepted with no body.
    • If the POST body is a request, the server MUST reply with either Content-Type: application/json (one JSON object) or Content-Type: text/event-stream (an SSE stream scoped to that request). The client MUST support both. On the stream the server MAY send notifications that relate to the originating request (progress, log messages) before the final response, it MUST NOT send independent JSON-RPC requests, and the final response SHOULD terminate the stream.
  • Required request metadata headers. The transport mirrors selected body fields into HTTP headers so intermediaries (load balancers, WAF rules, observability tooling) can route and inspect without parsing the body. Every POST that carries a request MUST carry MCP-Protocol-Version (matching the io.modelcontextprotocol/protocolVersion field in the body’s _meta) and Mcp-Method; tools/call, resources/read, and prompts/get requests MUST also carry Mcp-Name. The spec states these requirements for requests; it leaves the headers on a notification-only POST undefined, so send them there too but do not build a server that rejects their absence on notifications. Values that are not header-safe ASCII are carried in the Base64 sentinel format =?base64?...?=. Servers that process the body MUST validate that headers match it and reject mismatches with 400 Bad Request and JSON-RPC error -32020 (HeaderMismatch). The body stays the source of truth.
  • Custom headers from tool parameters. A tool’s inputSchema MAY annotate primitive parameters with x-mcp-header; conforming clients MUST mirror those argument values into Mcp-Param-{Name} headers, and MUST exclude a tool whose annotations violate the constraints from tools/list rather than fail the whole listing.
  • Change notifications. subscriptions/listen replaces the old GET stream and resources/subscribe: the client POSTs a notification filter, and the response is a long-lived SSE stream that opens with notifications/subscriptions/acknowledged and then carries only the opted-in change notifications, tagged with io.modelcontextprotocol/subscriptionId in _meta. Request-scoped notifications never ride this stream.
  • Cancellation. Closing a request’s SSE response stream MUST be treated by the server as cancellation of that request. There is no notifications/cancelled on Streamable HTTP; the disconnect is unambiguous because every request has its own stream.

Note the shape of this design: nothing outlives a single HTTP exchange except a stream the client explicitly asked to hold open. That is what makes Streamable HTTP the natural transport for serverless, and it is why this repo treats it as primary.

stdio

The client launches the MCP server as a subprocess. The server reads JSON-RPC messages from stdin and writes them to stdout. The framing rules are strict and easy to violate:

  • Messages are delimited by newlines and MUST NOT contain embedded newlines: one JSON message per line.
  • The server MUST NOT write anything to stdout that is not a valid MCP message, and MUST NOT write JSON-RPC requests at all; the client MUST NOT write anything to the server’s stdin that is not a valid MCP message, and MUST NOT write JSON-RPC responses.
  • The server MAY write UTF-8 to stderr for any logging purpose (informational and debug included, not just errors); the client MAY capture, forward, or ignore it and SHOULD NOT assume stderr output indicates an error.

All messages share the one channel, so notifications delivered for an active subscriptions/listen request are correlated by the io.modelcontextprotocol/subscriptionId field in _meta. Cancellation is the one transport-level difference from HTTP: there is no per-request stream to close, so the client MUST send notifications/cancelled referencing the request id. If the server process exits unexpectedly, the client SHOULD restart it; because the protocol is stateless, in-flight requests are simply retried against the fresh process and listen streams are re-established. You cannot deploy a stdio server to Vercel (there is no resident process for a host to spawn); in this repo stdio shows up when a host on your laptop launches a local server. Everything you deploy speaks Streamable HTTP.

Earlier Streamable HTTP revisions

Protocol revisions 2025-03-26 through 2025-11-25 used Streamable HTTP in a different shape: servers could mint a session via the Mcp-Session-Id header (terminated with HTTP DELETE), clients could open a standalone GET stream for server-initiated messages, servers could send JSON-RPC requests on SSE streams, and streams were resumable via Last-Event-ID. None of that survives in 2026-07-28. A modern-only server that receives such traffic SHOULD answer GET or DELETE with 405 Method Not Allowed, ignore any Mcp-Session-Id header without minting or echoing ids, and ignore Last-Event-ID. Era detection runs the other way too: a dual-era client attempts a modern request first and, on 400 Bad Request, inspects the body; a recognized modern JSON-RPC error means a modern server (retry with a supported version), while anything else means a legacy server and the client falls back to the initialize handshake. See Versioning and Compatibility for the era model.

Legacy HTTP+SSE (2024-11-05)

The old transport used two endpoints: a long-lived GET stream for server messages and a separate POST endpoint the stream advertised via an endpoint event. Deprecated in prose since 2025-03-26, it is now formally Deprecated under the feature lifecycle policy (SEP-2596) and listed in the deprecated features registry, which makes it eligible for removal in a future revision. On Vercel the design is actively hostile: the held-open GET stream and the POSTs are separate HTTP requests that can be served by different function instances, so server messages must be relayed through shared state. mcp-handler 2.x dropped HTTP+SSE outright: the sseEndpoint, disableSse, and redisUrl options are gone, Redis is no longer a dependency, and the SDK server package ships no SSEServerTransport. What a 2025-era client gets from the same handler is the stateless Streamable HTTP fallback (legacy initialize answered at 2025-11-25, no session id issued); a pre-Streamable-HTTP client that can only speak HTTP+SSE is not served at all. If you must still serve such a client, that is a separate, self-hosted deployment with the shared state this section describes, not a switch on this stack.

Request / lifecycle flow (Streamable HTTP)

Vercel FunctionClientVercel FunctionClientalt[single JSON response][server opens a request-scoped SSE stream]POST tools/call (Mcp-Method, Mcp-Name, MCP-Protocol-Version)application/json resultSSE: notifications/progressSSE event: final result, stream closesPOST subscriptions/listen (notification filter)SSE: notifications/subscriptions/acknowledgedSSE: notifications/tools/list_changed (stream stays open)
Vercel FunctionClientVercel FunctionClientalt[single JSON response][server opens a request-scoped SSE stream]POST tools/call (Mcp-Method, Mcp-Name, MCP-Protocol-Version)application/json resultSSE: notifications/progressSSE event: final result, stream closesPOST subscriptions/listen (notification filter)SSE: notifications/subscriptions/acknowledgedSSE: notifications/tools/list_changed (stream stays open)
Mermaid sequence diagramOpen in Mermaid Live Editor
Diagram source (Mermaid)
sequenceDiagram
    participant Client
    participant Server as Vercel Function
    Client->>Server: POST tools/call (Mcp-Method, Mcp-Name, MCP-Protocol-Version)
    alt single JSON response
        Server-->>Client: application/json result
    else server opens a request-scoped SSE stream
        Server--)Client: SSE: notifications/progress
        Server--)Client: SSE event: final result, stream closes
    end
    Client->>Server: POST subscriptions/listen (notification filter)
    Server--)Client: SSE: notifications/subscriptions/acknowledged
    Server--)Client: SSE: notifications/tools/list_changed (stream stays open)

Each arrow into the server is its own HTTP request, and on Vercel each may be its own function invocation; no handshake precedes the first tools/call, because every request carries its protocol version and capabilities in _meta. stdio is the same method set with simpler framing: launch the subprocess, exchange newline-delimited messages over stdin/stdout, terminate by closing stdin.

Key messages / state transitions

These are transport mechanics, not new JSON-RPC methods; the method set is identical across transports.

  • Framing. stdio: one UTF-8 JSON message per line, no embedded newlines. Streamable HTTP: exactly one JSON-RPC request or notification per POST body; SSE frames for streamed server messages.
  • Header mirroring and validation. MCP-Protocol-Version, Mcp-Method, and (where applicable) Mcp-Name and Mcp-Param-{Name} MUST match the body; a missing required header, a mismatch, or invalid characters gets 400 Bad Request with -32020 (HeaderMismatch). Intermediaries that enforce policy on these headers SHOULD first check the protocol version is one that mandates header-body validation.
  • Version errors are per-request. A server that does not implement the requested version answers 400 with UnsupportedProtocolVersionError (-32022) listing its supported versions, and the client retries with a mutually supported one. An unknown method gets HTTP 404 with JSON-RPC -32601, which is also how a client tells a modern server from a legacy endpoint that 404s without a JSON-RPC body. See the error-code policy.
  • No sessions. There is no Mcp-Session-Id in this revision, on any request or response, and list endpoints no longer vary per connection. Cross-call state is carried by explicit server-minted handles passed as ordinary tool arguments; Serverless sessions is that story.
  • Streams are disposable. A client closing a request’s response stream cancels the request. A stream that breaks for any other reason loses the in-flight request: there are no SSE event ids and no Last-Event-ID, so the client MUST re-issue the request as a new request with a new request id.
  • Keep-alive. Servers SHOULD send X-Accel-Buffering: no when opening SSE streams, and on long-lived subscriptions/listen streams are encouraged to emit periodic SSE comment lines (a line starting with :) so intermediaries do not drop the quiet connection.

Common misconceptions

  • Misconception: MCP is HTTP. Reality: MCP is JSON-RPC over a chosen transport. Streamable HTTP is primary on Vercel because that is what a function can speak, but stdio is first-class in the protocol, and the framing rules differ while the methods do not.
  • Misconception: Streamable HTTP always streams over SSE. Reality: for a request the server MAY return a single application/json body; SSE is one of two allowed reply modes, used when the server wants to stream progress before the result.
  • Misconception: the server can push me messages any time over a standing connection. Reality: the GET push channel is gone. Server-to-client interactions arrive as MRTR input requests inside results, and change notifications only flow on a subscriptions/listen stream the client explicitly opened, filtered to what it opted into.
  • Misconception: there is still a session id somewhere, it just moved. Reality: protocol sessions were removed outright (SEP-2567). A modern server ignores Mcp-Session-Id and never mints one; state that must outlive a request rides in handles the server hands out as data.
  • Misconception: a dropped SSE stream can be resumed where it left off. Reality: resumability left the protocol with the sessions that made it meaningful. A broken response stream means the request is lost and gets re-issued; a broken listen stream gets reopened, with no replay of missed events.

Debugging notes

  • HTTP: 403 on connect. A server correctly validating Origin rejects requests whose origin it does not allow. Check what origin the client sends; this is the DNS-rebinding defense working as specified, not a bug.
  • HTTP: 400 on every request. Read the JSON-RPC body before guessing: -32020 means a required header (MCP-Protocol-Version, Mcp-Method, Mcp-Name, or an expected Mcp-Param-*) is missing or does not match the body, and -32022 means the protocol version is unsupported and the data.supported list tells you what to retry with. A 400 with neither is the signal to consider a legacy fallback.
  • HTTP: 404 on POST. If the body carries JSON-RPC -32601 the server is modern and the method name is wrong; if the body is empty or unrecognizable you may be POSTing at a legacy HTTP+SSE server that does not host a modern MCP endpoint.
  • HTTP: 405 on GET or DELETE. Expected from a modern-only server: the standalone GET stream and DELETE termination no longer exist. A client that needs change notifications should POST subscriptions/listen instead.
  • HTTP: SSE stream dies after minutes. An intermediary idle timeout or the function’s maxDuration ending the invocation. For a request stream, re-issue the request with a new id; for a listen stream, reopen it and re-run the list calls you care about, because nothing is replayed. Read Serverless sessions for the keepalive story.
  • stdio (local dev): stray stdout breaks everything. A single non-JSON line on stdout (a stray console.log, a dependency’s banner) corrupts the framing and the client sees a dead or malformed server. Log to stderr. Embedded newlines in a message do the same damage.

Security implications

  • Streamable HTTP has hard requirements. Servers MUST validate the Origin header on all incoming connections to prevent DNS-rebinding attacks and MUST respond 403 Forbidden to an invalid one; when running locally they SHOULD bind only to 127.0.0.1, and they SHOULD implement authentication. This repository’s implementation of the Origin rule is examples/secure-tools-server/src/origin.ts (in the repository): withOriginCheck wraps every example’s route, refuses a non-allowlisted Origin with 403 before createMcpHandler runs, and lets requests without an Origin header (non-browser clients) through to bearer-token authentication; the allowlist comes from MCP_ALLOWED_ORIGINS. See Deployment posture for the checklist items.
  • On Vercel, “SHOULD authenticate” is effectively MUST. Every deployment is a public URL, including preview deployments whenever Deployment Protection is off (it is on by default for new projects, so check rather than assume). There is no loopback bind to hide behind, and with no handshake there is no “before auth” phase: every single request must present and pass its credential. Wire up OAuth per Authorization and verify Authentication before anything ships.
  • Mirrored headers are a policy surface and a leak surface. Mcp-Method and Mcp-Name let a WAF rule or rate limiter act per-tool without body inspection, which is exactly why the server MUST verify they match the body: an intermediary trusting an unvalidated header while the server executes the body value is a routing bypass. The same mirroring copies x-mcp-header argument values into Mcp-Param-* headers visible to every proxy and log on the path, so never annotate a sensitive parameter for mirroring. See Trust boundaries.
  • No session id means one less credential class. There is nothing transport-level to hijack or fixate; correspondingly, any state handle your tools mint is now the thing to protect. Handles are untrusted input carrying no authority; see Session handling and Serverless sessions.
  • stdio inherits process trust. Launching a server subprocess runs third-party code as your user; treat the binary as a supply-chain dependency. This is a local-development concern on this stack, but it is not a small one. See Inventory & supply chain.

Runnable example

  • examples/minimal-server (in the repository) is the smallest Streamable HTTP server on this stack: createMcpHandler wraps a configureServer function with a serverInfo block, and app/api/mcp/route.ts exports the handler as GET, POST, and DELETE. Run npm run dev, connect MCP Inspector to http://localhost:3000/api/mcp over Streamable HTTP, and watch the POST-per-message pattern in the network tab. Per the SDK status note at the top of this page, today’s wire capture still shows the legacy initialize handshake rather than the per-request _meta carriage this page specifies.

For the same exchange annotated frame by frame, headers included, see the message trace.

Bibliography