If you want an MCP server that runs as a real network service, one a remote client connects to over a URL instead of spawning as a local subprocess, you want the Streamable HTTP transport. This guide builds one with the official TypeScript SDK, then takes it the rest of the way: the exact wire contract the current spec revision requires, the HTTP headers that cause most of the "it works in curl but not in the client" tickets, Origin validation, OAuth, and how to run more than one instance behind a load balancer.
Two things to settle before you copy any code from elsewhere. First, the MCP spec defines exactly two transports today: stdio and Streamable HTTP. The older two-endpoint HTTP+SSE transport (one endpoint for POST, a separate one for the SSE stream) has been deprecated since the 2025-03-26 spec revision and is now formally classified as Deprecated under the feature lifecycle policy. (MCP spec, Streamable HTTP) Second, and more disruptive if you learned this transport last year: the 2026-07-28 revision reshaped Streamable HTTP. Protocol-level sessions, the standalone GET stream, and Last-Event-ID resumability are all gone. If a tutorial has you minting an MCP-Session-Id or wiring a GET route to push messages at the client, it is describing the transport as it stood through 2025-11-25.
What "Streamable HTTP" actually requires
The SDK implements this for you, but these details surface in production, so it is worth seeing the contract first. The server provides a single MCP endpoint (conventionally /mcp), and that endpoint accepts POST only. (spec)
- Every client message is its own HTTP POST to the endpoint, and the client must send an
Acceptheader listing bothapplication/jsonandtext/event-stream. The body must be a single JSON-RPC request or notification. The server replies with either a single JSON object (Content-Type: application/json) or an SSE stream (Content-Type: text/event-stream) scoped to that one request; the client has to handle both. A notification POST the server accepts gets202 Acceptedwith no body. (spec) - There is no handshake.
initializeandnotifications/initializedwere removed. Every request carries its own protocol version and client capabilities in_meta:io.modelcontextprotocol/protocolVersionandio.modelcontextprotocol/clientCapabilitiesare both required, andio.modelcontextprotocol/clientInfoshould be there too. Servers must implement the newserver/discoverRPC, which is how a client learns supported versions, capabilities, and identity without a negotiation round. - Two headers are required on every POST, plus a third on some.
MCP-Protocol-Version(for exampleMCP-Protocol-Version: 2026-07-28) andMcp-Method(mirrorsmethod) go on every request;Mcp-Name(mirrorsparams.nameorparams.uri) joins them specifically ontools/call,resources/read, andprompts/get. They exist so load balancers and gateways can route and inspect without parsing the body — which is also why the values must match the body. A mismatch or a missing required header is400 Bad Requestwith JSON-RPC error-32020(HeaderMismatch). An unsupported protocol version is400withUnsupportedProtocolVersionError(-32022), whosedata.supportedlists what the server does speak. An unknown method is404 Not Foundwith-32601. (spec) - The server must validate the
Originheader on all connections to prevent DNS rebinding, and when running locally it should bind to127.0.0.1, not0.0.0.0. An invalid Origin gets a403. (spec) - There are no sessions and no resumability. A server that implements only this revision should answer HTTP GET or DELETE with
405 Method Not Allowed, and should ignoreMcp-Session-IdandLast-Event-IDheaders if an older client sends them. If a response stream breaks, the in-flight request is simply lost: the client re-issues it as a new request with a new request ID. - Change notifications come from
subscriptions/listen. A client that wantsnotifications/tools/list_changed, the otherlist_changednotifications, ornotifications/resources/updatedPOSTs asubscriptions/listenrequest and opts in by name; the response to that request is a long-lived SSE stream carrying only what was asked for. It replaces both the old GET stream andresources/subscribe/resources/unsubscribe. Request-scoped notifications likenotifications/progressandnotifications/messagestill flow on the originating request's own response stream, never on the listen stream. Servers should sendX-Accel-Buffering: nowhen opening SSE and emit periodic SSE comment keep-alives (:\r\n) on long-lived streams. - Cancellation is closing the stream. This revision defines no client-to-server notifications over Streamable HTTP at all; the client closes the request's SSE response stream and the server must treat that as cancellation of that request. (Client-initiated
notifications/cancelledstill exists, but only on stdio — servers separately send it, on any transport, when they tear down asubscriptions/listenstream.)
How it used to work: through 2025-11-25, the same endpoint also answered GET (a standalone server-to-client SSE stream) and DELETE (session termination), the server could mint an MCP-Session-Id at initialization for the client to echo on every later request, servers could send their own JSON-RPC requests down an SSE stream, and events carried IDs so a dropped stream could be replayed via Last-Event-ID. None of those mechanisms are part of the current revision.
Note what is still not on that list: JSON-RPC batching. It was added in 2025-03-26 and removed again in 2025-06-18, and it did not come back, so do not build a Streamable HTTP server around batching arrays of requests.
One practical caveat before you build anything. The 2026-07-28 revision is brand new, and essentially every client shipping today still speaks 2025-11-25 or earlier. There is no fall-forward path: a legacy client hitting a server that implements only the current revision fails, because it will send initialize and get a 404, or POST without the required headers and get a 400. Real deployments should be dual-era, serving both the per-request-metadata shape above and the older initialize handshake. That is not something you hand-roll — the Tier 1 SDKs ship dual-era support and choose the behavior from how the client opens the conversation.
Setup
There are two supported TypeScript SDK lines right now, and picking one is the first real decision.
@modelcontextprotocol/sdk1.30.0 is the original monolithic package. It is still the npmlatesttag, still published, and not deprecated — but it targets theinitialize-handshake era of the protocol, not2026-07-28.- The 2.0.0 line, published 2026-07-27, is the current one. The monolith was split into
@modelcontextprotocol/server,@modelcontextprotocol/client, and@modelcontextprotocol/core, plus runtime adapters@modelcontextprotocol/node,/express,/hono, and/fastify(and@modelcontextprotocol/server-legacy, a frozen copy of the v1 SSE transport for gradual migration). It requires Node.js 20+. Moving an existing v1 codebase over is a codemod:npx @modelcontextprotocol/codemod@latest v1-to-v2 .— run it at the package root, not./src, because it rewritespackage.jsontoo. Names you will hit on the way:McpError→ProtocolError,ErrorCode→ProtocolErrorCode,StreamableHTTPError→SdkHttpError, andStreamableHTTPServerTransport→NodeStreamableHTTPServerTransport, now imported from@modelcontextprotocol/node. (v1 → v2 migration guide)
The code below is pinned to 1.x, because that is what the overwhelming majority of running servers are built on and what you will be reading against in most existing codebases; where a snippet is specific to the pre-2026-07-28 wire protocol, it says so. You need Node.js 18+ and two packages (Zod builds your tool input schemas).
$npm install @modelcontextprotocol/sdk@1.30.0 zod express$npm install -D @types/express tsx typescript
For a greenfield server on the current revision, install @modelcontextprotocol/server plus the adapter for your runtime instead, and read the sections below for the protocol shape rather than the exact import paths.
McpServer is the high-level server, and StreamableHTTPServerTransport is the transport that implements everything in the previous section.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';Define tools with registerTool. The current API takes a name, a config object whose inputSchema is a raw Zod shape (not a wrapped z.object(...)), and a handler. If you give it an outputSchema, return structuredContent alongside the human-readable content. (SDK docs/server.md)
import * as z from 'zod/v4';
function buildServer() {
const server = new McpServer({ name: 'weather-server', version: '1.0.0' });
server.registerTool(
'get-forecast',
{
title: 'Get forecast',
description: 'Get the forecast for a city',
inputSchema: {
city: z.string(),
days: z.number().int().min(1).max(7).default(3)
},
outputSchema: { city: z.string(), summary: z.string() }
},
async ({ city, days }) => {
const output = { city, summary: `Sunny for the next ${days} days in ${city}.` };
return {
content: [{ type: 'text', text: JSON.stringify(output) }],
structuredContent: output
};
}
);
return server;
}The server.tool(name, schema, handler) signature is deprecated in the 1.x SDK and removed in 2.0. Use registerTool. (SDK docs/server.md)
The default shape: a stateless server
Statelessness is no longer a mode you opt into. As of 2026-07-28 the spec's first sentence about the protocol is that MCP is a stateless protocol: servers must not rely on anything established by a prior request on the same connection, and an open connection is not a session or a conversation. Each POST is handled in isolation, which is exactly what makes it trivial to run many copies behind a load balancer (more on that below).
In the 1.x SDK the shape is: create a fresh transport per request with sessionIdGenerator: undefined, connect it to a server instance, hand the request off, and tear both down when the response closes. The minimal POST handler is what carries the lesson:
app.post('/mcp', async (req, res) => {
const server = buildServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined // stateless: the transport never mints a session ID
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
res.on('close', () => { transport.close(); server.close(); });
});
// Nothing to stream on GET, no session to delete, so both return 405 —
// which is exactly what the 2026-07-28 revision tells a server to answer.For the full runnable version (Express wiring, error handling, the 405 handler, and app.listen), see the SDK's stateless example.
Two details that aren't obvious. createMcpExpressApp() is a helper from the SDK that wires up an Express app with Origin/host validation already enabled, so you get DNS rebinding protection for free instead of remembering to add it. (SDK docs/server.md) And transport.handleRequest(req, res, req.body) is doing all the content negotiation, header handling, and JSON-RPC plumbing the spec section described; you never parse MCP-Protocol-Version yourself.
If you specifically want JSON-only responses and no SSE at all (for example, behind a proxy that mangles streaming), construct the transport with enableJsonResponse: true. (SDK docs/server.md)
What replaced sessions
If your tools genuinely need to share context across calls, the answer under 2026-07-28 is an explicit handle, not a transport session. The spec is blunt about it: the protocol has no concept of a state handle, so a server that needs one mints its own. A creation tool returns an opaque identifier in its result, and every later tool that operates on that state accepts the identifier as an ordinary argument in its inputSchema. The state itself lives in your database or cache, keyed by that identifier. The transport stays stateless, and any instance can serve any call.
Two consequences worth internalizing. tools/list, resources/list, and prompts/list must not vary per connection — they may still vary by the authorization presented on the request — so you cannot use a connection to show one client a personalized tool set. And because you own the handle's lifetime, you own its expiry and its authorization check: for an authenticated server, a handle is a name, not a capability, so validate the caller's authorization against it on every call rather than trusting possession of the identifier; for an unauthenticated server the handle is necessarily a bearer token, so generate it with real entropy (a UUIDv4, not a counter) and give it a bounded lifetime.
Before 2026-07-28: a server could ask the transport for a session instead. It returned an MCP-Session-Id header on the InitializeResult, the client echoed it on every subsequent request, the server answered 400 to non-initialize requests that omitted it and 404 once the session was gone, a GET on the same endpoint opened the server-to-client stream, and a DELETE terminated the session. In the 1.x SDK that means sessionIdGenerator: () => randomUUID(), an eventStore for Last-Event-ID replay, a transports map keyed by session ID, and GET/DELETE routes; the SDK's stateful example still shows the whole arrangement. You will keep meeting this code, because dual-era servers hold it open for older clients — just don't design new state around it.
Authentication: the OAuth Resource Server model
Hand-rolled JWT checks inside each tool, with a hardcoded secret, are the wrong pattern for a remote MCP server. Since the 2025-06-18 spec, and unchanged in 2026-07-28, an MCP server that requires auth acts as an OAuth 2.1 Resource Server: it advertises Protected Resource Metadata (RFC 9728), validates bearer tokens, and verifies that each token was actually issued for this server using Resource Indicators (RFC 8707). That last check is what stops a token minted for some other service from being replayed against yours.
The TypeScript SDK ships the pieces. requireBearerAuth is middleware you put in front of /mcp; mcpAuthMetadataRouter serves the discovery metadata; getOAuthProtectedResourceMetadataUrl produces the URL that ties them together.
import { mcpAuthMetadataRouter, getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
const mcpServerUrl = new URL('https://your-host.example.com/mcp');
// Your verifier validates the token against your auth server and, critically,
// confirms the token's audience (RFC 8707 resource indicator) is THIS server.
const tokenVerifier = {
verifyAccessToken: async (token: string) => {
const data = await introspect(token); // call your auth server's introspection endpoint
if (!data.aud || !audienceMatches(data.aud, mcpServerUrl)) {
throw new Error('Token was not issued for this MCP server');
}
return {
token,
clientId: data.client_id,
scopes: data.scope ? data.scope.split(' ') : [],
expiresAt: data.exp
};
}
};
app.use(mcpAuthMetadataRouter({
oauthMetadata, // your auth server's OAuth metadata
resourceServerUrl: mcpServerUrl,
scopesSupported: ['mcp:tools'],
resourceName: 'Weather MCP Server'
}));
const authMiddleware = requireBearerAuth({
verifier: tokenVerifier,
requiredScopes: [],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
});
app.post('/mcp', authMiddleware, mcpPostHandler);The audience check is the part people skip and the part that matters: a token whose aud doesn't match the configured resource must be rejected, so that a token minted for another service can't be replayed against yours. Note that this auth model applies to HTTP transports; stdio servers pass credentials through environment variables instead.
Two things 2026-07-28 changed at the edges. OAuth Dynamic Client Registration (RFC 7591) is now deprecated in favour of Client ID Metadata Documents, where the client uses an HTTPS URL as its client_id and the authorization server fetches that document to read the metadata and validate redirect_uris; keep DCR only for authorization servers that don't support CIMD. And authorization servers should now return the RFC 9207 iss parameter on authorization responses, which clients must validate against the issuer they recorded during discovery before redeeming the code.
Scaling out: running more than one instance
This used to be the hardest section of the guide. Under the old session model, the initialize POST landed on instance A, which minted a session and held the transport in its memory; the next request round-robined to instance B, which had never heard of that session ID and returned 400. Everything you did to scale Streamable HTTP was some answer to that one fact.
2026-07-28 deleted the problem rather than solving it. With no session ID to preserve and no Last-Event-ID replay to serve, there is nothing a second instance can be missing. Any instance can answer any request. You do not need session affinity, you do not need a Redis-backed eventStore, and you do not need the failover story that went with either. Plain round-robin is correct, and the cheapest distributed-session bug is the one the spec designed out for you.
A production Dockerfile stays boring on purpose: small base image, non-root user, and a real signal handler so in-flight requests drain on shutdown (tini gives you correct PID-1 signal forwarding).
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
RUN apk add --no-cache tini
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY /app/dist ./dist
USER node
EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]On Kubernetes it needs nothing exotic: a plain Deployment with several replicas, a readiness probe so the load balancer only sends traffic to instances that are up, and a HorizontalPodAutoscaler to add replicas under CPU pressure. Add a lightweight health route to your Express app (outside /mcp) for the probe to hit.
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-streamablehttp
spec:
replicas: 3
selector:
matchLabels: { app: mcp-server }
template:
metadata:
labels: { app: mcp-server }
spec:
containers:
- name: mcp
image: your-registry/mcp-server:latest
ports: [{ containerPort: 3000 }]
readinessProbe:
httpGet: { path: /health, port: 3000 }
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests: { cpu: "250m", memory: "256Mi" }
limits: { cpu: "500m", memory: "512Mi" }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: mcp-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: mcp-streamablehttp
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 60 }The proxy still needs attention
The piece of the old scaling section that survives is the reverse proxy. Responses are still SSE streams, and a subscriptions/listen stream stays open for as long as the client wants change notifications, so a proxy that buffers or times out will break them. Don't buffer the /mcp location, raise the read timeout, and terminate TLS at the edge. Your server should also be sending X-Accel-Buffering: no on SSE responses and periodic SSE comment keep-alives on long-lived streams, which covers the proxies you don't control.
upstream mcp_backend {
server mcp1:3000; # plain round-robin: no affinity required
server mcp2:3000;
server mcp3:3000;
}
server {
listen 443 ssl;
server_name mcp.example.com;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
location /mcp {
proxy_pass http://mcp_backend;
proxy_http_version 1.1;
proxy_set_header Connection ""; # keep upstream connections alive
proxy_buffering off; # don't buffer the SSE stream
proxy_read_timeout 300s; # long-lived subscriptions/listen streams
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}Give the load balancer a health endpoint so it can route around bad instances, and you're done.
Before 2026-07-28: scaling a stateful server meant pinning each session to one instance — sessionAffinity: ClientIP on a Kubernetes Service, target-group stickiness on an AWS ALB, ip_hash on nginx — so the in-memory transports map kept working, at the cost of losing every session on an instance when it died. Surviving that meant implementing the SDK's EventStore interface against Redis so any instance could replay a stream from a Last-Event-ID. A dual-era server still needs one of those arrangements for its legacy clients; traffic on the current revision needs neither.
Common issues
400 Bad Request with error -32020. Header validation. MCP-Protocol-Version, Mcp-Method, and (on tools/call, resources/read, and prompts/get) Mcp-Name are required on every POST, and their values must match the request body exactly — a gateway routing on the header and a server executing on the body must not be able to disagree. Hand-built requests and intermediaries that strip headers are the usual culprits. The neighbouring case is 400 with -32022: the server doesn't implement the protocol version you asked for, and the error's data.supported tells you what it does.
405 Method Not Allowed on GET. Working as intended for a server that implements only 2026-07-28. Something is trying to open the old standalone GET stream, which means a legacy client; the fix is dual-era support on the server, or a client upgrade. For change notifications, subscriptions/listen is the replacement.
400 Bad Request: No valid session ID provided on the second request. You are talking to a pre-2026-07-28 server, or a dual-era one that fell back, and the client isn't echoing the MCP-Session-Id the server returned at initialization. Under the current revision there is no session to be missing.
Works in curl, fails from a real client. The most common cause is still the Accept header. Clients must advertise both application/json and text/event-stream; a hand-built request that sends only application/json will not get the SSE behavior the client expects. The second cause is one of the required headers above, which a strict server answers with 400. (spec)
403 Forbidden locally. That's Origin/host validation doing its job. Using createMcpExpressApp() you get this protection by default; if you bind to 0.0.0.0 or front the server with a different host, configure the allowed hosts rather than disabling the check. (SDK docs/server.md)
SSE streams cut off behind a proxy. A reverse proxy is buffering the response or timing out an idle connection. Turn off buffering for the /mcp location and raise the read timeout, as in the nginx config above, and send X-Accel-Buffering: no plus keep-alive comments from the server. There is no longer an escape hatch here: Last-Event-ID resumability is gone, so a broken stream loses the in-flight request and the client has to re-issue it with a new request ID.
A note on the Python SDK
If your stack is Python rather than Node, the same transport is available through the official mcp SDK. Version 2.0.0 (2026-07-28) is the line that implements the current revision, and it renamed the high-level server on the way: FastMCP became MCPServer, and mcp.server.fastmcp became mcp.server.mcpserver, with the old import paths removed rather than deprecated. Transport options moved off the constructor and onto run(), where HTTP deployments typically pass stateless_http=True and json_response=True. (Don't confuse the official mcp package with the separate community fastmcp project, which spells the transport transport="http" and has its own upgrade path.) The transport-level rules in this guide — one POST-only endpoint, the required headers, Origin validation, no sessions — are identical across languages, because they're properties of the spec, not of any one SDK. For the Python specifics, see our Python FastMCP guide.
Where to go from here
A Streamable HTTP server that answers POSTs on one endpoint, validates its headers and its Origin, implements server/discover, and keeps no state the transport is no longer supposed to remember is the whole job for most teams. For deeper transport background, see comparing stdio, SSE, and Streamable HTTP; for hardening the deployment, see configuring MCP installations for production.
Once it's serving real clients, the open questions turn operational: which tools clients actually call, what arguments they send, which protocol revision each one is speaking, and where calls error or stall across instances. That visibility is the gap AgentCat fills for production MCP servers.
Related Guides
Comparing stdio vs. SSE vs. Streamable HTTP
How to choose an MCP transport: stdio for local subprocesses, Streamable HTTP for remote services, and why the old HTTP+SSE transport is deprecated, not a third option.
Configuring MCP installations for production deployments
What actually changes when an MCP server moves from your laptop to production: Streamable HTTP transport, the OAuth Resource Server auth model, Origin validation and DNS rebinding, statelessness, and secrets. The MCP-specific parts, verified against the 2026-07-28 spec revision and the SDKs, with the generic DevOps trimmed.
Configuring MCP servers for multiple simultaneous connections
How MCP servers actually serve concurrent clients under the 2026-07-28 spec revision: a stateless protocol with per-request metadata, no session IDs to track, and the dual-era support production servers still need for older clients.