How to Build an MCP HTTP Server in TypeScript
Build a production-ready MCP HTTP server in TypeScript with Streamable HTTP, sessions, security, deployment guidance, and troubleshooting.
Use the TypeScript MCP SDK to create an McpServer, register tools, resources, and prompts, attach a Streamable HTTP transport, and call server.connect(transport). Streamable HTTP is the recommended transport for remote servers. Use stdio when a client launches your server locally, and use the older HTTP+SSE transport only when you must support a legacy client.
This guide builds a Node.js server at /mcp, shows stateful and stateless sessions, covers authentication and localhost protection, and explains deployment, shutdown, testing, and failure handling.
1. Choose the MCP transport
| Transport | Best for | Session behavior | Operational notes |
|---|---|---|---|
| Streamable HTTP | Remote MCP servers | Stateful sessions or stateless requests | Modern transport; supports streaming and JSON-only responses |
| stdio | Local process-spawned integrations | Process lifetime | The client starts your program and communicates over stdin/stdout |
| HTTP+SSE | Legacy compatibility | Session-oriented | Deprecated for new deployments; retain it only for clients that require it |
The official MCP transport documentation describes Streamable HTTP as the modern, fully featured transport. The TypeScript SDK documentation generally uses McpServer from @modelcontextprotocol/sdk/server/mcp.js.
2. Create the project
mkdir typescript-mcp-http
cd typescript-mcp-http
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
npx tsc --init
Pin the SDK generation in package.json. The v1 and v2 documentation use different package layouts: v1 uses @modelcontextprotocol/sdk, while the v2 documentation uses split packages such as @modelcontextprotocol/server and identifies the 2026-07-28 specification era. Do not mix imports from those generations.
{
"scripts": {
"dev": "tsx src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
}
3. Build a complete Streamable HTTP server
Create src/server.ts. This example uses a stateful session ID, JSON responses, one tool, one resource, and one prompt.
import { createServer, IncomingMessage, ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/node.js";
import { z } from "zod";
const port = Number(process.env.PORT ?? 3000);
const host = process.env.HOST ?? "127.0.0.1";
const server = new McpServer({
name: "typescript-http-example",
version: "1.0.0"
});
server.registerTool(
"add",
{
title: "Add numbers",
description: "Add two numbers and return the sum.",
inputSchema: {
a: z.number(),
b: z.number()
}
},
async ({ a, b }) => ({
content: [{ type: "text", text: String(a + b) }]
})
);
server.registerResource(
"status",
"status://service",
{
title: "Service status",
description: "Current health information for this MCP server.",
mimeType: "application/json"
},
async (uri) => ({
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({ ok: true, service: "typescript-http-example" })
}]
})
);
server.registerPrompt(
"summarize-status",
{
title: "Summarize status",
description: "Ask a model to summarize the service status.",
argsSchema: { focus: z.string().optional() }
},
async ({ focus }) => ({
messages: [{
role: "user",
content: {
type: "text",
text: `Read status://service and summarize it${focus ? ` with focus on ${focus}` : ""}.`
}
}]
})
);
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
enableJsonResponse: true
});
async function handle(req: IncomingMessage, res: ServerResponse) {
if (req.url === "/healthz" && req.method === "GET") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
if (req.url !== "/mcp") {
res.writeHead(404);
res.end("Not found");
return;
}
await transport.handleRequest(req, res);
}
const httpServer = createServer((req, res) => {
handle(req, res).catch((error) => {
console.error(error);
if (!res.headersSent) res.writeHead(500);
res.end("Internal server error");
});
});
httpServer.listen(port, host, () => {
console.log(`MCP server listening on http://${host}:${port}/mcp`);
});
async function shutdown(signal: string) {
console.log(`${signal}: shutting down`);
httpServer.close(async () => {
await transport.close();
await server.close();
process.exit(0);
});
}
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
Run it with:
npm run dev
SDK minor versions can change the Node adapter method signature. If your pinned version requires a parsed request body or a framework adapter, follow that version’s official SDK guide while keeping the same lifecycle: create the server, create the transport, connect them, route /mcp, and close both during shutdown.
4. Stateful versus stateless sessions
Stateful mode
Stateful mode supplies a session ID generator. The server can associate later requests with the same session and support session-oriented behavior such as resumability-related flows. It is useful when tools keep conversational or temporary state.
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
enableJsonResponse: true
});
For horizontal scaling, route a session consistently to the same worker or store the state in shared storage. A random session ID alone does not make memory local to every replica.
Stateless mode
Omit the session ID generator for API-style services where every request contains everything needed to execute the tool.
const transport = new NodeStreamableHTTPServerTransport({
enableJsonResponse: true
});
Stateless mode simplifies load balancing and restarts. It also means you must put authentication, authorization, and any required context in each request or in a shared service.
5. Add authentication, CORS, and host protection
The transport handles MCP messages; your HTTP layer still owns access control. Put authentication in front of /mcp, validate the token before handing the request to the transport, and avoid logging secrets.
function authorized(req: IncomingMessage): boolean {
const expected = process.env.MCP_AUTH_TOKEN;
if (!expected) return false;
return req.headers.authorization === `Bearer ${expected}`;
}
async function handle(req: IncomingMessage, res: ServerResponse) {
if (req.url === "/mcp" && !authorized(req)) {
res.writeHead(401, { "www-authenticate": "Bearer" });
res.end("Unauthorized");
return;
}
// Route health checks and MCP requests here.
}
For browser-based clients, configure an explicit CORS allowlist. Do not use * with credentials. On localhost, implement DNS rebinding and Host/Origin protection: accept only the hostnames and origins you intend to expose, such as 127.0.0.1:3000 and an approved client origin. This prevents a malicious web page from using a browser to reach a local MCP server.
6. Test the endpoint
Use an MCP-compatible client for the full initialize and tool-call flow. A raw HTTP probe is still useful for checking reachability:
curl -i http://127.0.0.1:3000/healthz
For a real MCP request, send the JSON-RPC message expected by your pinned SDK version and preserve any session header returned by the server. Client libraries usually handle initialization, session headers, streaming, and reconnection for you.
7. Deploy on Node.js
- Run
npm run buildduring the image or release build. - Start the compiled process with
npm start. - Expose a stable HTTPS endpoint such as
https://example.com/mcp. - Terminate with SIGTERM and allow the HTTP server, transport, and MCP server to close.
- Keep authentication and origin checks at the edge and in the application.
The official Node transport is NodeStreamableHTTPServerTransport. Framework adapters are also available; choose one that lets you pass the incoming request and response to the transport without buffering or rewriting MCP streaming responses.
Shutdown deserves attention: in-flight tool handlers are not automatically drained when the process exits. Give the process a termination window, stop accepting new requests, and make long-running tools cancellation-aware where your SDK version supports it.
8. Performance, reliability, and cost
- Keep handlers bounded. Add timeouts around network calls and return useful structured errors.
- Prefer stateless mode for simple APIs. It avoids session affinity and makes replacement instances easier.
- Use stateful mode deliberately. Plan session storage and routing before adding replicas.
- Protect the event loop. Move CPU-heavy work to a worker thread or separate service.
- Reuse connections. Configure HTTP clients with keep-alive and sensible pool limits.
- Observe the protocol boundary. Record request IDs, method names, duration, status, and error class without recording credentials or private tool arguments.
- Budget external services separately. MCP itself does not define your hosting, database, model, or third-party API costs.
9. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
404 on /mcp |
Proxy path and application path differ | Mount the transport at the exact public path or configure the proxy rewrite explicitly. |
| Client cannot initialize | Wrong SDK generation or malformed JSON-RPC request | Pin one SDK line and use its matching client and transport documentation. |
| Session disappears after scaling | Requests reach different workers | Use sticky routing or shared session storage, or switch to stateless mode. |
| Browser receives CORS errors | Origin is not allowlisted | Return the specific allowed origin and handle OPTIONS requests. |
| Local server is reachable from an unexpected site | Missing Host/Origin and DNS rebinding checks | Bind to the intended interface and reject unapproved hostnames and origins. |
| Streaming response is corrupted | Reverse proxy buffers or rewrites the response | Disable buffering for the MCP route and preserve streaming headers. |
| Process exits with work still running | Shutdown closes the listener immediately | Stop new traffic, await cleanup, and give the process a termination grace period. |
| Tool arguments fail validation | Zod schema does not match the client payload | Use explicit types, validate optional fields, and return a clear input error. |
10. Or skip the browser setup
If your MCP tool’s job is to capture web pages, ScreenshotNeo gives you a website screenshot API and MCP server instead of making your server operate a browser. The API accepts one GET request and returns PNG, JPEG, WebP, or PDF. See the ScreenshotNeo API documentation for the full option list.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
- Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot.
- Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; response headers identify the page verdict and billing result.
- The MCP server includes
take_screenshot,get_page_info, andcapture_pdffor Claude, Cursor, and other MCP clients. - Free includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots.
Create a free ScreenshotNeo account and start with 1,000 screenshots a month without a card.
11. FAQ
Should a public MCP server use Streamable HTTP or SSE?
Use Streamable HTTP for a new remote server. Keep HTTP+SSE only for clients that still require the legacy transport.
When is stdio the better choice?
Use stdio when the MCP client launches your Node process locally and no public HTTP endpoint is needed.
Do all MCP servers need sessions?
No. Stateless mode is appropriate when each request is self-contained. Stateful mode is useful when the server needs session context or resumability-related behavior.
Can I put the server behind a reverse proxy?
Yes. Preserve the /mcp path, required headers, request bodies, and streaming behavior, and disable response buffering where necessary.
Which package should a new project install?
Follow one pinned SDK generation. The v1 quick start installs @modelcontextprotocol/sdk and zod; v2 documentation uses split packages and different imports.


