Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project
Transports
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-handler2.1.1 on@modelcontextprotocol/server2.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 SDKClientdefaults 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
Acceptheader listing bothapplication/jsonandtext/event-stream. The client MUST NOT send JSON-RPC responses.- If the POST body is a notification the server accepts, it MUST return
202 Acceptedwith no body. - If the POST body is a request, the server MUST reply with either
Content-Type: application/json(one JSON object) orContent-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.
- If the POST body is a notification the server accepts, it MUST return
- 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 theio.modelcontextprotocol/protocolVersionfield in the body’s_meta) andMcp-Method;tools/call,resources/read, andprompts/getrequests MUST also carryMcp-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 with400 Bad Requestand JSON-RPC error-32020(HeaderMismatch). The body stays the source of truth. - Custom headers from tool parameters. A tool’s
inputSchemaMAY annotate primitive parameters withx-mcp-header; conforming clients MUST mirror those argument values intoMcp-Param-{Name}headers, and MUST exclude a tool whose annotations violate the constraints fromtools/listrather than fail the whole listing. - Change notifications.
subscriptions/listenreplaces the old GET stream andresources/subscribe: the client POSTs a notification filter, and the response is a long-lived SSE stream that opens withnotifications/subscriptions/acknowledgedand then carries only the opted-in change notifications, tagged withio.modelcontextprotocol/subscriptionIdin_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/cancelledon 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
stdoutthat is not a valid MCP message, and MUST NOT write JSON-RPC requests at all; the client MUST NOT write anything to the server’sstdinthat is not a valid MCP message, and MUST NOT write JSON-RPC responses. - The server MAY write UTF-8 to
stderrfor any logging purpose (informational and debug included, not just errors); the client MAY capture, forward, or ignore it and SHOULD NOT assumestderroutput 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)
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-NameandMcp-Param-{Name}MUST match the body; a missing required header, a mismatch, or invalid characters gets400 Bad Requestwith-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
400withUnsupportedProtocolVersionError(-32022) listing its supported versions, and the client retries with a mutually supported one. An unknown method gets HTTP404with 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-Idin 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: nowhen opening SSE streams, and on long-livedsubscriptions/listenstreams 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/jsonbody; 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/listenstream 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-Idand 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:
403on connect. A server correctly validatingOriginrejects 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:
400on every request. Read the JSON-RPC body before guessing:-32020means a required header (MCP-Protocol-Version,Mcp-Method,Mcp-Name, or an expectedMcp-Param-*) is missing or does not match the body, and-32022means the protocol version is unsupported and thedata.supportedlist tells you what to retry with. A400with neither is the signal to consider a legacy fallback. - HTTP:
404on POST. If the body carries JSON-RPC-32601the 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:
405on 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 POSTsubscriptions/listeninstead. - HTTP: SSE stream dies after minutes. An intermediary idle timeout or the function’s
maxDurationending 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 strayconsole.log, a dependency’s banner) corrupts the framing and the client sees a dead or malformed server. Log tostderr. Embedded newlines in a message do the same damage.
Security implications
- Streamable HTTP has hard requirements. Servers MUST validate the
Originheader on all incoming connections to prevent DNS-rebinding attacks and MUST respond403 Forbiddento an invalid one; when running locally they SHOULD bind only to127.0.0.1, and they SHOULD implement authentication. This repository’s implementation of the Origin rule isexamples/secure-tools-server/src/origin.ts(in the repository):withOriginCheckwraps every example’s route, refuses a non-allowlistedOriginwith 403 beforecreateMcpHandlerruns, and lets requests without anOriginheader (non-browser clients) through to bearer-token authentication; the allowlist comes fromMCP_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-MethodandMcp-Namelet 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 copiesx-mcp-headerargument values intoMcp-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:createMcpHandlerwraps aconfigureServerfunction with aserverInfoblock, andapp/api/mcp/route.tsexports the handler asGET,POST, andDELETE. Runnpm run dev, connect MCP Inspector tohttp://localhost:3000/api/mcpover 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 legacyinitializehandshake rather than the per-request_metacarriage this page specifies.
For the same exchange annotated frame by frame, headers included, see the message trace.
Related
- Serverless sessions - where cross-call state lives now that the protocol defines no sessions
- MCP internals overview - the per-request lifecycle these transports frame
- Annotated message trace - a real Streamable HTTP exchange with the HTTP layer visible
- The 2026-07-28 stateless revision - what changed in this revision and why
- Authorization - the OAuth wiring that turns “SHOULD authenticate” into practice on Vercel
- Deployment -
vercel.jsonanatomy, environments, and protection for the endpoint this page describes
Bibliography
- mcp-handler README, Protocol Support (2.1.1: 2026-07-28 served natively, stateless 2025-era fallback, HTTP+SSE removed) - https://github.com/vercel/mcp-handler#protocol-support
- Model Context Protocol Specification, Transports: Overview, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/basic/transports
- Model Context Protocol Specification, Streamable HTTP, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http
- Model Context Protocol Specification, stdio, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio
- Model Context Protocol Specification, Versioning and Compatibility, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning
- Model Context Protocol Specification, Subscriptions, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions
- Model Context Protocol Specification, Cancellation, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation
- Model Context Protocol Specification, Key Changes, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/changelog
- Model Context Protocol Specification, Deprecated Features, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/deprecated
- Model Context Protocol, Feature Lifecycle - https://modelcontextprotocol.io/community/feature-lifecycle
- Model Context Protocol, Security Best Practices - https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices
- Vercel Documentation, Deploy MCP servers to Vercel - https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel
- Vercel, mcp-handler (GitHub repository) - https://github.com/vercel/mcp-handler