Build type-safe, production-grade MCP servers with the official TypeScript SDK — Zod input validation, typed resources, structured error handling, and Lambda deployment via esbuild bundles.
Python is the dominant language for AI tooling, but TypeScript has compelling advantages for MCP servers in enterprise environments — especially when the server is consumed by JavaScript/TypeScript clients or when the team's primary expertise is in the JS ecosystem.
.js bundle with esbuild in under 200ms. Lambda cold starts with a bundled TS server are 60–80% faster than Python servers with heavy dependencies like NumPy or Pydantic.The official @modelcontextprotocol/sdk package provides both server and client implementations. Combined with TypeScript 5 and tsx for zero-config execution, you can have a working MCP server in minutes.
Shell — scaffold a TypeScript MCP server project# Initialize project mkdir my-mcp-server && cd my-mcp-server npm init -y # Install MCP SDK + runtime deps npm install @modelcontextprotocol/sdk zod # Install dev tools npm install -D typescript@5 tsx esbuild @types/node # Generate tsconfig npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext \ --strict true --outDir dist --rootDir src
JSON — package.json scripts{ "scripts": { "dev": "tsx watch src/index.ts", "build": "esbuild src/index.ts --bundle --platform=node --target=node20 --outfile=dist/index.js", "start": "node dist/index.js", "typecheck": "tsc --noEmit" } }
The TypeScript MCP SDK uses Zod schemas to define tool inputs. Zod provides runtime validation AND TypeScript type inference from the same schema definition — you write the schema once and get both validation and types automatically.
TypeScript — complete typed MCP server with Zod toolsimport { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; const server = new McpServer({ name: "documents-server", version: "1.0.0", }); // ── Tool 1: search_documents ────────────────────────────────────── const SearchDocumentsInput = z.object({ query: z.string().min(1).max(500).describe("Full-text search query"), limit: z.number().int().min(1).max(50).default(10).describe("Max results"), filter: z.enum(["pdf", "docx", "all"]).default("all"), }); server.tool( "search_documents", "Search the document repository using full-text search", SearchDocumentsInput, async ({ query, limit, filter }) => { // TypeScript knows: query is string, limit is number, filter is "pdf"|"docx"|"all" const results = await searchIndex(query, { limit, filter }); return { content: [{ type: "text" as const, text: JSON.stringify({ results, total: results.length }), }], }; } ); // ── Tool 2: get_document ────────────────────────────────────────── server.tool( "get_document", "Retrieve a document by ID with optional section filter", { document_id: z.string().uuid().describe("Document UUID"), sections: z.array(z.string()).optional().describe("Specific section names to return"), }, async ({ document_id, sections }) => { const doc = await fetchDocument(document_id, sections); return { content: [{ type: "text" as const, text: doc.content }] }; } ); // ── Start server ────────────────────────────────────────────────── const transport = new StdioServerTransport(); await server.connect(transport);
.describe() = MCP tool documentation: Every .describe() call on a Zod field becomes the parameter description in the MCP tool's JSON schema — which Claude reads to understand how to call the tool correctly. Write clear, specific descriptions. z.string().describe("Search query") is much less useful than z.string().describe("Full-text search query. Supports boolean operators: AND, OR, NOT. Phrase search with quotes.").MCP resources expose data that agents can read as context. The TypeScript SDK supports both static resources (fixed URI) and resource templates (URI with parameters) — both with full type safety.
TypeScript — resource and resource template definitionsimport { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; // ── Static resource: company knowledge base index ───────────────── server.resource( "knowledge-index", "docs://knowledge/index", { mimeType: "application/json", description: "Index of all available documents" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(await getDocumentIndex()), }], }) ); // ── Resource template: individual documents by ID ───────────────── server.resource( "document", new ResourceTemplate("docs://documents/{document_id}", { list: async () => ({ resources: (await listDocuments()).map(doc => ({ uri: `docs://documents/${doc.id}`, name: doc.title, mimeType: "text/plain", })), }), }), async (uri, { document_id }) => { // document_id is typed as string — extracted from URI template const doc = await fetchDocument(document_id as string); return { contents: [{ uri: uri.href, mimeType: "text/plain", text: doc.content }], }; } );
MCP defines a structured error format that clients use to understand what went wrong. The TypeScript SDK provides the McpError class and ErrorCode enum — always use these instead of throwing plain Error objects from tool handlers.
TypeScript — structured error handling with McpErrorimport { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; server.tool( "get_document", "Retrieve a document by ID", { document_id: z.string().uuid() }, async ({ document_id }) => { let doc; try { doc = await fetchDocument(document_id); } catch (err) { if (err instanceof DocumentNotFoundError) { // Client-facing: document with this ID does not exist throw new McpError( ErrorCode.InvalidRequest, `Document ${document_id} not found` ); } if (err instanceof PermissionDeniedError) { throw new McpError( ErrorCode.InvalidRequest, `Access denied to document ${document_id}` ); } // Unexpected server error — use InternalError throw new McpError( ErrorCode.InternalError, "Failed to retrieve document due to an internal error" ); } return { content: [{ type: "text" as const, text: doc.content }] }; } ); // ── ErrorCode reference ─────────────────────────────────────────── // ErrorCode.ParseError = -32700 (invalid JSON) // ErrorCode.InvalidRequest = -32600 (bad params, not found) // ErrorCode.MethodNotFound = -32601 (unknown tool/resource) // ErrorCode.InvalidParams = -32602 (param validation failure) // ErrorCode.InternalError = -32603 (unexpected server error)
TypeScript's esbuild bundling makes Lambda deployment exceptionally fast. The entire server compiles to a single JS file — no virtual environments, no dependency layers, no slow pip installs during CI.
Shell — build TypeScript MCP server for Lambda deployment# Bundle to single file (all deps inlined) esbuild src/index.ts \ --bundle \ --platform=node \ --target=node20 \ --external:@aws-sdk/* \ # AWS SDK is provided by Lambda runtime --minify \ --outfile=dist/handler.js # Package for Lambda cd dist && zip -r ../lambda.zip handler.js # Result: ~180KB zip (vs 25MB+ for Python with dependencies) # Deploy aws lambda update-function-code \ --function-name mcp-documents-server \ --zip-file fileb://../lambda.zip
| Dimension | Python (fastmcp) | TypeScript SDK |
|---|---|---|
| Schema definition | Python type hints + Pydantic | Zod schemas (runtime + compile-time) |
| IDE support | Good (Pylance/Pyright) | Excellent (native TS) |
| Lambda cold start | ~400–800ms (with deps) | ~80–150ms (bundled) |
| Bundle size | ~25MB (venv layer) | ~180KB (esbuild) |
| AI/ML library ecosystem | Unmatched (NumPy, HuggingFace) | Limited |
| Error handling | McpError (via SDK) | McpError + ErrorCode enum |
| Best for | AI/ML tools, data processing | API integrations, JS/TS shops |
.describe() method on tool input fields in the TypeScript MCP SDK?McpError(ErrorCode.InternalError, ...) versus McpError(ErrorCode.InvalidRequest, ...) from a TypeScript MCP tool handler?