Subchapter 11.3
reference/node_mcp_server_v2.mdMarkdown34 KBView on GitHub
This guide covers building MCP servers with v2 of the MCP TypeScript SDK (@modelcontextprotocol/server), the current stable release. If the project already depends on the v1 package @modelcontextprotocol/sdk, use the or migrate first (see ).
Code patterns are adapted from the MCP TypeScript SDK examples (opens in a new tab). The full v2 documentation index is https://ts.sdk.modelcontextprotocol.io/v2/llms.txt; every page is also served as markdown at its .md URL.
npm install @modelcontextprotocol/server zod
npm install @modelcontextprotocol/express @modelcontextprotocol/node express # Streamable HTTP only
npm install -D @modelcontextprotocol/client typescript tsx @types/node @types/expressimport { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
function createServer(): McpServer {
const server = new McpServer({ name: "example-mcp-server", version: "1.0.0" });
server.registerTool(
"example_greet",
{
title: "Greet",
description: "Greet someone by name",
inputSchema: z.object({ name: z.string().describe("Who to greet") }),
annotations: { readOnlyHint: true }
},
async ({ name }) => ({ content: [{ type: "text", text: `Hello, ${name}!` }] })
);
return server;
}
serveStdio(createServer);
console.error("example-mcp-server running on stdio"); // stderr: stdout carries the protocolMost published examples and training data show v1 code. Translate it before use:
v1 (@modelcontextprotocol/sdk) | v2 |
|---|---|
import ... from "@modelcontextprotocol/sdk/server/mcp.js" | import ... from "@modelcontextprotocol/server" |
import ... from "@modelcontextprotocol/sdk/server/stdio.js" | import ... from "@modelcontextprotocol/server/stdio" |
zod ^3, import { z } from "zod" | zod ^4.2.0, import * as z from "zod/v4" |
inputSchema: { q: z.string() } (raw shape) | inputSchema: z.object({ q: z.string() }) |
server.tool(...), server.resource(...), server.prompt(...) | Removed: use registerTool, registerResource, registerPrompt |
new StdioServerTransport() + server.connect(transport) | serveStdio(createServer) (StdioServerTransport from @modelcontextprotocol/server/stdio also still works) |
Per-request StreamableHTTPServerTransport + express.json() | createMcpHandler(createServer) + createMcpExpressApp() + toNodeHandler() |
Handler extra.signal, extra.authInfo | Handler ctx.mcpReq.signal, ctx.http?.authInfo |
McpError, ErrorCode | ProtocolError, ProtocolErrorCode |
| Node.js 18+ | Node.js 20+ |
Do not use imports from @modelcontextprotocol/sdk/..., raw-shape schemas, Zod 3 or zod/v3, hand-wired StreamableHTTPServerTransport instances, or a shared McpServer instance returned from the factory (one created at module scope and reused across HTTP requests) in a v2 server.
{service}-mcp-server/
├── package.json
├── tsconfig.json
├── README.md
├── src/
│ ├── index.ts # createServer() factory and transport selection
│ ├── api.ts # Upstream API client and error messages
│ ├── tools/ # One registerXxxTools(server) function per domain
│ └── constants.ts # API_BASE_URL, CHARACTER_LIMIT
├── scripts/
│ └── smoke.ts # Self-check client (see Verify Your Server)
└── dist/ # Build output (entry point: dist/index.js)Name the package and server {service}-mcp-server (lowercase, hyphens, no version numbers), e.g. github-mcp-server, jira-mcp-server.
{
"name": "{service}-mcp-server",
"version": "1.0.0",
"description": "MCP server for the {Service} API",
"type": "module",
"bin": { "{service}-mcp-server": "dist/index.js" },
"files": ["dist"],
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"smoke": "tsx scripts/smoke.ts"
},
"engines": { "node": ">=20.3" },
"dependencies": {
"@modelcontextprotocol/express": "^2.0.2",
"@modelcontextprotocol/node": "^2.1.1",
"@modelcontextprotocol/server": "^2.3.1",
"express": "^5.1.0",
"zod": "^4.2.0"
},
"devDependencies": {
"@modelcontextprotocol/client": "^2.3.1",
"@types/express": "^5.0.0",
"@types/node": "^24.0.0",
"tsx": "^4.19.2",
"typescript": "^5.9.3"
}
}bin plus the #!/usr/bin/env node line at the top of src/index.ts let hosts launch the server with npx -y {service}-mcp-serverexpress, @types/express, and the two adapter packageszod at ^4.2.0 or later: v2 does not support Zod 3, and Zod 4.0–4.1 schemas fail to type-check in registerTool. If the server runs anyway (tsx skips type-checking), the SDK logs a fallback warning and, before 4.1.13, drops .describe() text from the advertised schemas{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"]
}Write a createServer() function that builds a fresh McpServer and registers every tool, resource, and prompt on it. You never call it yourself: the transport entry points do.
serveStdio(createServer) calls it once for the stdio connectioncreateMcpHandler(createServer) calls it once per HTTP request, so the HTTP server keeps no state between requests and scales horizontally as-isMcpServer from every call. A factory that returns one shared instance passes manual tests and the self-check script, because they send one request at a time. A request that arrives while another is being served fails with -32603 “Internal server error”, and the cause goes only to the handler’s onerror optioncreateMcpHandler, the factory receives the request context: createMcpHandler(({ authInfo }) => ...) builds an instance for one authenticated callerOne file can serve both transports; the complete example picks one from the TRANSPORT environment variable.
Put the upstream API client at module scope, in one apiRequest helper that every tool calls. Node 20+ has fetch built in. The core of the complete example‘s helper:
const response = await fetch(url, {
headers: { Authorization: `Bearer ${API_KEY}`, Accept: "application/json" },
// Aborts when the client cancels the call, or after 30 seconds
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)])
});
if (!response.ok) throw new ApiError(response.status);User-Agent, version header) here, in one placectx.mcpReq.signal, so a cancelled call also cancels its upstream requesthttpErrorMessage in the complete example): “Rate limit exceeded. Wait a minute before making more requests.”ApiError takes its message from httpErrorMessage and keeps the HTTP status, so a caller can treat a 404 differently from a 401 or a 429isError: true result whose text is the error’s messageslack_send_message, github_create_issue, asana_list_tasksdescription is what the model reads to decide when and how to call the tool. Say what it does, what it returns, when to use a sibling tool instead, and give one or two example requests. JSDoc comments are not extractedtitle (display name), description, inputSchema (if it takes arguments), and annotationsAnnotations are hints for clients, for example to auto-approve read-only tools or to confirm destructive ones. They never change how the SDK runs the tool.
| Annotation | Set to true when the tool… |
|---|---|
readOnlyHint | does not modify anything |
destructiveHint | may delete or overwrite data |
idempotentHint | can be repeated with the same arguments with no extra effect |
openWorldHint | talks to an external system (most API tools) |
inputSchema is a Zod object schema: z.object({...}), or z.strictObject({...}) to reject unknown arguments. From that one schema the SDK derives the JSON Schema clients see, validates arguments before your handler runs, and infers the handler’s argument types (no annotation needed).
const SearchInput = z.strictObject({
query: z.string().min(2).max(200).describe("Text to match against user names and emails"),
limit: z.number().int().min(1).max(100).default(20).describe("Maximum number of users to return"),
offset: z.number().int().min(0).default(0).describe("Number of matches to skip, for pagination"),
response_format: z.enum(ResponseFormat).default(ResponseFormat.MARKDOWN)
.describe("'markdown' for readable text, 'json' for structured data")
});.describe() text becomes the argument’s JSON Schema description, the only documentation the model gets for it.default() are optional for the caller and always present in the handler’s argumentsisError: true result such as Input validation error: Invalid arguments for tool example_search_users: query: Too small: expected string to have >=2 charactersz.strictObject({...}) instead of .strict(), z.enum(MyTsEnum) instead of z.nativeEnum, and z.email() / z.url() / z.uuid() instead of z.string().email() and similarLet the caller choose the format with a response_format argument:
When a result includes structuredContent, the spec says it SHOULD also include the same data serialized as JSON in a text block. So:
structuredContent and its JSON.stringify as the textoutputSchema only on tools whose output is always JSON. With an outputSchema, every successful result must include matching structuredContent, or the SDK replaces it with an output validation erroroutputSchema disallows extra fields, so build structuredContent from the schema, e.g. UserSchema.parse(raw), which also validates the upstream dataList and search tools take limit and offset and return where the next page starts:
const nextOffset = offset + users.length;
const hasMore = data.total > nextOffset;
const page = {
total: data.total,
count: users.length,
offset,
users,
has_more: hasMore,
...(hasMore ? { next_offset: nextOffset } : {})
};In Markdown mode, end the text with how to get more, e.g. More results: call again with offset=20.
Cap responses with a CHARACTER_LIMIT (25,000 characters is a good default). Stay under it by returning fewer items, never by cutting text: next_offset then points at the first item left out, and the JSON text still matches structuredContent. Measure the text the tool sends, not an intermediate value: pretty-printing and the page fields count too.
let users = data.users;
let result = render(users);
while (users.length > 1 && result.text.length > CHARACTER_LIMIT) {
users = users.slice(0, Math.ceil(users.length / 2));
result = render(users);
}render(users) builds the page and its text in the requested format; see the complete example.
MCP has two error channels:
isError: true. The model reads them, so the text should say how to recover. A tool handler produces them by returning isError: true, or by throwing: the thrown error’s message becomes the result textisError channel, so they throw ProtocolError (see Resources and Prompts)One file, two tools, both transports. example_search_users shows pagination, the character limit, and the two output formats; example_get_user shows an outputSchema.
#!/usr/bin/env node
/**
* MCP server for the Example API.
* Serves stdio by default, Streamable HTTP when TRANSPORT=http.
*/
import { createMcpExpressApp } from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
const API_BASE_URL
serveStdio(createServer) reads requests on stdin and writes responses on stdout. Never write to stdout yourself: one console.log corrupts the JSON-RPC stream. Log with console.error.
createMcpHandler(createServer) returns a web-standard handler; toNodeHandler adapts it to Express:
const handler = createMcpHandler(createServer);
const app = createMcpExpressApp();
const nodeHandler = toNodeHandler(handler);
app.all("/mcp", (req, res) => void nodeHandler(req, res, req.body));
app.listen(3000, "127.0.0.1");createMcpExpressApp() installs express.json() and validates Host and Origin headers on localhost binds, which blocks DNS rebinding attacks. When binding all interfaces, name the hosts you serve: createMcpExpressApp({ host: "0.0.0.0", allowedHosts: ["mcp.example.com"] })@modelcontextprotocol/hono and @modelcontextprotocol/fastifyexport default handler is the whole mount; put Host/Origin checks in front as described in the web-standard docs (opens in a new tab)The handler verifies no tokens. For a server exposed beyond localhost, put requireBearerAuth in front of the route:
import { createMcpExpressApp, requireBearerAuth, type OAuthTokenVerifier } from "@modelcontextprotocol/express";
import { OAuthError, OAuthErrorCode, type AuthInfo } from "@modelcontextprotocol/server";
const serverUrl = new URL("https://mcp.example.com/mcp");
const verifier: OAuthTokenVerifier = {
async verifyAccessToken(token): Promise<AuthInfo> {
// Replace with JWT verification or token introspection against your authorization server
const claims = await verifyWithYourAuthServer(token);
if (!claims) throw new OAuthError(OAuthErrorCode.InvalidToken, "Unknown or expired token");
return {
token,
clientId: claims.clientId,
scopes: claims.scopes,
expiresAt: claims.exp, // Required: tokens without an expiry are rejected
resource: new URL(claims.aud) // Compared with expectedResource below
};
}
};
const app = createMcpExpressApp({ host: "0.0.0.0", allowedHosts: ["mcp.example.com"] });
const requireAuth = requireBearerAuth({ verifier, requiredScopes: ["mcp"], expectedResource: serverUrl });
app.all("/mcp", requireAuth, (req, res) => void nodeHandler(req, res, req.body));expectedResource accepts only tokens issued for this server, so a token meant for another service is refusedAuthInfo reaches the factory as createMcpHandler(({ authInfo }) => ...) and tool handlers as ctx.http?.authInfo (undefined on stdio)Transport selection: stdio for local tools a host launches as a subprocess; Streamable HTTP for remote services and multiple clients.
Resources expose read-only data at a URI that the client application reads and attaches as context. Prompts are message templates a user invokes by name. Tools are for anything the model should call, including operations with side effects.
import { completable, ProtocolError, ProtocolErrorCode, ResourceNotFoundError, ResourceTemplate } from "@modelcontextprotocol/server";
// A resource at a fixed URI
server.registerResource(
"example-teams",
"example://teams",
{ title: "Teams", description: "Every team in the Example workspace", mimeType: "application/json" },
async (uri, ctx) => {
const teams = await apiRequest<unknown>("teams", {}, ctx.mcpReq.signal);
return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(teams) }] };
}
);
// A URI template: the matched variables arrive as the second argument
server.registerResource(
"example-user",
new ResourceTemplate("example://users/{userId}", { list: undefined }),
{ title: "User profile", description: "One Example user by ID", mimeType: "application/json" },
async (uri, { userId }, ctx) => {
if (!/^U\d+$/.test(String(userId))) {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `User IDs look like U123, got "${userId}"`);
}
const user = await apiRequest<unknown>(`users/${userId}`, {}, ctx.mcpReq.signal).catch((error: unknown) => {
// Only a 404 means the resource is missing; a 401, 429, or timeout keeps its own error
if (error instanceof ApiError && error.status === 404) throw new ResourceNotFoundError(uri.href);
throw error;
});
return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(user) }] };
}
);
// A prompt; completable() lets clients autocomplete the argument as the user types
const TEAMS = ["engineering", "marketing", "sales"];
server.registerPrompt(
"example_team_report",
{
title: "Team report",
description: "Summarize who is on a team",
argsSchema: z.object({
team: completable(z.string().describe("Team name"), value => TEAMS.filter(team => team.startsWith(value)))
})
},
async ({ team }) => ({
messages: [
{
role: "user",
content: { type: "text", text: `Use example_search_users to find everyone on the ${team} team, then summarize the team in a short table.` }
}
]
})
);list callback that returns { resources: [...] } to make its instances appear in resources/list; { list: undefined } leaves them readable but unlistedrealpath and reject anything outside your root before readingThe handles returned by registerTool, registerResource, and registerPrompt send the matching list_changed notification when you change them:
const exportTool = server.registerTool(
"example_export_data",
{ description: "Export workspace data (export-enabled plans only)" },
async () => ({ content: [{ type: "text", text: "Export started" }] })
);
exportTool.disable(); // Sends notifications/tools/list_changed
exportTool.enable(); // So do enable(), update(), and remove()Behind createMcpHandler each request has its own server instance, so publish changes through the handler instead: handler.notify.toolsChanged(), handler.notify.resourcesChanged(). Send notifications only when the server’s capabilities actually change.
Write a self-check script that starts the built server, calls its tools as a client would, and exits non-zero on any failure. Run it after every change, along with npm run build; adapt the tool names and assertions to your server.
/**
* Self-check: starts the built server over stdio, calls its tools, and exits non-zero on any failure.
* Run with: npm run build && npm run smoke
*/
import { strict as assert } from "node:assert";
import { Client } from "@modelcontextprotocol/client";
import { getDefaultEnvironment, StdioClientTransport } from "@modelcontextprotocol/client/stdio";
const client = new Client({ name: "smoke-test", version: "1.0.0" });
await client.connect(
new StdioClientTransport({
command: "node",
args: ["dist/index.js"],
// The child gets only a safe default environment; pass the server's own variables explicitly
env: { ...getDefaultEnvironment(), EXAMPLE_API_KEY: process.env.EXAMPLE_API_KEY ?? "" }
})
);
const { tools } = await client.listTools();
assert.deepEqual(tools.map(tool => tool.name).sort(), ["example_get_user", "example_search_users"]);
const search = await client.callTool({
name: "example_search_users",
arguments: { query: "john", limit: 5, response_format: "json" }
});
assert.ok(!search.isError, `search failed: ${JSON.stringify(search.content)}`);
const page = search.structuredContent as { users: { id: string }[]; total: number };
assert.ok(Array.isArray(page.users), "search should return a users array");
if (page.users.length > 0) {
const user = await client.callTool({ name: "example_get_user", arguments: { user_id: page.users[0].id } });
assert.ok(!user.isError, `get_user failed: ${JSON.stringify(user.content)}`);
}
// Invalid arguments must come back as a tool error the model can read
const invalid = await client.callTool({ name: "example_search_users", arguments: { query: "j" } });
assert.equal(invalid.isError, true, "a one-character query should be rejected");
await client.close();
console.error("Smoke test passed");StdioClientTransport gives the child process only a safe default environment (PATH, HOME, and similar), so pass API keys and other settings in envTRANSPORT=http npm start) and connect with new StreamableHTTPClientTransport(new URL("http://127.0.0.1:3000/mcp")) from @modelcontextprotocol/client insteadnpx @modelcontextprotocol/inspector node dist/index.js opens an interactive UIhttps://ts.sdk.modelcontextprotocol.io/v2/testing.mdEach linked example is a runnable server and client pair from the SDK repository:
| To… | See |
|---|---|
| Report progress, send log messages, and handle cancellation in long-running tools | streaming (opens in a new tab) |
| Ask the user for input mid-call (elicitation) | elicitation (opens in a new tab) |
| Notify subscribed clients when resources change | subscriptions (opens in a new tab) |
| Add OAuth login (authorization code flow) | oauth (opens in a new tab) |
| Accept machine-to-machine OAuth (client credentials) | oauth-client-credentials (opens in a new tab) |
| Require different scopes for different tools | scoped-tools (opens in a new tab) |
| Serve on Hono or another web-standard runtime | hono (opens in a new tab), bearer-auth-web (opens in a new tab) |
| Run sessionful 2025-era HTTP deployments | standalone-get (opens in a new tab), sse-polling (opens in a new tab) |
| See every server feature in one reference server | todos-server (opens in a new tab) |
To move an existing @modelcontextprotocol/sdk server to v2, run the official codemod at the package root, then fix what it marks:
npx @modelcontextprotocol/codemod@latest v1-to-v2 .
grep -rn '@mcp-codemod-error' . # Spots the codemod could not rewrite safely
npx tsc --noEmitThe codemod rewrites imports, package.json, registration calls, and handler context access. Check that zod ends up at ^4.2.0 or later, then run your formatter and tests. The full guide is at https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md.
title, a description that says when to use it, and correct annotations.describe() text; z.strictObject where unknown arguments should be rejectedlimit / offset and return next_offsetCHARACTER_LIMIT by returning fewer itemsstructuredContent also include that data serialized as JSON text; outputSchema only on always-JSON toolscreateServer() returns a new McpServer on every call, with everything registered inside it; shared clients live at module scopectx.mcpReq.signal and have a timeoutany; upstream data validated where it feeds structuredContentpackage.json depends on @modelcontextprotocol/server (not @modelcontextprotocol/sdk) and zod ^4.2.0 or later@modelcontextprotocol/sdk/... or zod/v3bin points at dist/index.js, and src/index.ts starts with #!/usr/bin/env nodenpm run build completes without errors