How to Build a Remote MCP Server with Streamable HTTP
Build a remote MCP server with the TypeScript SDK v2 and Streamable HTTP: Express setup, Host and Origin checks, bearer auth, curl tests and deployment.
Cet article est en anglais.
TL;DR. To build a remote MCP server in TypeScript today, install @modelcontextprotocol/server (the v2 SDK), wrap a server factory in createMcpHandler, and mount it on one POST endpoint such as /mcp. Under the 2026-07-28 spec, Streamable HTTP has no sessions and no GET stream, so every request gets a fresh server instance and any load balancer works. Before the server leaves localhost it needs Host and Origin validation, and bearer token checks if the data is private. The steps below use Express; the same handler runs on Hono, Fastify, Cloudflare Workers, Deno and Bun.
What a remote MCP server has to do under the 2026-07-28 spec
A remote MCP server exposes one HTTP endpoint that accepts POST and answers each JSON-RPC request on its own. The Streamable HTTP transport page puts it this way: "The server MUST provide a single HTTP endpoint path (hereafter referred to as the MCP endpoint) that supports POST." The reply is either a single JSON object or a Server-Sent Events stream scoped to that request, which carries progress notifications and then the final response.
If you learned the transport from an older tutorial, two things are gone. The spec lists them at the top of the page: "Removal of the GET stream endpoint" and "Removal of protocol-level sessions". There is no Mcp-Session-Id to mint or store, and streams cannot be resumed with Last-Event-ID.
What is new is a set of headers that mirror the request body, so that proxies can route without parsing JSON. Every POST carries MCP-Protocol-Version, every request carries Mcp-Method, and tools/call, resources/read and prompts/get also carry Mcp-Name. This is the spec's own example:
POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weatherIf a header disagrees with the body, the server must answer 400 Bad Request with JSON-RPC error -32020 (HeaderMismatch). The SDK does this validation for you, but the error code is worth recognizing when it turns up in a log.
If you are still deciding whether the server should be remote at all, the earlier post on remote vs local MCP servers covers that choice, and What is MCP? explains the stateless model in more detail.
Set up the project and install the TypeScript SDK
You need Node.js 20 or later. The SDK repository says its main branch is v2, that it implements the 2026-07-28 spec, and that "v2 is the stable release line". The v1 line keeps receiving bug fixes and security updates for at least six months after the v2 release. The latest release when this was written is v2.3.0.
In v2 the SDK is split into several packages. @modelcontextprotocol/server holds the server and the HTTP handler, @modelcontextprotocol/node adapts that handler to Node's request and response objects, and @modelcontextprotocol/express adds an app factory with security defaults. The setup commands follow the SDK's first server tutorial and Express guide; the install line adds the three SDK packages, Express, Zod and tsx:
mkdir notes-mcp && cd notes-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express zod tsx
mkdir srcTool schemas use Standard Schema, so Zod v4, Valibot and ArkType all work. The examples here use Zod, imported as zod/v4, as the SDK docs do.
Build a remote MCP server with createMcpHandler and Express
The whole server is a factory function passed to createMcpHandler, mounted on one Express route. Put this in src/index.ts:
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const handler = createMcpHandler(() => {
const server = new McpServer({ name: 'notes', version: '1.0.0' });
server.registerTool(
'add-note',
{
description: 'Save a note',
inputSchema: z.object({ text: z.string() })
},
async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] })
);
return server;
});
const app = createMcpExpressApp();
const node = toNodeHandler(handler);
app.all('/mcp', (req, res) => void node(req, res, req.body));
app.listen(3000, '127.0.0.1');The factory runs once per HTTP request. The HTTP serving guide describes a fresh server instance for each caller, with nothing kept between requests. If you port v1 code that creates one McpServer at startup and reuses it, it will break: the v2.3.0 release notes say "Server.connect() now rejects while the instance is already connected, and a stateless Streamable HTTP transport handles one request." Anything that must outlive a request (a database pool, a cache) belongs outside the factory.
createMcpExpressApp already installs express.json(), which is why the route passes req.body as the third argument. Without it the adapter would try to read a stream that Express has already consumed.
registerTool takes a name, a config object and an async handler. The SDK turns the Zod schema into JSON Schema for tools/list and validates arguments before your handler runs, so the handler only sees well-formed input.
The handler picks the response format per request. According to the createMcpHandler reference, the default responseMode is 'auto': a single JSON body unless the tool emits messages mid-call, in which case the response upgrades to SSE. 'json' never streams and drops mid-call notifications, and 'sse' always streams.
const jsonOnly = createMcpHandler(factory, { responseMode: 'json' });Test the endpoint with curl
Start the server with npx tsx src/index.ts, then send a tools/list request from a second terminal. This is the test command from the SDK's Express guide:
curl -s -X POST http://127.0.0.1:3000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'The reply should contain the add-note tool with its generated input schema. The guide says it arrives as an SSE message event, so expect an event: and data: line around the JSON.
The Accept header is required by the spec: a client must list both application/json and text/event-stream, because the server may choose either. A real client also sends the MCP-Protocol-Version and Mcp-Method headers shown earlier. The curl above omits them and still works because the handler serves older protocol traffic by default, which a later section covers.
Validate Host and Origin before you go public
Host and Origin validation is the first thing to get right when the server moves off localhost, and the bare handler does not do it. The HTTP serving guide is explicit that createMcpHandler validates neither header and that you mount validation in front of it. The spec requires the Origin check: "Servers MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks", with HTTP 403 when the header is present and invalid.
With Express you get this from the app factory. createMcpExpressApp() with no arguments assumes a 127.0.0.1 binding, and requests whose Host or Origin is not localhost receive a 403 before they reach your handler. For a public deployment, say which hostname you serve:
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] });Passing allowedOrigins replaces the default list instead of extending it, so the Express guide tells you to start from localhostAllowedOrigins() if you want to keep the localhost entries. And requests without an Origin header always pass, which is what lets non-browser clients such as Claude Code or curl connect. Origin checks stop a web page from reaching your server through a visitor's browser. They do nothing against a caller who simply omits the header, so they do not replace authentication.
If you serve from plain node:http without a framework, @modelcontextprotocol/node exports localhostHostValidation() and localhostOriginValidation() guards to call before the handler.
Add authorization with bearer tokens
An MCP server that protects data acts as an OAuth resource server: it checks tokens and never issues them. The SDK's authorization guide says so in one line: "Your MCP server is an OAuth resource server: it verifies access tokens that an authorization server issued, and it never issues them." You bring an authorization server (your identity provider) and a function that verifies its tokens.
The authorization section of the spec makes authorization optional, but if you implement it, two rules are fixed. The server must publish OAuth 2.0 Protected Resource Metadata (RFC 9728), so clients can find the authorization server. And it must check that each token was issued for this server as the intended audience. Both map to SDK calls:
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
import {
createMcpExpressApp,
getOAuthProtectedResourceMetadataUrl,
mcpAuthMetadataRouter,
requireBearerAuth
} from '@modelcontextprotocol/express';
const mcpServerUrl = new URL('https://api.example.com/mcp');
const verifier: OAuthTokenVerifier = { verifyAccessToken };
const auth = requireBearerAuth({
verifier,
requiredScopes: ['mcp'],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
expectedResource: mcpServerUrl
});
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] });
app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl }));
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));Keep @modelcontextprotocol/express in step with @modelcontextprotocol/server: the guide warns that @modelcontextprotocol/express 2.0.1 does not pass expectedResource on, so no audience check happens. The v2.3.0 release ships it as 2.0.2.
verifyAccessToken and oauthMetadata are yours to supply. The first takes the raw token and returns an AuthInfo object with token, clientId, scopes, expiresAt and resource; inside it you verify a JWT or call your provider's introspection endpoint. The guide asks you to always fill in expiresAt. expectedResource is the audience check: the middleware compares the token's resource with your server's URL.
A missing or invalid token gets 401 with invalid_token, and a token without the required scopes gets 403 insufficient_scope. Both carry a WWW-Authenticate: Bearer challenge, and the resource_metadata parameter in it is how a client starts the sign-in flow without any manual configuration.
Inside a tool, the verified caller is available on the context:
server.registerTool('whoami', { description: 'Report the authenticated caller' }, async ctx => {
const caller = ctx.http?.authInfo;
return { content: [{ type: 'text', text: `${caller?.clientId} [${caller?.scopes.join(' ')}]` }] };
});One rule from the spec deserves attention if your tools call an upstream API: "MCP servers MUST NOT accept or transit any other tokens." The token a client sends you is for your server only. Do not forward it upstream.
For a sense of what other servers do, 373 of the 544 listings on mcp.tc were remote on 2026-10-04. Of those, 279 sign users in with OAuth, 25 take an API key or token in a header, 58 need no sign-in (our own directory server among them), and 11 work without sign-in but accept one (optional). The state of public MCP servers post has the full breakdown.
Deploy without sessions and keep older clients working
A v2 server holds no state between requests, so deployment needs no session affinity. The SDK's scaling guide says you can put the nodes behind any load balancer: "no session affinity, nothing to share, nothing to configure." The one exception is change notifications. If clients use subscriptions/listen and you run several nodes, each handler needs a shared ServerEventBus.
Older clients are covered by default. The legacy option of createMcpHandler defaults to 'stateless', which serves pre-2026 protocol traffic through per-request instances and answers GET and DELETE with 405, the same response the spec recommends for a modern-only server. Set legacy: 'reject' if you want strict mode, where legacy requests fail with an unsupported protocol version error.
Settings to check before production:
keepAliveMsdefaults to15000. The handler sends an SSE comment frame at that interval so that proxies do not close quiet streams.maxRequestBodySizedefaults to 4 MiB. Larger bodies get a 413 before parsing.- The spec says servers should send
X-Accel-Buffering: noon SSE responses so that nginx and similar proxies do not buffer events. If streamed progress arrives in one burst at the end, check your proxy's buffering first. - Call
await handler.close()onSIGINT. It aborts in-flight exchanges and resolves when all per-request instances have shut down.
On Cloudflare Workers, Deno or Bun you do not need Express or the Node adapter at all. The handler has a web-standard fetch method, so the module's default export is the handler:
export default handler;Remember that this path skips the Express app factory, so Host and Origin validation becomes your job again.
Connect a client and list the server
Once the server is reachable over https, any client that supports Streamable HTTP can use its URL directly. In Claude Code the documented syntax is claude mcp add --transport http <name> <url>:
claude mcp add --transport http notes https://api.example.com/mcpIf the server requires OAuth, run /mcp inside Claude Code and finish the sign-in in the browser. For a static token during development, the same command accepts --header "Authorization: Bearer your-token". Steps for Cursor, VS Code and the Claude apps are in our guide on how to add an MCP server.
When the server is public, you can suggest it to the mcp.tc directory by pasting its URL. The checker performs a live MCP handshake and reads the tools list, so a server that passes the curl test above has what it needs. The docs for server owners explain what the checker reads and how the daily re-checks work, and the verified checkmark page explains how a DNS TXT record on your domain shows who runs the server.
FAQ
Do I still need Mcp-Session-Id for a Streamable HTTP server?
No. Revision 2026-07-28 removed protocol-level sessions and the GET stream endpoint. A server that implements only this revision should ignore an Mcp-Session-Id header from an older client and should not mint or echo session IDs. In the TypeScript SDK v2, createMcpHandler builds a fresh server instance for every HTTP request, so there is no session to track unless you deliberately run the legacy sessionful transport.
Which npm package do I install for a TypeScript MCP server?
Install @modelcontextprotocol/server for the server and HTTP handler. On Node.js, add @modelcontextprotocol/node for the request adapter and, if you use a framework, @modelcontextprotocol/express, the Hono package or the Fastify package. The SDK repository describes v2 as the stable release line for the 2026-07-28 spec, and the v1 line continues to receive bug fixes and security updates for at least six months after v2.
Does a remote MCP server need OAuth?
Authorization is optional in the MCP specification, so a server with only public, read-only data can run without sign-in. If the server exposes private data or actions, it should act as an OAuth resource server: publish Protected Resource Metadata (RFC 9728), require a bearer token on every request, and verify that each token was issued for this server. The SDK's requireBearerAuth middleware handles the 401 and 403 responses.
Will clients that speak an older MCP version still connect?
Yes, with the default settings. createMcpHandler has a legacy option that defaults to 'stateless', which serves pre-2026 protocol requests through per-request server instances on the same endpoint. GET and DELETE requests get a 405. If you want a modern-only server, set legacy: 'reject' and older requests fail with an unsupported protocol version error.
Can I run the same MCP server on Cloudflare Workers, Deno or Bun?
Yes. The object returned by createMcpHandler has a web-standard fetch method, and the SDK docs show export default handler; as the whole deployment for those runtimes. You lose the Express app factory on that path, so add your own Host and Origin validation, and call the bearer auth gate before handler.fetch if the server needs sign-in.
Why does my server return 403 to requests from a browser?
The Express app factory validates the Host and Origin headers to block DNS rebinding. With the default localhost binding, any request whose Host or Origin is not localhost gets a 403 before it reaches your handler. For a public hostname, pass host and allowedHosts to createMcpExpressApp. If you set allowedOrigins, it replaces the default list, so include the localhost origins again if you still need them.
Sources
- modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http
- modelcontextprotocol.io/specification/2026-07-28/basic/authorization
- github.com/modelcontextprotocol/typescript-sdk
- github.com/modelcontextprotocol/typescript-sdk/releases
- ts.sdk.modelcontextprotocol.io/v2/get-started/first-server.html
- ts.sdk.modelcontextprotocol.io/v2/serving/http.html
- ts.sdk.modelcontextprotocol.io/v2/serving/express.html
- ts.sdk.modelcontextprotocol.io/v2/serving/authorization.html
- ts.sdk.modelcontextprotocol.io/v2/serving/sessions-state-scaling.html
- ts.sdk.modelcontextprotocol.io/v2/api/@modelcontextprotocol/server/server/createMcpHandler.html
- code.claude.com/docs/en/mcp