Serve a TypeScript MCP server over Streamable HTTP
- Spec
2026-07-28- Evidence
- E2Reproducedarm64
- TypeScript SDK (server)
@modelcontextprotocol/server@2.4.0- TypeScript SDK v2 (Node HTTP)
@modelcontextprotocol/node@2.1.2- MCP Inspector
@modelcontextprotocol/inspector@2.12.0- Verified
A stdio server runs on the same machine as the client. To share a server with other people or run it in the cloud, serve it over Streamable HTTP: one URL, usually /mcp, that takes POST requests.
This guide serves the add tool from the first server over HTTP. The tool code is the same; only the last few lines change.
Install
Section titled “Install”npm install --save-exact @modelcontextprotocol/server@2.4.0 @modelcontextprotocol/node@2.1.2 zod@4.6.7@modelcontextprotocol/server (2.4.0) contains the HTTP handler. @modelcontextprotocol/node (2.1.2) adapts it to Node’s http module and adds the Host and Origin checks.
The server
Section titled “The server”import { createServer } from 'node:http';import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from '@modelcontextprotocol/node';import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';import * as z from 'zod';
// The same factory serves every request. Nothing is kept between requests.function buildServer() { const server = new McpServer({ name: 'hello-ts-http', version: '1.0.0' }); server.registerTool( 'add', { description: 'Add two numbers and return the sum.', inputSchema: z.object({ a: z.number(), b: z.number() }), outputSchema: z.object({ sum: z.number() }), }, async ({ a, b }) => { const output = { sum: a + b }; return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output }; }, ); return server;}
const handler = toNodeHandler(createMcpHandler(buildServer));const validateHost = localhostHostValidation();const validateOrigin = localhostOriginValidation();const port = Number(process.env.PORT ?? 3000);
createServer((req, res) => { // Reject requests whose Host or Origin is not localhost (DNS rebinding protection). if (!validateHost(req, res) || !validateOrigin(req, res)) return; if (new URL(req.url ?? '/', 'http://localhost').pathname !== '/mcp') { res.writeHead(404).end(); return; } void handler(req, res);}).listen(port, '127.0.0.1', () => { console.error(`hello-ts-http: listening on http://127.0.0.1:${port}/mcp`);});What the HTTP part does:
createMcpHandler(buildServer)returns a handler with a web-standardfetch(request)method. It calls your factory for every request, so nothing is shared between requests. That is the stateless model of the 2026-07-28 spec.toNodeHandler(...)wraps that handler forhttp.createServer. Express, Fastify and Hono have their own adapters; the SDK guide lists them.localhostHostValidation()andlocalhostOriginValidation()reject requests whoseHostorOriginheader is not localhost. Without theHostcheck, a web page that points its own domain at127.0.0.1could call your local server from the user’s browser (DNS rebinding). The official guide says the handler validates noHost, noOriginand no token by itself.listen(port, '127.0.0.1')binds to loopback only. Change it only when the server sits behind a proxy that does its own checks.
node src/server.tsExpectedhello-ts-http: listening on http://127.0.0.1:3000/mcp
Test it with curl
Section titled “Test it with curl”curl -s -X POST http://127.0.0.1:3000/mcp \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -H 'MCP-Protocol-Version: 2026-07-28' \ -H 'Mcp-Method: tools/call' \ -H 'Mcp-Name: add' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3},"_meta":{ "io.modelcontextprotocol/protocolVersion":"2026-07-28", "io.modelcontextprotocol/clientCapabilities":{}}}}'ExpectedstructuredContent is {"sum":5} and resultType is complete.
No initialize, no session header: the request carries everything. In 2026-07-28 the Mcp-Method header must repeat the JSON-RPC method, and Mcp-Name the tool name. When we left out Mcp-Method, the server answered:
{"jsonrpc":"2.0","error":{"code":-32020,"message":"Bad Request: the request headers and body disagree: the body names method tools/list but the required Mcp-Method header is absent", …}}Other answers we saw from this server:
| Request | Response |
|---|---|
GET /mcp |
405 Method Not Allowed, allow: POST |
POST /mcp with Host: evil.example |
403, Invalid Host: evil.example |
POST /mcp with Origin: https://evil.example |
403, Invalid Origin: evil.example |
POST without any 2026-07-28 header, no initialize first |
Answered as a stateless 2025-era request |
Older clients
Section titled “Older clients”By default createMcpHandler also answers 2025-era clients: each old-style request gets a fresh server from the same factory, without sessions. We connected the TypeScript client SDK in three ways:
| Client setting | Negotiated version | add(2, 3) |
|---|---|---|
| Default | 2025-11-25 | {"sum":5} |
versionNegotiation: { mode: 'auto' } |
2026-07-28 | {"sum":5} |
versionNegotiation: { mode: { pin: '2026-07-28' } } |
2026-07-28 | {"sum":5} |
To accept only 2026-07-28 traffic, pass { legacy: 'reject' } as the second argument of createMcpHandler.
Test with the Inspector
Section titled “Test with the Inspector”npx -y @modelcontextprotocol/inspector@2.12.0 --cli \ http://127.0.0.1:3000/mcp --protocol-era auto --method tools/listThe Inspector picks Streamable HTTP because the URL ends in /mcp. See Test a server with the MCP Inspector.
Sources
MCPDev is community documentation. It is not affiliated with the Model Context Protocol project or with the vendors of the clients it covers.