Max Narditdata & ai systems
← Articles
10 mindev

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://evil.example 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/server 2.4.0, @modelcontextprotocol/node 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.modelcontextprotocol/protocolVersion and its capabilities in io.modelcontextprotocol/clientCapabilities, both inside params._meta.
  • Every result carries resultType, which is "complete" for an ordinary result.
  • tools/list and the other list results must carry ttlMs and cacheScope: a freshness hint, and whether a cached copy may be shared beyond one authorization context.
  • Every HTTP POST carries MCP-Protocol-Version and Mcp-Method, plus Mcp-Name for tools/call, resources/read and prompts/get. A missing or mismatched header gets 400 Bad Request with error code -32020 (HeaderMismatch).
  • The GET stream and ping are removed, and so is logging/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 with 405.

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.

shell
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/node

The tsconfig.json I used:

json
{
  "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:

ts
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:

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

shell
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 json

Both 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/node adapts it, and the same package has the two guards the handler does not apply on its own.

src/http.ts:

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/server 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:

shell
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 json

Both 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:

json
{"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:

shell
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 403

Host. 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":3600000,"cacheScope":"public". 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":false, 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/listen replaces the old GET stream, and a server whose tool list changes only on deploy has nothing to announce on it. The SDK advertises listChanged: true for tools by default, which invites clients to listen; my endpoint advertises listChanged: false to legacy clients and a bare tools: {} 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.

Max Nardit@mnardit

02 / More articles