MCP TypeScript SDK (V2)
    Preparing search index...

    Building MCP servers

    This guide covers the TypeScript SDK APIs for building MCP servers. For protocol-level concepts — what tools, resources, and prompts are and when to use each — see the MCP overview.

    Building a server takes three steps:

    1. Create an McpServer and register your tools, resources, and prompts.
    2. Create a transport — Streamable HTTP for remote servers or stdio for local integrations.
    3. Connect them with server.connect(transport).

    The examples below use these imports. Adjust based on which features and transport you need:

    import { randomUUID } from 'node:crypto';

    import { createMcpExpressApp } from '@modelcontextprotocol/express';
    import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
    import type { CallToolResult, ResourceLink } from '@modelcontextprotocol/server';
    import { completable, McpServer, ResourceTemplate, StdioServerTransport } from '@modelcontextprotocol/server';
    import * as z from 'zod/v4';

    MCP supports two transport mechanisms (see Transport layer in the MCP overview). Choose based on deployment model:

    • Streamable HTTP — for remote servers accessible over the network.
    • stdio — for local servers spawned as child processes (Claude Desktop, CLI tools).

    Create a NodeStreamableHTTPServerTransport and connect it to your server:

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

    const transport = new NodeStreamableHTTPServerTransport({
    sessionIdGenerator: () => randomUUID()
    });

    await server.connect(transport);

    Options: Set sessionIdGenerator to a function (shown above) for stateful sessions. Set it to undefined for stateless mode (simpler, but does not support resumability). Set enableJsonResponse: true to return plain JSON instead of SSE streams.

    For a complete server with sessions, logging, and CORS mounted on Express, see simpleStreamableHttp.ts.

    For local, process-spawned integrations, use StdioServerTransport:

    const server = new McpServer({ name: 'my-server', version: '1.0.0' });
    const transport = new StdioServerTransport();
    await server.connect(transport);

    Instructions describe how to use the server and its features — cross-tool relationships, workflow patterns, and constraints (see Instructions in the MCP specification). Clients may add them to the system prompt. Instructions should not duplicate information already in tool descriptions.

    const server = new McpServer(
    { name: 'db-server', version: '1.0.0' },
    {
    instructions:
    'Always call list_tables before running queries. Use validate_schema before migrate_schema for safe migrations. Results are limited to 1000 rows.'
    }
    );

    Tools let clients invoke actions on your server — they are usually the main way LLMs call into your application (see Tools in the MCP overview).

    Register a tool with registerTool. Provide an inputSchema (Zod) to validate arguments, and optionally an outputSchema for structured return values:

    server.registerTool(
    'calculate-bmi',
    {
    title: 'BMI Calculator',
    description: 'Calculate Body Mass Index',
    inputSchema: z.object({
    weightKg: z.number(),
    heightM: z.number()
    }),
    outputSchema: z.object({ bmi: z.number() })
    },
    async ({ weightKg, heightM }) => {
    const output = { bmi: weightKg / (heightM * heightM) };
    return {
    content: [{ type: 'text', text: JSON.stringify(output) }],
    structuredContent: output
    };
    }
    );
    Note

    When defining a named type for structuredContent, use a type alias rather than an interface. Named interfaces lack implicit index signatures in TypeScript, so they aren't assignable to { [key: string]: unknown }:

    type BmiResult = { bmi: number };    // assignable
    interface BmiResult { bmi: number } // type error

    Alternatively, spread the value: structuredContent: { ...result }.

    Tools can return resource_link content items to reference large resources without embedding them, letting clients fetch only what they need:

    server.registerTool(
    'list-files',
    {
    title: 'List Files',
    description: 'Returns files as resource links without embedding content'
    },
    async (): Promise<CallToolResult> => {
    const links: ResourceLink[] = [
    {
    type: 'resource_link',
    uri: 'file:///projects/readme.md',
    name: 'README',
    mimeType: 'text/markdown'
    },
    {
    type: 'resource_link',
    uri: 'file:///projects/config.json',
    name: 'Config',
    mimeType: 'application/json'
    }
    ];
    return { content: links };
    }
    );

    Tools can include annotations that hint at their behavior — whether a tool is read-only, destructive, or idempotent. Annotations help clients present tools appropriately without changing execution semantics:

    server.registerTool(
    'delete-file',
    {
    description: 'Delete a file from the project',
    inputSchema: z.object({ path: z.string() }),
    annotations: {
    title: 'Delete File',
    destructiveHint: true,
    idempotentHint: true
    }
    },
    async ({ path }): Promise<CallToolResult> => {
    // ... perform deletion ...
    return { content: [{ type: 'text', text: `Deleted ${path}` }] };
    }
    );

    Return isError: true to report tool-level errors. The LLM sees these and can self-correct, unlike protocol-level errors which are hidden from it:

    server.registerTool(
    'fetch-data',
    {
    description: 'Fetch data from a URL',
    inputSchema: z.object({ url: z.string() })
    },
    async ({ url }): Promise<CallToolResult> => {
    try {
    const res = await fetch(url);
    if (!res.ok) {
    return {
    content: [{ type: 'text', text: `HTTP ${res.status}: ${res.statusText}` }],
    isError: true
    };
    }
    const text = await res.text();
    return { content: [{ type: 'text', text }] };
    } catch (error) {
    return {
    content: [{ type: 'text', text: `Failed: ${error instanceof Error ? error.message : String(error)}` }],
    isError: true
    };
    }
    }
    );

    If a handler throws instead of returning isError, the SDK catches the exception and converts it to { isError: true } automatically — so an explicit try/catch is optional but gives you control over the error message. When isError is true, output schema validation is skipped.

    Resources expose read-only data — files, database schemas, configuration — that the host application can retrieve and attach as context for the model (see Resources in the MCP overview). Unlike tools, which the LLM invokes on its own, resources are application-controlled: the host decides which resources to fetch and how to present them.

    A static resource at a fixed URI:

    server.registerResource(
    'config',
    'config://app',
    {
    title: 'Application Config',
    description: 'Application configuration data',
    mimeType: 'text/plain'
    },
    async uri => ({
    contents: [{ uri: uri.href, text: 'App configuration here' }]
    })
    );

    Dynamic resources use ResourceTemplate with URI patterns. The list callback lets clients discover available instances:

    server.registerResource(
    'user-profile',
    new ResourceTemplate('user://{userId}/profile', {
    list: async () => ({
    resources: [
    { uri: 'user://123/profile', name: 'Alice' },
    { uri: 'user://456/profile', name: 'Bob' }
    ]
    })
    }),
    {
    title: 'User Profile',
    description: 'User profile data',
    mimeType: 'application/json'
    },
    async (uri, { userId }) => ({
    contents: [
    {
    uri: uri.href,
    text: JSON.stringify({ userId, name: 'Example User' })
    }
    ]
    })
    );

    Prompts are reusable templates that help structure interactions with models (see Prompts in the MCP overview). Use a prompt when you want to offer a canned interaction pattern that users invoke explicitly; use a tool when the LLM should decide when to call it.

    server.registerPrompt(
    'review-code',
    {
    title: 'Code Review',
    description: 'Review code for best practices and potential issues',
    argsSchema: z.object({
    code: z.string()
    })
    },
    ({ code }) => ({
    messages: [
    {
    role: 'user' as const,
    content: {
    type: 'text' as const,
    text: `Please review this code:\n\n${code}`
    }
    }
    ]
    })
    );

    Both prompts and resources can support argument completions. Wrap a field in the argsSchema with completable() to provide autocompletion suggestions:

    server.registerPrompt(
    'review-code',
    {
    title: 'Code Review',
    description: 'Review code for best practices',
    argsSchema: z.object({
    language: completable(z.string().describe('Programming language'), value =>
    ['typescript', 'javascript', 'python', 'rust', 'go'].filter(lang => lang.startsWith(value))
    )
    })
    },
    ({ language }) => ({
    messages: [
    {
    role: 'user' as const,
    content: {
    type: 'text' as const,
    text: `Review this ${language} code for best practices.`
    }
    }
    ]
    })
    );

    Logging lets your server send structured diagnostics — debug traces, progress updates, warnings — to the connected client as notifications (see Logging in the MCP specification).

    Declare the logging capability, then call ctx.mcpReq.log(level, data) (from ServerContext) inside any handler:

    const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { logging: {} } });
    

    Then log from any handler:

    server.registerTool(
    'fetch-data',
    {
    description: 'Fetch data from an API',
    inputSchema: z.object({ url: z.string() })
    },
    async ({ url }, ctx): Promise<CallToolResult> => {
    await ctx.mcpReq.log('info', `Fetching ${url}`);
    const res = await fetch(url);
    await ctx.mcpReq.log('debug', `Response status: ${res.status}`);
    const text = await res.text();
    return { content: [{ type: 'text', text }] };
    }
    );

    Progress notifications let a tool report incremental status updates during long-running operations (see Progress in the MCP specification).

    If the client includes a progressToken in the request _meta, send notifications/progress via ctx.mcpReq.notify() (from BaseContext):

    server.registerTool(
    'process-files',
    {
    description: 'Process files with progress updates',
    inputSchema: z.object({ files: z.array(z.string()) })
    },
    async ({ files }, ctx): Promise<CallToolResult> => {
    const progressToken = ctx.mcpReq._meta?.progressToken;

    for (let i = 0; i < files.length; i++) {
    // ... process files[i] ...

    if (progressToken !== undefined) {
    await ctx.mcpReq.notify({
    method: 'notifications/progress',
    params: {
    progressToken,
    progress: i + 1,
    total: files.length,
    message: `Processed ${files[i]}`
    }
    });
    }
    }

    return { content: [{ type: 'text', text: `Processed ${files.length} files` }] };
    }
    );

    progress must increase on each call. total and message are optional. If the client does not provide a progressToken, skip the notification.

    MCP is bidirectional — servers can send requests to the client during tool execution, as long as the client declares matching capabilities (see Architecture in the MCP overview).

    Sampling lets a tool handler request an LLM completion from the connected client — the handler describes a prompt and the client returns the model's response (see Sampling in the MCP overview). Use sampling when a tool needs the model to generate or transform text mid-execution.

    Call ctx.mcpReq.requestSampling(params) (from ServerContext) inside a tool handler:

    server.registerTool(
    'summarize',
    {
    description: 'Summarize text using the client LLM',
    inputSchema: z.object({ text: z.string() })
    },
    async ({ text }, ctx): Promise<CallToolResult> => {
    const response = await ctx.mcpReq.requestSampling({
    messages: [
    {
    role: 'user',
    content: {
    type: 'text',
    text: `Please summarize:\n\n${text}`
    }
    }
    ],
    maxTokens: 500
    });
    return {
    content: [
    {
    type: 'text',
    text: `Model (${response.model}): ${JSON.stringify(response.content)}`
    }
    ]
    };
    }
    );

    For a full runnable example, see toolWithSampleServer.ts.

    Elicitation lets a tool handler request direct input from the user — form fields, confirmations, or a redirect to a URL (see Elicitation in the MCP overview). It supports two modes:

    • Form (mode: 'form') — collects non-sensitive data via a schema-driven form.
    • URL (mode: 'url') — opens a browser URL for sensitive data or secure flows (API keys, payments, OAuth).
    Important

    Sensitive information must not be collected via form elicitation; always use URL elicitation or out-of-band flows for secrets.

    Call ctx.mcpReq.elicitInput(params) (from ServerContext) inside a tool handler:

    server.registerTool(
    'collect-feedback',
    {
    description: 'Collect user feedback via a form',
    inputSchema: z.object({})
    },
    async (_args, ctx): Promise<CallToolResult> => {
    const result = await ctx.mcpReq.elicitInput({
    mode: 'form',
    message: 'Please share your feedback:',
    requestedSchema: {
    type: 'object',
    properties: {
    rating: {
    type: 'number',
    title: 'Rating (1\u20135)',
    minimum: 1,
    maximum: 5
    },
    comment: { type: 'string', title: 'Comment' }
    },
    required: ['rating']
    }
    });
    if (result.action === 'accept') {
    return {
    content: [
    {
    type: 'text',
    text: `Thanks! ${JSON.stringify(result.content)}`
    }
    ]
    };
    }
    return { content: [{ type: 'text', text: 'Feedback declined.' }] };
    }
    );

    For runnable examples, see elicitationFormExample.ts (form) and elicitationUrlExample.ts (URL).

    Roots let a tool handler discover the client's workspace directories — for example, to scope a file search or identify project boundaries (see Roots in the MCP overview). Call server.server.listRoots() (requires the client to declare the roots capability):

    server.registerTool(
    'list-workspace-files',
    {
    description: 'List files across all workspace roots',
    inputSchema: z.object({})
    },
    async (_args, _ctx): Promise<CallToolResult> => {
    const { roots } = await server.server.listRoots();
    const summary = roots.map(r => `${r.name ?? r.uri}: ${r.uri}`).join('\n');
    return { content: [{ type: 'text', text: summary }] };
    }
    );
    Warning

    The tasks API is experimental and may change without notice.

    Task-based execution enables "call-now, fetch-later" patterns for long-running operations (see Tasks in the MCP specification). Instead of returning a result immediately, a tool creates a task that can be polled or resumed later. To use tasks:

    For a full runnable example, see simpleTaskInteractive.ts.

    For stateful multi-session HTTP servers, capture the http.Server from app.listen() so you can stop accepting connections, then close each session transport:

    // Capture the http.Server so it can be closed on shutdown
    const httpServer = app.listen(3000);

    process.on('SIGINT', async () => {
    httpServer.close();

    for (const [sessionId, transport] of transports) {
    await transport.close();
    transports.delete(sessionId);
    }

    process.exit(0);
    });

    Calling transport.close() closes SSE streams and rejects any pending outbound requests. In-flight tool handlers are not automatically drained — they are terminated when the process exits.

    For stdio servers, server.close() is sufficient:

    process.on('SIGINT', async () => {
    await server.close();
    process.exit(0);
    });

    For a complete multi-session server with shutdown handling, see simpleStreamableHttp.ts.

    Under normal circumstances, cross-origin browser restrictions limit what a malicious website can do to your localhost server. DNS rebinding attacks get around those restrictions entirely by making the requests appear as same-origin, since the attacking domain resolves to localhost. Validating the host header on the server side protects against this scenario. All localhost MCP servers should use DNS rebinding protection.

    The recommended approach is to use createMcpExpressApp() (from @modelcontextprotocol/express) or createMcpHonoApp() (from @modelcontextprotocol/hono), which enable Host header validation by default:

    // Default: DNS rebinding protection auto-enabled (host is 127.0.0.1)
    const app = createMcpExpressApp();

    // DNS rebinding protection also auto-enabled for localhost
    const appLocal = createMcpExpressApp({ host: 'localhost' });

    // No automatic protection when binding to all interfaces
    const appOpen = createMcpExpressApp({ host: '0.0.0.0' });

    When binding to 0.0.0.0 / ::, provide an allow-list of hosts:

    const app = createMcpExpressApp({
    host: '0.0.0.0',
    allowedHosts: ['localhost', '127.0.0.1', 'myhost.local']
    });

    createMcpHonoApp() from @modelcontextprotocol/hono provides the same protection for Hono-based servers and Web Standard runtimes (Cloudflare Workers, Deno, Bun).

    If you use NodeStreamableHTTPServerTransport directly with your own HTTP framework, you must implement Host header validation yourself. See the hostHeaderValidation middleware source for reference.

    • examples/server/ — Full runnable server examples
    • Client guide — Building MCP clients with this SDK
    • MCP overview — Protocol-level concepts: participants, layers, primitives
    • Migration guide — Upgrading from previous SDK versions
    • FAQ — Frequently asked questions and troubleshooting
    Feature Description Example
    Web Standard transport Deploy on Cloudflare Workers, Deno, or Bun honoWebStandardStreamableHttp.ts
    Session management Per-session transport routing, initialization, and cleanup simpleStreamableHttp.ts
    Resumability Replay missed SSE events via an event store inMemoryEventStore.ts
    CORS Expose MCP headers for browser clients simpleStreamableHttp.ts
    Multi-node deployment Stateless, persistent-storage, and distributed routing patterns examples/server/README.md