Guide · TypeScript

MCP server TypeScript

The official MCP TypeScript SDK provides the McpServer class and Zod-based tool schema definitions that give you type-safe tool inputs end-to-end: the Zod schema both defines the JSON Schema published in tools/list and narrows the TypeScript type inside the handler function. This means schema drift between what you advertise and what you accept is a compile-time error, not a runtime surprise. The tradeoff is that TypeScript adds build complexity — you need a compile step and a source map setup for production debugging. The rest of this guide walks through both.

TL;DR

Use the @modelcontextprotocol/sdk package with McpServer and define tool inputs with Zod schemas — the SDK infers TypeScript types from the schema automatically. Compile with tsc to dist/ with sourceMap: true and declaration: true. Run the compiled output in production (node dist/index.js) — never run ts-node in production. Set up tsconfig.json with strict: true to catch type mismatches before they reach the MCP session lifecycle. Use AliveMCP to monitor the production server after each deploy so TypeScript compilation errors that slip through to runtime are caught within 60 seconds.

Minimal TypeScript MCP server

// src/index.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import express from 'express';

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

// Define a tool with a Zod schema — the type of `args` is inferred automatically
server.tool(
  'search_docs',
  'Search the documentation for a given query',
  {
    query: z.string().min(1).describe('The search query'),
    limit: z.number().int().min(1).max(50).default(10).describe('Max results to return'),
  },
  async (args) => {
    // TypeScript knows: args.query is string, args.limit is number
    const results = await searchDocs(args.query, args.limit);
    return {
      content: [{ type: 'text', text: JSON.stringify(results) }],
    };
  }
);

// HTTP/SSE transport for remote clients
const app = express();
app.use(express.json());

app.post('/mcp', async (req, res) => {
  const transport = new StreamableHTTPServerTransport({ sessionIdHeader: 'mcp-session-id' });
  await server.connect(transport);
  await transport.handleRequest(req, res);
});

app.listen(parseInt(process.env.PORT ?? '3001', 10));

The Zod schema in server.tool() does three things simultaneously: it defines the JSON Schema published in tools/list (what AI clients see), it validates incoming tool call arguments at runtime (invalid arguments return a -32602 JSON-RPC error), and it narrows the TypeScript type of args inside the handler (compile-time safety). This single-source-of-truth schema is the core TypeScript advantage over a JavaScript MCP server, where schema and runtime validation are separate layers that can drift. See schema drift in MCP tool definitions for why this matters.

tsconfig.json for MCP servers

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "sourceMap": true,
    "declaration": true,
    "declarationMap": true,
    "skipLibCheck": false
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

Key settings and why they matter for MCP servers:

Typed tool input patterns

Zod provides a composable schema language that covers the most common MCP tool input shapes:

import { z } from 'zod';

// String with format constraints
const urlInput = z.string().url().describe('The URL to fetch');

// Enum — maps to JSON Schema "enum" in tools/list
const formatInput = z.enum(['json', 'markdown', 'text']).describe('Output format');

// Optional with default — AI clients see the default in the schema
const limitInput = z.number().int().min(1).max(100).default(20);

// Object — for tools with structured inputs
const searchInput = z.object({
  query: z.string().min(1),
  filters: z.object({
    tags: z.array(z.string()).optional(),
    dateFrom: z.string().datetime().optional(),
  }).optional(),
  limit: limitInput,
});

// Discriminated union — different shapes based on a field value
const actionInput = z.discriminatedUnion('action', [
  z.object({ action: z.literal('create'), title: z.string(), body: z.string() }),
  z.object({ action: z.literal('delete'), id: z.string().uuid() }),
]);

// In your tool handler — TypeScript infers the exact type
server.tool('manage_item', 'Create or delete an item', actionInput.shape, async (args) => {
  if (args.action === 'create') {
    // TypeScript knows: args.title and args.body are strings
    await createItem(args.title, args.body);
  } else {
    // TypeScript knows: args.id is a UUID string
    await deleteItem(args.id);
  }
  return { content: [{ type: 'text', text: 'Done' }] };
});

The z.string().describe() annotations appear as description fields in the JSON Schema that AI clients receive in tools/list. Well-written descriptions are how AI agents understand when and how to call your tools — they're not just for human documentation. Use imperative phrases: "The ID of the item to delete", not "Item ID".

Build and run setup

