If you copied an MCP server example in the last year and it stopped working, you are not doing
anything wrong. The protocol and the TypeScript SDK both changed, and the old shape fails silently.
Here is what you will actually see:
[error] MCP server "myserver" failed to start: no tools found TypeError: server.connect is not a function {"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}} Three different bugs, one root cause: the SDK is now v2 and the spec revision is 2026-07-28.
I spent a day on this, read the SDK's actual type definitions, and wrote the smallest server that
works. This post is the diff, the three gotchas that cost the most time, and the HTTP header contract
that no tutorial mentions.
The changes that break old code
| Concern | 2025 revisions | 2026-07-28 |
|---|---|---|
| Package | @modelcontextprotocol/sdk | @modelcontextprotocol/server (split: server / client / node) |
| Register a tool | server.tool(name, schema, fn) | server.registerTool(name, { description, inputSchema }, fn) |
| Register a resource | server.resource(...) | server.registerResource(name, uri, config, fn) |
| Register a prompt | server.prompt(...) | server.registerPrompt(name, { argsSchema }, fn) |
| stdio entry | new StdioServerTransport() + connect() | serveStdio(createServer) — hand it a factory |
| HTTP entry | transport per request + connect() | createMcpHandler(factory) → { fetch } |
| Handshake | initialize / notifications/initialized | removed — version travels in _meta |
| Sessions | Mcp-Session-Id header | removed — the endpoint is stateless |
| Results | — | every result carries resultType: "complete" |
| Modules | CJS or ESM | ESM only |
The structural change is the one that matters: the server is a factory now.
-const server = new McpServer({ name: "x", version: "1.0.0" }); -server.tool("hello", { name: z.string() }, async ({ name }) => ({ content: [...] })); -const transport = new StdioServerTransport(); -await server.connect(transport); +export function createServer(): McpServer { + const server = new McpServer({ name: "x", version: "1.0.0" }); + server.registerTool( + "hello", + { + description: "Greet someone", + inputSchema: z.object({ name: z.string().describe("Who to greet") }) + }, + async ({ name }) => ({ content: [{ type: "text", text: `hi ${name}` }] }) + ); + return server; +} + +void serveStdio(createServer); Why a factory and not an instance? Because the same function later serves HTTP, where
createMcpHandler calls it once per request. That is also why the endpoint is stateless by
construction: nothing may live on the instance. Shared state goes at module scope.
Gotcha 1: console.log kills the connection
stdout is the JSON-RPC channel. One log line and the host reads a corrupt message and drops you.
void serveStdio(createServer); console.error("[minimal] ready on stdio"); // stderr, always This is the single most common cause of "connects, then no tools". No error is printed, because from
the host's point of view the stream was garbage.
Gotcha 2: ESM only, and TypeScript 7 needs types
The v2 packages ship ES modules only. Two settings, or you get ERR_REQUIRE_ESM and
Cannot find name 'process':
{ "type": "module" } { "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext", "types": ["node"] } } TypeScript 7 does not pick up @types/node implicitly in every configuration. Adding "types": ["node"]
is not optional here.
Gotcha 3: server/discover returns -32601 and that is not a bug
The spec requires servers to implement server/discover. Call it and you may get:
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}} Because a request without _meta["io.modelcontextprotocol/protocolVersion"] is treated as a
2025-era opening and served by the legacy path, which has no such method. Add the version and the same
server answers:
{ "jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {} } } } The HTTP contract nobody documents
If you serve over Streamable HTTP, createMcpHandler validates that the request headers agree with
the JSON-RPC body. Three headers are mandatory:
| Header | Value |
|---|---|
content-type | application/json |
accept | application/json, text/event-stream |
MCP-Protocol-Version | 2026-07-28 |
Mcp-Method | server/discover, tools/call, … |
Mcp-Name | the tool name, for methods that name one |
Get one wrong and you do not get a useful error, you get:
{"jsonrpc":"2.0","error":{"code":-32020,"message":"Bad Request: the request headers and body disagree: the body envelope names protocol version 2026-07-28 but the required MCP-Protocol-Version header is absent"}} A working curl:
curl -s -X POST 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' \ -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{ "io.modelcontextprotocol/protocolVersion":"2026-07-28", "io.modelcontextprotocol/clientCapabilities":{}}}}' And one that bit me while testing
Node's fetch will not let you forge the Host header — undici derives it from the URL and
silently drops your override. If you test the DNS-rebinding guard with fetch, it looks like the
guard does not work, because your forged request never reached the server that way. Use node:http
for that test:
http.request({ host: '127.0.0.1', port, path: '/mcp', headers: { host: 'evil.example.com' } }, res => { /* 403 expected */ }); The whole server, working
import { McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; export function createServer(): McpServer { const server = new McpServer({ name: 'minimal', version: '1.0.0' }); server.registerTool( 'echo', { description: 'Return the text you pass in', inputSchema: z.object({ text: z.string().describe('Text to echo back') }) }, async ({ text }) => ({ content: [{ type: 'text', text }] }) ); return server; } void serveStdio(createServer); console.error('[minimal] ready on stdio'); Verify it in 60 seconds, without a host
You do not need Claude, Cursor or Copilot to know whether your server is correct. Drive it with a real
client, in memory:
import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; import { createServer } from './dist/index.js'; const [ct, st] = InMemoryTransport.createLinkedPair(); const server = createServer(); const client = new Client({ name: 'smoke', version: '1.0.0' }); await server.connect(st); await client.connect(ct); const { tools } = await client.listTools(); console.log(tools.map(t => t.name)); // ['echo'] const res = await client.callTool({ name: 'echo', arguments: { text: 'ping' } }); console.log(res.content[0].text); // 'ping' No port, no subprocess, no pipes — this runs in CI and in sandboxes that forbid spawning.
The three rules I keep coming back to
- Log to stderr, never stdout.
- Register inside the factory, never on a shared instance.
- Let bad arguments become
isError: trueresults, not exceptions — the model can read those and retry, and that is the difference between "the tool is broken" and "the model corrected itself".
I put the working server above into a repo along with a dependency-free smoke test:
→ https://github.com/Code-0-0/mcp-v2-minimal
The repo is MIT and it is deliberately minimal. The parts that took the longest to get right —
resources and resource templates, the same factory behind Streamable HTTP, the Host/Origin guards,
the full 18-test harness, and the cookbook with the v1 → v2 migration diff — are in a paid kit here:
→ https://haojm.gumroad.com/l/mcp-starter-kit
Either way: if your server now lists its tools, I did my job.


