Skip to content

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.

TerminalAdd the Node adapter
Terminal window
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.

EditorWrite src/server.ts
src/server.ts
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-standard fetch(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 for http.createServer. Express, Fastify and Hono have their own adapters; the SDK guide lists them.
  • localhostHostValidation() and localhostOriginValidation() reject requests whose Host or Origin header is not localhost. Without the Host check, a web page that points its own domain at 127.0.0.1 could call your local server from the user’s browser (DNS rebinding). The official guide says the handler validates no Host, no Origin and 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.
TerminalStart it
Terminal window
node src/server.ts

Expectedhello-ts-http: listening on http://127.0.0.1:3000/mcp

TerminalCall the tool with one request
Terminal window
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

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.

Terminal window
npx -y @modelcontextprotocol/inspector@2.12.0 --cli \
http://127.0.0.1:3000/mcp --protocol-era auto --method tools/list

The 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.