{
  "scripts": {
    "build": "tsc",
    "build:watch": "tsc --watch",
    "start": "node dist/index.js",
    "dev": "node --loader ts-node/esm --watch src/index.ts",
    "test": "node --loader ts-node/esm --test src/**/*.test.ts",
    "typecheck": "tsc --noEmit"
  }
}

The dev script uses ts-node with --watch for fast iteration locally — file changes restart the server automatically. The start script runs the compiled output from dist/. The critical rule: never run ts-node in production. ts-node compiles on demand with significant startup overhead and lacks the optimizations that tsc's pre-compilation provides. A 2-second cold-start in development becomes a 5-second cold-start in production, which directly affects your cold-start suppression window and availability metrics.

Add a typecheck script that runs tsc --noEmit in CI as a separate step before the full build. This catches type errors fast without waiting for the full emit step, and produces cleaner output since it separates type errors from build errors.

Type-safe error handling

Tool handlers must not throw unhandled exceptions — unhandled throws crash the session. TypeScript helps here with the Result pattern or by catching all errors at the tool boundary:

// Option 1: explicit try/catch in every handler (verbose but clear)
server.tool('fetch_data', 'Fetch data from the API', { id: z.string() }, async (args) => {
  try {
    const data = await fetchFromApi(args.id);
    return { content: [{ type: 'text', text: JSON.stringify(data) }] };
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Unknown error';
    // Return a structured error result, not throw — the session stays alive
    return {
      content: [{ type: 'text', text: `Error: ${message}` }],
      isError: true,
    };
  }
});

// Option 2: a withErrorHandling wrapper for all tools (DRY)
function withErrorHandling<T extends Record<string, z.ZodType>>(
  handler: (args: { [K in keyof T]: z.infer<T[K]> }) => Promise<{ content: Array<{type: string, text: string}> }>
) {
  return async (args: any) => {
    try {
      return await handler(args);
    } catch (err) {
      const message = err instanceof Error ? err.message : 'Unknown error';
      logger.error('tool.error', { error_message: message });
      return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
    }
  };
}

The MCP spec distinguishes between tool-level errors (return isError: true in the result content) and protocol-level errors (throw a McpError with a JSON-RPC error code). Tool-level errors are the right choice for application failures (API down, data not found, validation failed). Protocol-level errors are for situations where the tool call itself is invalid (-32602 invalid params, -32601 method not found). The Zod validation in server.tool() handles the -32602 case automatically — you only need to handle application errors inside the handler. See MCP server error rate for the distinction between client errors and server errors in production metrics.

Related questions

Should I use ESM or CommonJS for my TypeScript MCP server?

ESM (import/export). The official MCP SDK is distributed as ESM only (it uses .js extensions in its source imports). Using CommonJS requires either dynamic import() for the SDK or a bundler that transforms ESM to CJS. Set "type": "module" in package.json and "module": "ESNext" in tsconfig.json. One consequence: you must use .js extensions in your own TypeScript imports (import { logger } from './logger.js') because Node.js ESM resolves the compiled .js file at runtime, not the TypeScript source.

How do I handle optional tool parameters in TypeScript?

Use z.optional() or .optional() chained on a Zod type. The TypeScript type for the handler argument will be string | undefined. If you want a default value when the parameter is absent, use .default(value) — TypeScript will then type the parameter as the non-optional base type inside the handler. For optional object properties that should be omitted rather than null when absent, use exactOptionalPropertyTypes: true in tsconfig — this forces you to be explicit about whether absent means undefined or truly missing from the object.

How do I share types between my MCP server and a client that calls it?

Extract your Zod schemas to a shared package or a shared src/schemas/ directory. Export both the Zod schema (for runtime validation) and the inferred TypeScript type (export type SearchInput = z.infer<typeof searchInputSchema>). If you have a TypeScript client, import the type directly. If you have a JavaScript client or a client in another language, publish the JSON Schema (available as zodToJsonSchema(schema) from the zod-to-json-schema package) as a build artifact. This is the same JSON Schema that appears in tools/list — a single source of truth for both the server's validation and the client's type generation.

What's the recommended Node.js version for TypeScript MCP servers?

Node.js 22 (current LTS). It supports native --watch mode for development, the node:test runner for protocol compliance tests without test framework dependencies, and stable fetch for HTTP-based tool implementations. Node.js 20 is also fine. Avoid Node.js 18 for new projects — it requires polyfills for some ES2022+ features and fetch is behind a flag. Specify the engine in package.json ("engines": {"node": ">=22"}) so CI fails immediately if the wrong Node.js version is used.

Further reading