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 |
|---|---|
|
The server: |
|
The client (see Building MCP Clients). |
|
|
|
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. |
|
The raw Zod |
|
The Inspector, with the |
The SDK is ESM-first and also ships a CommonJS build, so a CommonJS project can require() the packages.
Setting up the project
{
"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"
}
}
{
"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.
// 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.
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).
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:
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 freshMcpServerserves 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()isexpress()withexpress.json()and DNS-rebinding protection already applied. With the default127.0.0.1bind it validates theHostandOriginheaders and answers403for 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 anOriginheader always passes, so non-browser MCP clients are unaffected. -
toNodeHandler(handler)adapts the web-standardRequest/Responsehandler to Express’s(req, res). Passingreq.bodyas the third argument avoids re-reading a stream thatexpress.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:
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:
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, |
|
|
|
Zod 3 accepted |
Zod 4.2 or newer, or another Standard Schema library. A Zod 3 range fails quietly at the first |
|
Moved to |
|
Renamed |
|
Removed. Migrate to Streamable HTTP; a frozen v1 copy is published for migration only. |
Handler |
|
|
|
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
-
Authorization protects the endpoint with
requireBearerAuth. -
Testing & Debugging holds the contract script this server passes.
-
Building MCP Clients connects to it with
@modelcontextprotocol/client. -
Deploying Remote Servers covers containers, gateways and scaling.