# Transports

Canonical URL: https://vercel-mcp-reference.vercel.app/internals/transports/
Markdown: https://vercel-mcp-reference.vercel.app/internals/transports.md
Audience: engineer, architect, security. MCP spec version: 2026-07-28. Last reviewed: 2026-08-26. Status: stable.

> **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](#legacy-httpsse-2024-11-05) stays because deleting it would strand readers whose stacks still speak it.

## Plain-language explanation

**TL;DR:** MCP is a [JSON-RPC](https://vercel-mcp-reference.vercel.app/glossary/#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](https://vercel-mcp-reference.vercel.app/glossary/#streamable-http-transport) reaches a [server](https://vercel-mcp-reference.vercel.app/glossary/#server) over the network, and it is the transport that matters on Vercel, because a [Vercel Function](https://vercel-mcp-reference.vercel.app/glossary/#vercel-function) is invoked by HTTP requests and is not a resident process. The [stdio transport](https://vercel-mcp-reference.vercel.app/glossary/#stdio-transport) is for a server the [host](https://vercel-mcp-reference.vercel.app/glossary/#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](https://vercel-mcp-reference.vercel.app/internals/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](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/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`](https://github.com/vercel/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](#earlier-streamable-http-revisions)).

- **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](https://en.wikipedia.org/wiki/Server-sent_events) 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`](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions) 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning) 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](https://modelcontextprotocol.io/community/feature-lifecycle) (SEP-2596) and listed in the [deprecated features registry](https://modelcontextprotocol.io/specification/2026-07-28/deprecated), 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)

```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](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes).
- **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](https://vercel-mcp-reference.vercel.app/internals/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](https://vercel-mcp-reference.vercel.app/internals/serverless-sessions/#streams-and-keepalive) 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](https://vercel-mcp-reference.vercel.app/security/checklist/#deployment-posture) for the checklist items.
- **On Vercel, "SHOULD authenticate" is effectively MUST.** Every deployment is a public URL, including [preview deployments](https://vercel-mcp-reference.vercel.app/glossary/#preview-deployment) whenever [Deployment Protection](https://vercel-mcp-reference.vercel.app/glossary/#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](https://vercel-mcp-reference.vercel.app/security/authorization/) and verify [Authentication](https://vercel-mcp-reference.vercel.app/security/checklist/#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](https://vercel-mcp-reference.vercel.app/security/checklist/#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](https://vercel-mcp-reference.vercel.app/security/checklist/#session-handling) and [Serverless sessions](https://vercel-mcp-reference.vercel.app/internals/serverless-sessions/#security-implications).
- **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](https://vercel-mcp-reference.vercel.app/security/checklist/#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](https://vercel-mcp-reference.vercel.app/internals/message-trace/).

## Related

- [Serverless sessions](https://vercel-mcp-reference.vercel.app/internals/serverless-sessions/) - where cross-call state lives now that the protocol defines no sessions
- [MCP internals overview](https://vercel-mcp-reference.vercel.app/internals/overview/) - the per-request lifecycle these transports frame
- [Annotated message trace](https://vercel-mcp-reference.vercel.app/internals/message-trace/) - a real Streamable HTTP exchange with the HTTP layer visible
- [The 2026-07-28 stateless revision](https://vercel-mcp-reference.vercel.app/internals/spec-2026-07-28/) - what changed in this revision and why
- [Authorization](https://vercel-mcp-reference.vercel.app/security/authorization/) - the OAuth wiring that turns "SHOULD authenticate" into practice on Vercel
- [Deployment](https://vercel-mcp-reference.vercel.app/deployment/) - `vercel.json` anatomy, 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>
