Node.js MCP Servers

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against the 2026-07-28 revision of the specification and the TypeScript SDK v2 packages @modelcontextprotocol/server 2.2.0, @modelcontextprotocol/express 2.0.1 and @modelcontextprotocol/node 2.1.0, with Zod 4.6.5, TypeScript 7.0.2 and Node.js 24.20.0, as checked on 2026-09-29 (see the Model Context Protocol baseline). The SDK itself needs Node.js 20 or newer. The server type-checks with tsc --noEmit against those packages and passed the shared Inspector contract script over stdio (five steps) and over Streamable HTTP (all six, including the multi-round-trip elicitation); the migration walkthrough at the end was run with the real codemod.

This is the TypeScript build of the docs-assistant server introduced on Python MCP Servers; the Java one is on Spring Boot MCP Servers. One difference from the other two builds: the TypeScript server searches a stubbed in-memory index, not a vector database. The stub is the same four pages, and a production build would replace search() with a call to a retrieval service, as described on Exposing RAG through MCP.

The v2 packages

The v1 SDK was one package, @modelcontextprotocol/sdk. v2 splits it, and the split shows up in every import:

Package Role

@modelcontextprotocol/server

The server: McpServer, registerTool / registerResource / registerPrompt, createMcpHandler, the multi-round-trip helpers, the request-state codec. Its /stdio subpath holds serveStdio().

@modelcontextprotocol/client

The client (see Building MCP Clients).

@modelcontextprotocol/node

toNodeHandler, which adapts a web-standard MCP handler to Node’s (req, res).

@modelcontextprotocol/express, /hono, /fastify

Framework adapters: an app factory with the protocol’s DNS-rebinding protection and, for Express, the bearer-token middleware. The framework itself is a peer dependency you install.

@modelcontextprotocol/core

The raw Zod *Schema constants of the protocol types, for code that validates wire messages.

@modelcontextprotocol/inspector

The Inspector, with the mcp-inspector command used by the contract script.

The SDK is ESM-first and also ships a CommonJS build, so a CommonJS project can require() the packages.

Setting up the project

package.json
{
  "name": "docs-assistant-ts",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsc --noEmit",
    "start": "tsx src/http.ts",
    "stdio": "tsx src/stdio.ts"
  },
  "dependencies": {
    "@modelcontextprotocol/express": "^2.0.1",
    "@modelcontextprotocol/node": "^2.1.0",
    "@modelcontextprotocol/server": "^2.2.0",
    "express": "^5.2.1",
    "zod": "^4.6.5"
  },
  "devDependencies": {
    "@modelcontextprotocol/inspector": "^2.8.0",
    "@types/express": "^5.0.6",
    "@types/node": "^26.6.3",
    "tsx": "^4.23.15",
    "typescript": "^7.0.2"
  }
}
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext",
    "strict": true, "skipLibCheck": false, "types": ["node"], "noEmit": true
  },
  "include": ["src"]
}

Three details are easy to miss. "type": "module" is required for the ESM imports below. "types": ["node"] is needed for the Node globals under TypeScript 7. And Zod must be 4.2 or newer: v2 dropped Zod 3, and a project that keeps a ^3.25 range installs and type-checks cleanly and then fails at runtime, when the first tools/list reports an error. Import Zod from zod/v4 in code, as the SDK’s own examples do.

store.ts — the stubbed index
// Stubbed in-memory documentation index shared by the docs-assistant examples.
export interface Page { component: string; page: string; title: string; text: string }

export const PAGES: Page[] = [
    { component: 'algebra', page: 'matrices', title: 'Matrices', text: 'Matrix decompositions such as LU, QR and SVD solve linear systems.' },
    { component: 'algebra', page: 'wrappers', title: 'Wrappers', text: 'Wrapper classes hide the decomposition details behind a simple solver.' },
    { component: 'units', page: 'conversion', title: 'Unit conversion', text: 'Convert lengths, masses and times between unit systems.' },
    { component: 'statistics', page: 'gaussian', title: 'Gaussian distributions', text: 'Normal distributions, means and covariance matrices.' }
];

export const COMPONENTS = ['algebra', 'units', 'statistics'] as const;

export function search(query: string, component: string, topK = 3) {
    const words = query.toLowerCase().split(/\s+/).filter(Boolean);
    return PAGES.filter(p => p.component === component)
        .map(p => {
            const haystack = `${p.title} ${p.text}`.toLowerCase();
            return { component: p.component, page: p.page, title: p.title, score: words.filter(w => haystack.includes(w)).length, snippet: p.text };
        })
        .filter(h => h.score > 0)
        .sort((a, b) => b.score - a.score || a.page.localeCompare(b.page))
        .slice(0, topK);
}

