How to build an MCP server in 2026: one TypeScript factory, both protocol eras, and what the SDK's HTTP handler leaves to you
One factory serves both transports and both generations of clients. In a test run the SDK handler answered a forged Origin with 200 and gave clients no freshness window to cache by, and the Inspector checked only the old handshake until asked for the new one. The two guards to add, the cache hints to set, and what a first server can leave out.
Contents
The official guide to building an MCP server gets you to a working stdio server in any of eight languages, and for TypeScript it already handles the protocol split that came with the 2026-07-28 revision. It does not cover serving over HTTP, which is where that revision changed the most and where a server anyone else can reach has to live.
I built the HTTP part on 15 November 2026 and tested it. The TypeScript SDK’s HTTP handler served new and old clients from one endpoint and checked the new headers against the body. It also answered a request carrying Origin: https:// with 200, sent list results with no freshness window, and the MCP Inspector, run with its defaults, tested only the old handshake. The fix for the first is two lines, the second is one option, and the third is a flag. Versions: @modelcontextprotocol/ 2.4.0, @modelcontextprotocol/ 2.1.2, Inspector 2.15.0, Node 22.23.5. Commands and outputs come from that run; where I trimmed an output, I say so.
For contrast I use the read-only MCP endpoint on this site, a 276-line Next.js route I wrote by hand in October. It covers the subset this site needs (discovery and three tools, no subscriptions), and I point out where it and the SDK handler differ as they come up.
What did MCP 2026-07-28 change for someone building a server?
It made the protocol stateless. The initialize handshake, protocol sessions and the Mcp-Session-Id header are gone; every request carries its protocol version and client capabilities in _meta, and servers must implement a new server/discover method. Over HTTP the client mirrors some of the body into headers, and the server must reject a request whose headers disagree with its body.
The parts that land on a server author, from the changelog and the Streamable HTTP binding:
- Each request names its version in
io.and its capabilities inmodelcontextprotocol/ protocolVersion io., both insidemodelcontextprotocol/ clientCapabilities params._meta. - Every result carries
resultType, which is"complete"for an ordinary result. tools/listand the other list results must carryttlMsandcacheScope: a freshness hint, and whether a cached copy may be shared beyond one authorization context.- Every HTTP POST carries
MCP-Protocol-VersionandMcp-Method, plusMcp-Namefortools/call,resources/readandprompts/get. A missing or mismatched header gets400 Bad Requestwith error code-32020(HeaderMismatch). - The GET stream and
pingare removed, and so islogging/setLevel: the log level now travels per request, and the Logging feature as a whole is deprecated. A server that only speaks the new revision answers GET and DELETE with405.
The spec does not assume clients move at once. Its versioning page calls a server that answers both kinds “dual-era” and says how it tells them apart: a request with the per-request _meta is served statelessly, and an initialize request selects the old behaviour. A modern-only server turns a legacy client away with a 400, and the spec notes that legacy clients “have no fall-forward mechanism”. If you want the clients people already run to connect, you serve both.
How do you build an MCP server with the TypeScript SDK?
Write one function that builds a fresh McpServer and registers your tools, then hand that function to whichever transport you serve. The SDK calls it for every HTTP request and for every stdio connection, and on stdio sometimes once more for a discovery probe it throws away, so it has to be a factory that is cheap to call, not a shared instance.
Set up the project with the versions I tested. The Inspector 2.15.0 package declares Node ^22.23.4, ^24.22.1 or >=26.12.1, a narrower range than its docs page states.
mkdir notes-mcp && cd notes-mcp
npm init -y && npm pkg set type=module
npm install @modelcontextprotocol/server@2.4.0 @modelcontextprotocol/node@2.1.2 zod@4.6.7
npm install -D typescript@7.0.2 @types/nodeThe tsconfig.json I used:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"types": ["node"],
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}The server, in src/server.ts. It has one read-only tool over a hard-coded list, which keeps the example about MCP and not about a database:
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
const NOTES = [
{ slug: "prompt-cache", title: "How prompt caching changes an agent bill" },
{ slug: "mcp-vs-api", title: "MCP vs a plain HTTP API" },
];
// One factory for every entry point: stdio calls it per connection,
// the HTTP handler calls it per request.
export function createServer() {
const server = new McpServer(
{ name: "notes", version: "1.0.0" },
{
// Public, rarely changing data: let clients cache the lists.
cacheHints: {
"server/discover": { ttlMs: 3_600_000, cacheScope: "public" },
"tools/list": { ttlMs: 3_600_000, cacheScope: "public" },
},
},
);
server.registerTool(
"search_notes",
{
title: "Search notes",
description:
"Search note titles. Returns titles and canonical URLs, not full text.",
inputSchema: z.object({
query: z.string().min(1).max(100).describe('Words to look for, e.g. "prompt".'),
limit: z.number().int().min(1).max(10).default(5),
}),
annotations: { readOnlyHint: true, openWorldHint: false },
},
async ({ query, limit }) => {
const q = query.toLowerCase();
const results = NOTES.filter((n) => n.title.toLowerCase().includes(q))
.slice(0, limit)
.map((n) => ({ title: n.title, url: `https://example.com/notes/${n.slug}` }));
return {
content: [{ type: "text", text: JSON.stringify({ results }) }],
structuredContent: { results },
};
},
);
return server;
}The stdio entry, in src/stdio.ts, is what the official guide shows:
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { createServer } from "./server.js";
serveStdio(createServer, { onerror: (e) => console.error("notes:", e) });
console.error("notes MCP server on stdio");Log to stderr, never stdout: on stdio, stdout is the protocol channel, and a stray console.log corrupts it. The guide also warns that wiring the server by hand with server.connect(new StdioServerTransport()) still compiles but serves only 2025-era clients. serveStdio is the part that answers both. To use the server from a desktop client, add the built build/stdio.js to the client’s MCP config as the guide’s Claude for Desktop step shows.
How do you test an MCP server in both protocol eras with the Inspector?
Run the Inspector’s CLI twice, once with --protocol-era modern and once with --protocol-era legacy. The Inspector defaults to legacy, which sends only the old initialize handshake, so a single green run with default flags tells you nothing about clients on the new revision.
npx tsc
npx @modelcontextprotocol/inspector@2.15.0 --cli node build/stdio.js \
--protocol-era modern --method tools/list --format json
npx @modelcontextprotocol/inspector@2.15.0 --cli node build/stdio.js \
--protocol-era legacy --method tools/list --format jsonBoth runs listed search_notes with the same schema.
There is a third value, auto, which probes server/discover and falls back to initialize. That is closer to what a dual-era client does, but the protocol eras page explains why it is not the default: a probe can stall against a silent legacy stdio server. For testing your own server, pinning each era is the clearer check.
How do you serve an MCP server over Streamable HTTP for both eras?
Pass the same factory to createMcpHandler. It returns a handler object whose fetch(request) method resolves to a web-standard Response, and by default it serves both eras from one endpoint: modern requests statelessly, legacy requests through a stateless fallback. For a Node server, toNodeHandler from @modelcontextprotocol/ adapts it, and the same package has the two guards the handler does not apply on its own.
src/http.ts:
import { createServer as createHttpServer } from "node:http";
import { createMcpHandler } from "@modelcontextprotocol/server";
import {
localhostHostValidation,
localhostOriginValidation,
toNodeHandler,
} from "@modelcontextprotocol/node";
import { createServer } from "./server.js";
const mcp = toNodeHandler(createMcpHandler(createServer, { responseMode: "json" }));
const hostOk = localhostHostValidation();
const originOk = localhostOriginValidation();
createHttpServer((req, res) => {
if (req.url !== "/mcp") return void res.writeHead(404).end();
if (!hostOk(req, res) || !originOk(req, res)) return; // already answered 403
void mcp(req, res);
}).listen(8787, "127.0.0.1");On a platform that takes web-standard handlers (Workers, Bun, Deno), you skip the adapter and pass each request to the handler’s fetch method.
The legacy option chooses how old clients are treated: "stateless" is the default, "reject" makes the endpoint modern-only. responseMode: "json" returns plain JSON to modern requests instead of upgrading to an SSE stream, at the cost of dropping progress notifications sent before the result, which a lookup tool does not send. A subscriptions/listen request still gets a stream whatever this option says.
createMcpHandler is not new. It is in the type declarations of @modelcontextprotocol/ 2.0.0, published on 27 July 2026, the day before the spec revision, and in 2.3.1, the latest release when I wrote the endpoint on this site. So a hand-written route is not a workaround for a missing SDK feature, and a server started today can use the handler.
Build and test it the same way, over HTTP:
npx tsc
node build/http.js &
npx @modelcontextprotocol/inspector@2.15.0 --cli http://127.0.0.1:8787/mcp --transport http \
--protocol-era modern --method tools/call --tool-name search_notes --tool-arg query=prompt --format json
npx @modelcontextprotocol/inspector@2.15.0 --cli http://127.0.0.1:8787/mcp --transport http \
--protocol-era legacy --method tools/call --tool-name search_notes --tool-arg query=prompt --format jsonBoth returned the matching note. This is the Inspector’s decoded output for the modern run; the SDK client consumes resultType, which is present on the wire. The legacy run printed the same result without the _meta block, because in that era the server identifies itself once, in the initialize result:
{"result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"notes","version":"1.0.0"}},"content":[{"type":"text","text":"{\"results\":[{\"title\":\"How prompt caching changes an agent bill\",\"url\":\"https://example.com/notes/prompt-cache\"}]}"}],"structuredContent":{"results":[{"title":"How prompt caching changes an agent bill","url":"https://example.com/notes/prompt-cache"}]}}}What the handler does on its own, checked with curl: a tools/call for search_notes sent with Mcp-Name: delete_everything in the header got 400 and -32020, and a GET got 405. The header rule exists for gateways. A load balancer or a policy layer may route on the header without parsing the body while the server executes what the body says, so the two must agree.
What does the MCP SDK’s HTTP handler leave to you?
Two checks and one setting. The handler did not validate Origin or Host, and it sent ttlMs: 0 on every cacheable result. Both checks matter most for a server on your own machine, because a server listening on localhost is what DNS rebinding goes after.
Origin. The spec says servers MUST validate the Origin header “to prevent DNS rebinding attacks” and answer an invalid one with 403. In my first run, with the handler alone, this request got a normal server/discover result and 200:
curl -s http://127.0.0.1:8787/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-H 'Origin: https://evil.example' \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'With the guards from src/http.ts in front:
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Invalid Origin: evil.example"},"id":null}
HTTP 403Host. The same request with Host: evil.example instead got Invalid Host: evil.example and 403. Binding to 127.0.0.1 controls who can connect, not which hostname the browser thinks it is talking to, and in a rebinding attack that hostname is the attacker’s.
Both localhost helpers allow only localhost-class names. A deployed server passes its own list to originValidation() and hostHeaderValidation(), and the list holds hostnames, not URLs: ["example.com"], not ["https://example.com"]; ports are ignored. Requests without an Origin header pass, since non-browser clients do not send one. The endpoint on this site makes a different call, stated in its code comment: it is public and holds no user data, so it accepts a missing Origin, the literal null and any value that parses as a URL, and answers anything else with 403. That suits public read-only data. It is not a default to copy.
Cache hints. Without the cacheHints option, server/discover and tools/list came back with ttlMs: 0 and cacheScope: "private". That satisfies the spec but gives a client no freshness window: every cached copy is stale at once. For a tool list that changes only when you deploy, I set an hour and public, and both results then carried "ttlMs":. The cost is that a client may keep using the old list for up to an hour after a deploy, which is acceptable for public data. My hand-written endpoint uses the same values. A list that differs per user stays private.
Two smaller surprises will bite a test suite. responseMode applies to modern requests only: a legacy initialize sent with curl (with Accept: application/json, text/event-stream, as the transport requires) came back as an SSE frame, event: message plus a data: line, and a test that parses the body as JSON chokes on it. And the SDK’s server/discover listed only "2026-07-28" in supportedVersions, although the same endpoint answered initialize for 2025-06-18; mine lists the legacy versions too. A dual-era client does not depend on that list: it learns the era from whether its first modern request succeeds or returns a modern error.
How do you design an MCP tool schema a model uses correctly?
Keep tools few, narrow and read-only where you can, and say what the tool returns, not only what it does. The model chooses a tool from its name, description and schema, and a description that says “returns titles and canonical URLs, not full text” tells it what the next step will look like before it spends the call.
The endpoint on this site has three tools: get_contact, list_products and search_articles. All three carry readOnlyHint: true. Annotations are hints to the host, which may use them to decide when to ask the user; the spec tells clients to treat them as untrusted unless the server is, so they inform a prompt and protect nothing. The search takes at most three arguments, every schema sets additionalProperties: false, and the server enforces the schema itself: an unknown argument comes back as a tool error, a malformed call as JSON-RPC -32602.
Two bugs from its history. Looking up the tool name with in let the string "constructor" match an inherited property and crash the handler; it now uses Object.hasOwn. And on 26 October I stopped offering protocol version 2025-03-26, because that revision made accepting JSON-RPC batches mandatory and the server rejects batches. A client asking for it now gets 2025-11-25 back in the initialize result, which is how negotiation in that era works.
With Zod, one detail shows up only in the output. The schema the SDK published for z.object had no additionalProperties: false, and a call with an extra argument named bogus went through without complaint. Declared with z.strictObject, the same tool’s tools/list carried "additionalProperties":, and the same call came back as a tool error: Unrecognized key: "bogus". Use z.strictObject if you want the model told, and the call refused.
Each tool adds its name, description and schema to what a host may load into context, a cost I wrote about as the token tax of agent tools.
What should a first MCP server leave out?
Auth you do not have, state between calls, and the features the new revision deprecated. A read-only server over public data needs none of them, and each one you add is a surface to test in two eras.
- Auth. If the data is public, ship without it and say so in the server’s
instructions. If it is not, the spec’s authorization chapter is a project of its own. Do not publish OAuth discovery documents for auth you do not run just because a readiness checker scores them. - State between calls. The revision removed protocol sessions on purpose. A server that needs cross-call state passes explicit handles as tool arguments, and a long job belongs in the Tasks extension, which is a different build.
- Roots, Sampling and Logging. All three are deprecated in 2026-07-28. Log to stderr on stdio, or use OpenTelemetry.
- Subscriptions.
subscriptions/listenreplaces the old GET stream, and a server whose tool list changes only on deploy has nothing to announce on it. The SDK advertiseslistChanged: truefor tools by default, which invites clients to listen; my endpoint advertiseslistChanged: falseto legacy clients and a baretools: {}to modern ones.
Should you build an MCP server at all?
Only if someone has already decided to connect you. That is the position I argued in MCP vs API for AI agents: MCP pays off when a host application integrates your service on purpose, and agents that find you while reading the web are better served by a plain HTTP endpoint with an OpenAPI description.
If you do build one, the server is the cheap part now. On a local build of this site’s endpoint on 15 November 2026, the Inspector returned the same search results with modern, legacy and auto, and the site’s own end-to-end test passed with zero failures. Nothing in that run says whether anyone outside calls it, and I have no record that anyone does. Getting a host to connect it is the part no SDK does for you.
01 / Discussion
No comment section here — all discussions happen on X.
02 / More articles
Agentic workflows: one word for two opposite designs, and the difference is who owns the loop
Anthropic's taxonomy and GitHub's product disagree on what the term covers, so the label settles nothing about a design. One test does: hand an LLM the steering only when an outside check, not its own claim, can confirm the job is finished.
Anthropic's always-on agents guide: what the inbox, timers and outbox do when a worker dies, tested in SQLite
Most of the failure handling the guide leaves out comes down to one move: make every wake-up an inbox event with a key the database refuses to store twice. Retried webhooks, timer replays, approval answers and their expiries all fit through that one constraint. Leases, retries and the outbox need a little more.
Self-hosted Claude Managed Agents: the server is yours, and so are the failures that report nothing
Self-hosting moves custody of the transcripts to you, and the operations come with it. Anthropic's list of differences names the cases where your server says success, or nothing, while the work is stuck, dropped or answered by another model. Those rows are the acceptance test for a deployment.