Originally published on Extenify.

If you build an MCP server in Node.js today, most tutorials you find will still show the v1 @modelcontextprotocol/sdk package and server.tool(), and both are now behind the curve. Version 2 of the official TypeScript SDK is the stable line, it implements the 2026-07-28 MCP spec, and it changes how you structure a server. Here is what changed, a minimal stateless server, and the security checks worth adding before you connect Claude.

What actually changed in 2026

The 2026-07-28 spec revision is the biggest shift since remote servers appeared. The parts that affect your code:

  • No more sessions. Protocol-level sessions and the Mcp-Session-Id header are gone from Streamable HTTP.
  • No initialize handshake. Each request carries its protocol version and client capabilities in _meta, and servers must implement server/discover.
  • One stream for updates. subscriptions/listen replaces the HTTP GET endpoint and resource subscriptions.
  • Deprecations. Roots, Sampling, Logging and the old HTTP+SSE transport are deprecated. Dynamic Client Registration gives way to Client ID Metadata Documents for OAuth.

The SDK follows suit. The single package is split into @modelcontextprotocol/server, @modelcontextprotocol/client and adapters such as @modelcontextprotocol/node. You need Node.js 20+ and Zod 4 (Zod 3 is not supported), and server.tool() is replaced by registerTool() with a config object.

The upgrade trap

Bumping the package version does not make your server speak the new protocol. According to the v1-to-v2 migration guide, a server that still calls server.connect(new StdioServerTransport()) serves 2025-era clients only. You need createMcpHandler() (HTTP) or serveStdio() (local) to serve both old and new clients.

This matters more than it sounds, because clients lag behind specs. In the original article's tests, both the v2 and v1 clients still opened with an initialize request for protocol version 2025-11-25. Use the handler that speaks both and you stop caring which version a client picks. Existing projects can start from the official codemod:

npx @modelcontextprotocol/codemod@latest v1-to-v2 . 

A minimal stateless server

createMcpHandler() takes a factory and calls it once per HTTP request, so nothing lives between requests and the endpoint scales horizontally. Here is the shape of it with a single read-only tool:

import { createServer } from 'node:http'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { hostHeaderValidation, originValidation, toNodeHandler } from '@modelcontextprotocol/node'; import * as z from 'zod/v4'; function buildServer({ authInfo }) { const server = new McpServer({ name: 'my-stats', version: '1.0.0' }); server.registerTool('get_summary', { description: 'Latest totals for one project owned by the caller.', inputSchema: z.object({ projectId: z.string().min(1) }), annotations: { readOnlyHint: true, openWorldHint: false }, }, async ({ projectId }) => { const data = await loadSummary(authInfo.extra.userId, projectId); // your DB call return { content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data }; }); return server; } const mcp = toNodeHandler(createMcpHandler(buildServer)); const hosts = ['localhost', '127.0.0.1']; const checkHost = hostHeaderValidation(hosts); const checkOrigin = originValidation(hosts); createServer(async (req, res) => { if (!checkHost(req, res) || !checkOrigin(req, res)) return; const user = await verifyBearer(req.headers.authorization); // your token check if (!user) return res.writeHead(401, { 'WWW-Authenticate': 'Bearer' }).end(); req.auth = { token: user.token, clientId: 'pat', scopes: ['read'], extra: { userId: user.id } }; await mcp(req, res); }).listen(3100, '127.0.0.1'); 

A few details that are easy to miss:

  • The Zod schema is the contract. The SDK turns it into JSON Schema for the client and rejects bad arguments before your handler runs, with an error the model can read.
  • req.auth is how the caller reaches your tools. toNodeHandler() forwards it as authInfo. The SDK never verifies tokens, so that must happen first.
  • Keep the factory cheap. Create database clients once at module scope, not inside buildServer.
  • Return errors as results. An isError: true result with a plain-language message ("call list_projects to see valid IDs") helps the model recover instead of failing.

For a personal tool, serveStdio() from @modelcontextprotocol/server/stdio accepts the same factory, so HTTP and stdio can share one set of tool definitions.

Security checks a model-driven API needs

An MCP server is an API that a model drives, which adds a few risks on top of the usual ones:

  1. Validate Host and Origin. The spec says servers must validate Origin to prevent DNS rebinding and should bind to localhost when local. The @modelcontextprotocol/node guards return 403 for an unknown Host or Origin and let Origin-less (non-browser) requests through.
  2. Scope every query to the caller. Never accept a user ID as a tool argument, because a model can be talked into passing someone else's.
  3. Hash tokens at rest and keep them out of URLs.
  4. Treat text output as untrusted. If a tool returns user-written text such as reviews, "ignore previous instructions" reaches the model verbatim. Keep those tools read-only and away from tools that write.
  5. Pick auth for the client. A bearer token works for Claude Code and scripts. For per-user data in claude.ai you need OAuth 2.0, and sign-in starts only when your server answers 401 with a WWW-Authenticate header.

Design tools for the model, not for humans

  • Return computed answers ("+1.5% in 7 days") rather than raw rows the model has to subtract.
  • Stay within client limits: about 150,000 characters per tool result and a 240-second timeout on claude.ai and Claude Desktop, and 25,000 tokens by default in Claude Code (MAX_MCP_OUTPUT_TOKENS).
  • Write descriptions that say when to call the tool and what the arguments look like.
  • Register tools in a stable order. The new spec asks for a deterministic tools/list order to help client and prompt caching.

Key takeaways

  • Use @modelcontextprotocol/server plus an adapter for new servers; v2 needs Node.js 20+ and Zod 4.
  • Upgrading the package alone keeps you on the 2025 protocol. Use createMcpHandler() or serveStdio() to serve both generations of clients.
  • Stateless per-request servers are the default now; sessions are gone from the spec.
  • Validate Host and Origin, scope every query to the authenticated user, and mark data tools readOnlyHint: true.
  • Keep results small and descriptions model-friendly.

The original post walks through the full build, including a MongoDB data layer, three analytics tools, end-to-end tests with both SDK clients and connecting to Claude: read the complete guide to building an MCP server in Node.js with the v2 SDK. If the data you want to expose is browser extension stats, Extenify tracks daily users, ratings and versions across Chrome, Edge and Firefox stores.

Have you moved an MCP server to the v2 SDK yet, and did the stateless model break anything that relied on sessions?