export function read(component: string, page: string): string {
    const p = PAGES.find(x => x.component === component && x.page === page);
    if (!p) throw new Error(`unknown page ${component}/${page}`);
    return `= ${p.title}\n\n${p.text}\n`;
}

The docs-assistant server

The server is a factory: a function that builds and returns a configured McpServer. Both transports below take the factory, not an instance, which is the design that lets one codebase run over stdio and behind a stateless HTTP endpoint alike.

server.ts — the complete docs-assistant server
import {
    acceptedContent,
    inputRequired,
    McpServer,
    ResourceTemplate,
    type CallToolResult,
    type InputRequiredResult
} from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
import { COMPONENTS, read, search } from './store.js';

const hitSchema = z.object({ component: z.string(), page: z.string(), title: z.string(), score: z.number(), snippet: z.string() });
const componentChoice = z.object({ component: z.enum(COMPONENTS).meta({ title: 'Which component should I search?' }) });

export function createDocsAssistant(): McpServer {
    const server = new McpServer({ name: 'docs-assistant', version: '1.0.0' });

    server.registerTool(
        'search_docs',
        {
            description: 'Search the documentation of one component and return the best matching pages.',
            inputSchema: z.object({
                query: z.string(),
                component: z.string().optional(),
                top_k: z.number().int().min(1).max(10).default(3)
            }),
            outputSchema: z.object({ query: z.string(), component: z.string(), hits: z.array(hitSchema) }),
            annotations: { readOnlyHint: true, openWorldHint: false }
        },
        async ({ query, component, top_k }, ctx): Promise<CallToolResult | InputRequiredResult> => {
            let chosen = component;
            if (chosen === undefined) {
                const answer = acceptedContent(ctx.mcpReq.inputResponses, 'component', componentChoice);
                if (answer === undefined) {
                    return inputRequired({
                        inputRequests: {
                            component: inputRequired.elicit({ message: 'Which component should I search?', requestedSchema: componentChoice })
                        }
                    });
                }
                chosen = answer.component;
            }
            if (!(COMPONENTS as readonly string[]).includes(chosen)) {
                return { isError: true, content: [{ type: 'text', text: `Unknown component '${chosen}'` }] };
            }
            const output = { query, component: chosen, hits: search(query, chosen, top_k) };
            return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output };
        }
    );

    server.registerResource(
        'docs-page',
        new ResourceTemplate('docs://{component}/{page}', { list: undefined }),
        { title: 'Documentation page', description: 'The AsciiDoc source of one documentation page', mimeType: 'text/asciidoc' },
        async (uri, { component, page }) => ({
            contents: [{ uri: uri.href, mimeType: 'text/asciidoc', text: read(String(component), String(page)) }]
        })
    );

    server.registerPrompt(
        'answer_with_citations',
        {
            description: 'Instruct the model to answer from the docs and cite page URIs.',
            argsSchema: z.object({ question: z.string(), component: z.string().default('algebra') })
        },
        ({ question, component }) => ({
            messages: [
                {
                    role: 'user' as const,
                    content: {
                        type: 'text' as const,
                        text: `Answer the question using only search_docs results for component '${component}'. Cite every claim as docs://${component}/<page>.\n\nQuestion: ${question}`
                    }
                }
            ]
        })
    );

    return server;
}

registerTool and Standard Schema

registerTool(name, config, handler) takes an explicit config object; the variadic server.tool(…​) of v1 is gone. The inputSchema is a schema object, and the SDK does three things with it: it derives the JSON Schema 2020-12 that tools/list advertises, it validates the arguments before your handler runs (a call that breaks the schema is rejected without reaching your code), and it infers the handler’s argument types. .describe() text on a field survives into the advertised schema, and it is the only documentation the model has for that argument, so write it for the model.

The schema does not have to be Zod. v2 accepts any library that implements the Standard Schema specification; Zod 4, ArkType and Valibot do. To check that, the same tool was registered with ArkType instead of Zod, and it type-checked and advertised the same JSON Schema:

import { type } from 'arktype';

server.registerTool(
    'search_docs',
    {
        description: 'Search the documentation of one component.',
        inputSchema: type({ query: 'string', component: '"algebra" | "units" | "statistics"', 'top_k?': '1 <= number.integer <= 10' })
    },
    async ({ query, component }) => ({ content: [{ type: 'text', text: `hits for ${query} in ${component}` }] })
);

