Building a Streamable HTTP MCP server

Kashish Hora

Kashish Hora

Co-founder of AgentCat

Try out AgentCat

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 Accept header listing both application/json and text/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 gets 202 Accepted with no body. (spec)
  • There is no handshake. initialize and notifications/initialized were removed. Every request carries its own protocol version and client capabilities in _meta: io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities are both required, and io.modelcontextprotocol/clientInfo should be there too. Servers must implement the new server/discover RPC, 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 example MCP-Protocol-Version: 2026-07-28) and Mcp-Method (mirrors method) go on every request; Mcp-Name (mirrors params.name or params.uri) joins them specifically on tools/call, resources/read, and prompts/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 is 400 Bad Request with JSON-RPC error -32020 (HeaderMismatch). An unsupported protocol version is 400 with UnsupportedProtocolVersionError (-32022), whose data.supported lists what the server does speak. An unknown method is 404 Not Found with -32601. (spec)
  • The server must validate the Origin header on all connections to prevent DNS rebinding, and when running locally it should bind to 127.0.0.1, not 0.0.0.0. An invalid Origin gets a 403. (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 ignore Mcp-Session-Id and Last-Event-ID headers 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 wants notifications/tools/list_changed, the other list_changed notifications, or notifications/resources/updated POSTs a subscriptions/listen request 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 and resources/subscribe/resources/unsubscribe. Request-scoped notifications like notifications/progress and notifications/message still flow on the originating request's own response stream, never on the listen stream. Servers should send X-Accel-Buffering: no when 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/cancelled still exists, but only on stdio — servers separately send it, on any transport, when they tear down a subscriptions/listen stream.)

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/sdk 1.30.0 is the original monolithic package. It is still the npm latest tag, still published, and not deprecated — but it targets the initialize-handshake era of the protocol, not 2026-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 rewrites package.json too. Names you will hit on the way: McpErrorProtocolError, ErrorCodeProtocolErrorCode, StreamableHTTPErrorSdkHttpError, and StreamableHTTPServerTransportNodeStreamableHTTPServerTransport, 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 --from=build /app/node_modules ./node_modules
COPY --from=build /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.