tools/list then reported properties for component as an enum of the three names, top_k as an integer between 1 and 10, and required: ["component", "query"]. If you already hold a JSON Schema, the SDK’s fromJsonSchema() helper wraps it instead of a schema object. See the TypeScript SDK documentation for the tool guide.

An outputSchema does for results what inputSchema does for arguments: the value in structuredContent is validated against it, and the tool description advertises it. As in every stack, the handler should also return the JSON as a text block for clients that read only unstructured content; the listing does both with one output object. An unknown component returns isError: true with a message, an execution error the model can read, and never throws. The listing’s annotations are the untrusted hints described on Tools, Resources, Prompts & Elicitation.

Elicitation with inputRequired

The handler is typed to return either a CallToolResult or an InputRequiredResult. When component is missing, it looks in ctx.mcpReq.inputResponses for the user’s answer with acceptedContent, validated against the same Zod schema that described the question; if there is none, it returns inputRequired({ inputRequests: { component: inputRequired.elicit(…​) } }), which the SDK sends as an input_required result. The client asks its user and retries the call with the answers, and the handler runs again from the top with inputResponses populated. The handler is therefore written as a function of its inputs, not as a coroutine that waits for the user, which is the whole point of the stateless design. The wire exchange is on Tools, Resources, Prompts & Elicitation. For pre-2026-07-28 clients the SDK’s legacy shim is on by default: it turns the same input_required return into real elicitation/create requests over the session and re-enters the handler, so one implementation serves both eras.

The requestState that travels with a retry is unprotected by default in the TypeScript SDK. A server with more than one instance must sign it: createRequestStateCodec returns an HMAC-SHA256 mint / verify pair (a key of at least 32 bytes shared by every instance, and a time-to-live) whose verify you pass as ServerOptions.requestState.verify, and tampered or expired state is then rejected with -32602 before the handler runs. Read the key from a secret store.

Resources and prompts

registerResource takes a name, a ResourceTemplate (URI template plus options; { list: undefined } says the template offers no enumeration), a metadata object and a read callback that receives the URI and the template variables. It requires the metadata argument, {} if you have nothing to say. registerPrompt takes an argsSchema in the same Standard Schema style and returns messages.

Serving over stdio

serveStdio(factory) from @modelcontextprotocol/server/stdio owns the transport: it calls the factory to build the instance that serves the connection, reads JSON-RPC from stdin, and writes it to stdout. It replaces the v1 wiring of new StdioServerTransport() plus server.connect(transport).

stdio.ts
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createDocsAssistant } from './server.js';

serveStdio(createDocsAssistant);
console.error('docs-assistant listening on stdio');

Note console.error. Standard output is the protocol channel and the host parses every line of it as a message, so a console.log corrupts the connection; use console.error, which writes to standard error. Start it with npx tsx src/stdio.ts and register it with a host as a stdio command (see Connecting Hosts). serveStdio also serves older clients from the same factory by default. The stdio contract script passed all five of its steps against this file; the multi-round-trip step is HTTP-only because the CLI declares no elicitation capability, as noted on Testing & Debugging.

Serving Streamable HTTP with Express

For a remote server, createMcpHandler(factory) turns the same factory into a web-standard HTTP handler, and the Node and Express packages adapt it to Express:

http.ts
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { createDocsAssistant } from './server.js';

const handler = createMcpHandler(createDocsAssistant);
const app = createMcpExpressApp();
const node = toNodeHandler(handler);
app.all('/mcp', (req, res) => void node(req, res, req.body));

const port = Number(process.env.PORT ?? 3000);
app.listen(port, '127.0.0.1', () => console.error(`docs-assistant listening on http://127.0.0.1:${port}/mcp`));

Each piece has one job:

  • createMcpHandler(factory) calls the factory once per request, so a fresh McpServer serves every call and nothing in memory survives between requests. That is what makes instances interchangeable behind a load balancer, and it is why a handler must not keep per-connection state in a variable (see Transports).

  • createMcpExpressApp() is express() with express.json() and DNS-rebinding protection already applied. With the default 127.0.0.1 bind it validates the Host and Origin headers and answers 403 for a non-localhost value before your handler runs. Binding to all interfaces removes that default, so name the hosts you serve: createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['docs.example.com'] }). A request without an Origin header always passes, so non-browser MCP clients are unaffected.

  • toNodeHandler(handler) adapts the web-standard Request / Response handler to Express’s (req, res). Passing req.body as the third argument avoids re-reading a stream that express.json() already consumed.

Start it with npx tsx src/http.ts and the endpoint is http://127.0.0.1:3000/mcp. The whole contract script passed against it, and the mcp-inspector --cli calls and the curl walkthrough on Transports work unchanged. Authentication is one more middleware on the route: requireBearerAuth({ verifier, …​ }) from the Express package verifies the bearer token, attaches the result to req.auth, and toNodeHandler forwards it so the handler reads it as ctx.http.authInfo. The complete, run version with a JWKS verifier, the Protected Resource Metadata router and scope checks is on Authorization.

The handler is web-standard, so the same factory runs elsewhere. The SDK has @modelcontextprotocol/hono and @modelcontextprotocol/fastify adapters, and createMcpHandler alone is enough on runtimes that speak Request and Response, such as Cloudflare Workers, Deno and Bun.

Migrating from the v1 SDK

A server written against @modelcontextprotocol/sdk needs a mostly mechanical migration and a few decisions. The SDK ships a codemod; the official upgrade guide is its manual. Here is a v1 docs-assistant fragment of the kind most existing servers contain:

v1 (before)
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

const server = new McpServer({ name: 'docs-assistant', version: '1.0.0' });

server.tool('search_docs', 'Search the documentation of one component.',
    { query: z.string(), component: z.string() },
    async ({ query, component }) => ({ content: [{ type: 'text', text: `hits for ${query} in ${component}` }] }));

await server.connect(new StdioServerTransport());

Run the codemod at the package root, not in src, because it also rewrites package.json:

npx @modelcontextprotocol/codemod@latest v1-to-v2 .
grep -rn '@mcp-codemod-error' .   # anything it recognised but could not rewrite safely
npx tsc --noEmit

On the fragment above it reported three changes in one file and edited package.json to remove @modelcontextprotocol/sdk and add @modelcontextprotocol/server. It also printed a warning that the project’s zod range of ^3.25.0 cannot satisfy v2’s floor of Zod 4.2. The result:

v2 (after the codemod)
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { McpServer } from "@modelcontextprotocol/server";
import { z } from 'zod';

const server = new McpServer({ name: 'docs-assistant', version: '1.0.0' });

server.registerTool('search_docs', { description: 'Search the documentation of one component.', inputSchema: z.object({ query: z.string(), component: z.string() }) }, async ({ query, component }) => ({ content: [{ type: 'text', text: `hits for ${query} in ${component}` }] }));

await server.connect(new StdioServerTransport());

With zod bumped to ^4.6.5 and the packages installed, that file type-checked under TypeScript 7. The codemod does not reformat its output, so run your formatter on the changed files. The StdioServerTransport plus connect pair still works; moving to serveStdio(factory) is a follow-up that makes the server era-aware and matches the code above.

v1 v2

One package, @modelcontextprotocol/sdk

@modelcontextprotocol/server, /client, /core, plus /node, /express, /hono, /fastify adapters.

server.tool(), .prompt(), .resource() (variadic)

registerTool, registerPrompt, registerResource with a config object; raw { field: z.string() } shapes are wrapped in z.object(). registerResource needs a metadata argument.

Zod 3 accepted

Zod 4.2 or newer, or another Standard Schema library. A Zod 3 range fails quietly at the first tools/list.

StdioServerTransport from sdk/server/stdio.js

Moved to @modelcontextprotocol/server/stdio (client side: @modelcontextprotocol/client/stdio); serveStdio() is the new entry point.

StreamableHTTPServerTransport

Renamed NodeStreamableHTTPServerTransport in @modelcontextprotocol/node; on web-standard runtimes use WebStandardStreamableHTTPServerTransport from the server package. New code should prefer createMcpHandler.

SSEServerTransport

Removed. Migrate to Streamable HTTP; a frozen v1 copy is published for migration only.

Handler extra argument

ctx, with ctx.mcpReq. and ctx.http. (for example ctx.http.authInfo).

McpError, ErrorCode

ProtocolError, ProtocolErrorCode, with SdkError for local failures such as timeouts.

The upgrade guide covers the rest: handler registration by method string instead of schema constants, the OAuth error consolidation, behavioural changes such as list auto-aggregation, and how to migrate a large codebase in stages. Adopting the new protocol revision (createMcpHandler, serveStdio, multi-round-trip requests, subscriptions/listen) is a separate, additive step described in the SDK’s guide for the 2026-07-28 revision, not a v1-to-v2 break.

Where to go next