TypeScript SDK · 2026-07-03 · TypeScript SDK Advanced Patterns arc
The MCP TypeScript SDK from the Inside Out: Dispatch, Types, Decorators, and Build Config
The MCP TypeScript SDK is intentionally minimal — it handles the protocol layer and leaves the rest to you. That minimalism is a feature: it gives you full control over how tools are registered, how errors surface, and how the build is structured. But it also means the SDK gives you no guardrails against four recurring mistakes: putting error handling in the wrong place (because dispatch runs before your handler in ways that aren't obvious), using loose types where TypeScript can enforce correctness at compile time, registering tools with boilerplate that obscures structure, and configuring the build in a way that causes silent runtime failures. This guide covers all four — starting with how the SDK actually works under the hood.
TL;DR
The SDK has two layers: Server (raw JSON-RPC handler, protocol negotiation, transport management) and McpServer (high-level type-safe registration built on top). Tool calls travel an 8-step path from transport.onmessage to your handler — Zod schema validation is step 4, which means validation failures never reach your handler. Use isError: true for domain errors (not found, permission denied), throw for internal server errors, McpError only for protocol-level errors. Use discriminated unions for tools with multiple modes, branded types to prevent ID argument swaps, z.lazy() for recursive schemas. Use decorator-based registration when you have 8+ tools that follow a common structure — otherwise functional server.tool() calls are clearer. Set "module": "NodeNext" in tsconfig; use esbuild for edge/Lambda bundles; run tsc --noEmit separately for type checking. None of this covers production failures — for those, wire AliveMCP to probe the actual protocol stack after every deploy.
How the SDK is structured: Server vs McpServer
The SDK exports two server classes. Most documentation and examples show McpServer, but understanding the relationship between the two unlocks the full API surface.
| Class | Package export | What it does | When to use it directly |
|---|---|---|---|
Server | @modelcontextprotocol/sdk/server/index.js | Raw JSON-RPC handler. Manages protocol negotiation, session lifecycle, and the internal request handler registry (Map<string, RequestHandler>). | Building custom registration frameworks; implementing SDK capabilities the high-level API doesn't expose; intercepting requests before they reach registered handlers. |
McpServer | @modelcontextprotocol/sdk/server/mcp.js | High-level wrapper. Provides type-safe .tool(), .resource(), .prompt() with Zod schema validation. Internally holds a Server reference. | Almost all production use cases. |
Every mcpServer.tool() call registers a handler on the internal Server instance for the JSON-RPC method tools/call. The high-level API is a convenience layer — it adds Zod validation and TypeScript type inference but introduces no magic that can't be replicated with server.setRequestHandler() directly.
A server goes through four lifecycle states:
- Constructed — handlers are registered but no transport is connected. The server has no active session and cannot accept messages.
- Connected —
server.connect(transport)is called. The transport'sonmessagecallback is wired. Theinitializehandshake runs: the server reads the client's capabilities and responds with its own before processing any other methods. - Running — handshake complete. Tool calls, resource reads, and prompt lookups dispatch to registered handlers.
Server.onerrorfires on protocol errors. - Closed —
server.close()disconnects the transport. In-flight requests receive errors or are dropped (transport-dependent). Always callserver.close()in your SIGTERM handler.
One important constraint: a single Server instance can only be connected to one transport at a time. For HTTP servers that handle multiple simultaneous clients, create a new McpServer instance per session, or use StreamableHTTPServerTransport in stateless mode where each request is a self-contained session.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
const server = new McpServer({ name: "my-server", version: "1.0.0" });
server.tool("ping", "Returns pong", {}, async () => ({
content: [{ type: "text", text: "pong" }]
}));
// HTTP handler — new transport per request in stateless mode
export async function handleRequest(req: Request): Promise<Response> {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport); // State: Constructed → Running
const response = await transport.handle(req);
await server.close(); // State: Closed
return response;
}
process.on("SIGTERM", async () => {
await server.close();
process.exit(0);
});
For a detailed breakdown of the transport interface — including how to write a custom transport and how InMemoryTransport works for testing — see MCP TypeScript SDK internals.
How tool dispatch actually works: the 8-step path
Understanding dispatch lets you know exactly where to put error handling and why Zod validation failures look different from handler errors. Here is the full path from a client call to your handler's return value:
- Transport receives the message. The transport's internal I/O fires; it deserializes the incoming bytes and calls
server.onmessage(rawMessage). - JSON-RPC structure validation. The
Serververifies the message is valid JSON-RPC 2.0 (hasjsonrpc: "2.0", validid, validmethodstring). Malformed messages are dropped or responded to with error code-32700 ParseError. - Method routing. The server looks up the method name (
"tools/call") in its handler registry. If no handler is registered, it responds with-32601 MethodNotFound. Your handler is never called. - Parameter validation. The
tools/callhandler validates the request parameters against the method's Zod schema. If the tool name is unknown, the server responds with-32602 InvalidParams(tool not found). If the tool arguments fail Zod validation, the server responds with-32602 InvalidParamswith the validation error details. Your handler is never called in either case. - Handler invocation. The tool handler is called with the validated, fully-typed arguments object and a request context containing client identity from the
initializehandshake. - Handler return — success path. The handler returns a
CallToolResultobject. IfisError: trueis set, the result is still a successful JSON-RPC response — but the client is informed that the tool operation failed. - Handler return — throw path. If the handler throws an uncaught exception, the SDK catches it and responds with a JSON-RPC error (
-32603 InternalError). This is the "server crashed" signal to the client — different fromisError: true. - Response serialization. The result is serialized to JSON-RPC 2.0 format and sent via
transport.send().
The most important takeaway from this path: Zod schema validation happens at step 4, before your handler runs. You don't need to validate arguments inside your handler — by the time your code executes, args is already a fully-typed object that passed schema validation. Add handler-level validation only for business rules the schema can't express: cross-field constraints, referential integrity checks against a database, or rate-limit decisions.
server.tool(
"create_document",
"Create a new document",
{
title: z.string().min(1).max(200),
content: z.string().max(50_000),
authorId: z.string().uuid(),
tags: z.array(z.string().max(50)).max(10).default([]),
},
async ({ title, content, authorId, tags }) => {
// args are already validated — no need to re-check title length or authorId format.
// Only validate things the schema can't express:
const author = await db.users.findById(authorId);
if (!author) {
// Domain error: return isError: true, not throw
return { isError: true, content: [{ type: "text", text: `Author not found: ${authorId}` }] };
}
if (!author.canCreateDocuments) {
return { isError: true, content: [{ type: "text", text: "Author does not have create permissions" }] };
}
const doc = await db.documents.create({ title, content, authorId, tags });
return { content: [{ type: "text", text: JSON.stringify(doc) }] };
}
);
isError vs throw vs McpError: which one to use where
Three error mechanisms exist in the SDK. Using the wrong one causes problems for the client and for monitoring tools reading your server's behavior. The rule is clear once you know what each one signals:
| Mechanism | JSON-RPC status | Client sees | Use when |
|---|---|---|---|
return { isError: true, content: [...] } | 200 success | Tool ran, returned a domain-level error. The LLM receives the error text as tool output and can reason about it. | Expected, recoverable failures: not found, permission denied, invalid state, quota exceeded, external API returned an error. The tool ran correctly — it just found an error condition. |
throw new Error("...") | JSON-RPC -32603 InternalError | Tool call failed with a server error. The LLM typically retries or reports failure. | Unexpected internal failures: unhandled exceptions, database connection failures, programming errors. The server did not handle the request correctly. |
throw new McpError(ErrorCode.X, "...") | JSON-RPC error with specific code | Protocol-level error with a defined error code. | Protocol violations: MethodNotFound for unknown tools, InvalidParams for parameter issues you detect manually, InternalError for unrecoverable server state. Rarely needed — the SDK throws these automatically for most cases. |
The practical rule: use isError: true for everything the LLM should know about and potentially act on. Use throw for bugs and infrastructure failures the LLM cannot fix. Throwing a plain Error for a "user not found" condition is wrong — it deprives the LLM of the information it needs to handle the situation (perhaps by creating the user, or asking the human to provide a valid ID).
Handling concurrent tool calls
The MCP SDK dispatches each incoming tools/call request independently and asynchronously. Multiple tool calls from the same client can be in-flight simultaneously. The SDK provides no built-in queuing, mutual exclusion, or call-depth limit — that's your responsibility.
// Pattern: AbortSignal-based timeout to prevent hung handlers
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
function withTimeout<T>(
fn: (signal: AbortSignal) => Promise<T>,
ms: number
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
return fn(controller.signal).finally(() => clearTimeout(timer));
}
server.tool(
"run_query",
"Execute a database query",
{ sql: z.string(), params: z.array(z.unknown()).default([]) },
async ({ sql, params }) => {
return withTimeout(async (signal) => {
// 25s handler timeout — buffer before typical 30s client timeout
const result = await db.query(sql, params, { signal });
return { content: [{ type: "text", text: JSON.stringify(result) }] };
}, 25_000);
}
);
For shared resources (connection pools, rate limiters, caches), protect them with appropriate concurrency controls — not by relying on the SDK to serialize requests. For the detailed dispatch path including dynamic tool registries, see MCP server tool dispatch internals.
TypeScript type patterns that eliminate runtime bugs
The MCP SDK gives you Zod for input schema definition and TypeScript for handler type inference. Four patterns go beyond the basics and eliminate four distinct classes of runtime error.
Discriminated unions for multi-mode tools
A common mistake is combining structurally different operations into one tool with optional fields: { userId?: string; orgId?: string; query?: string }. No field is required; no compile-time guarantee that the right combination is present. The discriminated union pattern fixes this:
const SearchSchema = z.discriminatedUnion("mode", [
z.object({ mode: z.literal("user"), userId: z.string().uuid(), includeMetadata: z.boolean().default(false) }),
z.object({ mode: z.literal("org"), orgId: z.string().uuid(), includeMembers: z.boolean().default(false) }),
z.object({ mode: z.literal("fts"), query: z.string().min(1).max(500), limit: z.number().int().max(100).default(20) }),
]);
server.tool("search", "Search users, orgs, or full-text content", SearchSchema, async (args) => {
switch (args.mode) {
case "user": {
// TypeScript narrows: args.userId is string, args.orgId doesn't exist
const user = await db.users.findById(args.userId);
return { content: [{ type: "text", text: JSON.stringify(user) }] };
}
case "org": {
const org = await db.orgs.findById(args.orgId);
return { content: [{ type: "text", text: JSON.stringify(org) }] };
}
case "fts": {
const results = await db.search(args.query, args.limit);
return { content: [{ type: "text", text: JSON.stringify(results) }] };
}
default: {
const _exhaustive: never = args; // TypeScript errors here if a mode is missing
throw new Error(`Unhandled mode: ${(_exhaustive as { mode: string }).mode}`);
}
}
});
The discriminant field (mode) must be in the tool description so the LLM knows which value to send for each intent. Keep discriminant values short and semantic: "user", not "MODE_USER_SEARCH_V2".
Branded types for safe ID arguments
Tools frequently receive multiple ID arguments. Without brands, TypeScript treats all of them as string — swapping a userId for an orgId is a silent runtime bug. Branded types make it a compile error with zero runtime cost:
// Brand declaration — erased by TypeScript compiler, no runtime overhead
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrgId = Brand<string, "OrgId">;
type ResourceId = Brand<string, "ResourceId">;
// Zod schemas that apply the brand via transform (cast only, not conversion)
const UserIdSchema = z.string().uuid().transform((v) => v as UserId);
const OrgIdSchema = z.string().uuid().transform((v) => v as OrgId);
const ResourceIdSchema = z.string().uuid().transform((v) => v as ResourceId);
server.tool(
"get_resource",
"Get a resource owned by a user within an org",
z.object({ userId: UserIdSchema, orgId: OrgIdSchema, resourceId: ResourceIdSchema }),
async ({ userId, orgId, resourceId }) => {
// userId is UserId, orgId is OrgId — TypeScript will not compile if you swap them
const resource = await db.resources.get(resourceId, { userId, orgId });
return { content: [{ type: "text", text: JSON.stringify(resource) }] };
}
);
Recursive schemas with z.lazy()
File-system trees, org hierarchies, and nested comment threads are all recursive structures. Zod handles them with z.lazy(), which requires an explicit TypeScript type annotation because inference can't resolve the cycle:
interface TreeNode { id: string; name: string; children: TreeNode[]; }
const TreeNodeSchema: z.ZodType<TreeNode> = z.object({
id: z.string(),
name: z.string(),
children: z.lazy(() => z.array(TreeNodeSchema)),
});
server.tool(
"create_tree",
"Create a nested tree of nodes",
{ root: TreeNodeSchema, maxDepth: z.number().int().min(1).max(10).default(5) },
async ({ root, maxDepth }) => {
const nodeCount = countNodes(root); // root is TreeNode — fully typed recursively
if (nodeCount > 1000) {
return { isError: true, content: [{ type: "text", text: "Tree too large (> 1000 nodes)" }] };
}
const result = await db.trees.create(root, maxDepth);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
}
);
satisfies for compile-time schema validation
When you define a static JSON schema for the MCP inputSchema field (bypassing Zod — sometimes necessary for dynamic schemas), satisfies validates the shape at compile time without widening the type:
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
// satisfies checks structural compatibility without casting to the broader type
const toolDef = {
name: "lookup_user",
description: "Look up a user by ID",
inputSchema: {
type: "object",
properties: { id: { type: "string", format: "uuid" } },
required: ["id"],
},
} satisfies Omit<Tool, "annotations">;
// TypeScript errors at compile time if inputSchema shape is wrong
For the full type composition guide including schema composition with z.merge/z.extend across tools, see TypeScript type composition for MCP servers.
Decorator-based tool registration: when it helps and when it doesn't
TypeScript decorators let you annotate class methods with tool metadata and register them automatically. The pattern is common in server frameworks (NestJS, FastAPI); the question is whether the ergonomic benefit justifies the indirection.
Stage 3 decorators vs experimental decorators
There are two decorator systems. Use Stage 3 for new code:
| Feature | Experimental (experimentalDecorators: true) | Stage 3 (TypeScript 5.0+, default) |
|---|---|---|
| Flag required | "experimentalDecorators": true in tsconfig | None |
| Metadata reflection | Via reflect-metadata polyfill | Via TC39 Decorator Metadata (or not needed) |
| Parameter decorators | Supported | Not supported |
| Ecosystem compat | NestJS, TypeORM, class-validator | Newer libs; NestJS migrating |
| Specification status | Non-standard TypeScript extension | TC39 Stage 3, shipping in V8 |
For MCP servers, you don't need parameter decorators or reflect-metadata — the input schema is passed explicitly to the decorator, not inferred from TypeScript types. Stage 3 decorators work cleanly.
A complete @Tool decorator implementation
// tool-decorator.ts
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
type ZodShape = Record<string, z.ZodTypeAny>;
interface ToolMeta { name: string; description: string; schema: ZodShape; methodKey: string; }
const toolRegistry = new WeakMap<object, ToolMeta[]>();
// Stage 3 method decorator
export function Tool(name: string, description: string, schema: ZodShape) {
return function (target: unknown, context: ClassMethodDecoratorContext) {
const proto = target as object;
const existing = toolRegistry.get(proto) ?? [];
existing.push({ name, description, schema, methodKey: String(context.name) });
toolRegistry.set(proto, existing);
};
}
export function registerTools(instance: object, server: McpServer): void {
const proto = Object.getPrototypeOf(instance) as object;
const tools = toolRegistry.get(proto) ?? [];
for (const meta of tools) {
const handler = (instance as Record<string, unknown>)[meta.methodKey];
if (typeof handler !== "function") continue;
server.tool(meta.name, meta.description, z.object(meta.schema), handler.bind(instance));
}
}
// tools/search-tools.ts — decorated tool class
import { z } from "zod";
import { Tool } from "../tool-decorator.js";
export class SearchTools {
constructor(private readonly db: Database) {}
@Tool("search_users", "Search users by name or email", {
query: z.string().min(1).max(200),
limit: z.number().int().min(1).max(50).default(10),
})
async searchUsers({ query, limit }: { query: string; limit: number }) {
const results = await this.db.users.search(query, limit);
return { content: [{ type: "text", text: JSON.stringify(results) }] };
}
@Tool("search_orgs", "Search organizations by name", {
query: z.string().min(1),
activeOnly: z.boolean().default(true),
})
async searchOrgs({ query, activeOnly }: { query: string; activeOnly: boolean }) {
const results = await this.db.orgs.search(query, { activeOnly });
return { content: [{ type: "text", text: JSON.stringify(results) }] };
}
}
// main.ts
const tools = new SearchTools(db);
registerTools(tools, server);
Method decorators for cross-cutting concerns
Method decorators are also useful for concerns that apply to many tools: timing, auth checks, logging. These work regardless of whether you use decorator-based registration or functional server.tool() calls:
// @Timed — wraps the handler to log duration
function Timed(target: unknown, context: ClassMethodDecoratorContext) {
return function (this: unknown, ...args: unknown[]) {
const start = Date.now();
const name = String(context.name);
return (target as Function).apply(this, args).then(
(result: unknown) => { console.error(JSON.stringify({ event: "tool_done", tool: name, ms: Date.now() - start })); return result; },
(err: unknown) => { console.error(JSON.stringify({ event: "tool_error", tool: name, ms: Date.now() - start, error: String(err) })); throw err; }
);
};
}
// @RequiresAuth — returns isError:true if session has no auth token
function RequiresAuth(target: unknown, context: ClassMethodDecoratorContext) {
return function (this: { authToken?: string }, args: unknown) {
if (!this.authToken) {
return Promise.resolve({ isError: true, content: [{ type: "text", text: "Authentication required" }] });
}
return (target as Function).apply(this, [args]);
};
}
Note that multiple method decorators execute bottom-up: if you stack @Timed above @RequiresAuth, @Timed wraps the result of @RequiresAuth. Keep the ordering intentional — timing should be outermost, auth should run before expensive operations.
When to use decorators vs functional registration
| Situation | Recommendation |
|---|---|
| Fewer than ~8 tools | Functional server.tool() — the indirection isn't worth it; tool definitions are visible in the same file |
| Many tools with consistent structure (DB access, API client, shared context) | Decorator pattern — class groups tools by domain, DI is natural via constructor |
| Migrating from NestJS / existing decorator codebase | Experimental decorators to match ecosystem compat (NestJS is migrating but isn't there yet) |
| New TypeScript 5.0+ project | Stage 3 decorators — no polyfill, no tsconfig flag, spec-compliant |
For the full decorator guide including legacy experimental decorator compatibility, see TypeScript decorators for MCP servers.
Build configuration for production TypeScript MCP servers
Build configuration errors in MCP servers are particularly insidious: they often produce servers that start successfully but fail at the first client connection with an opaque runtime error. Four decisions matter for production correctness.
tsconfig: NodeNext module resolution
The MCP TypeScript SDK is ESM-only. The only tsconfig that correctly handles ESM-only packages in Node.js is "module": "NodeNext" with "moduleResolution": "NodeNext":
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}
Two flags that matter specifically for MCP servers: noUncheckedIndexedAccess: true (tool arguments are objects — without this, array/object access silently produces undefined instead of flagging the unsafe access) and isolatedModules: true (required when using esbuild, which transpiles each file without cross-file type information — catches const enum and namespace merge patterns that esbuild can't handle).
ESM vs CJS: convert, don't fight
If your project uses CJS ("type": "commonjs"), you'll hit this at runtime:
// Error [ERR_REQUIRE_ESM]: require() of ES Module
// node_modules/@modelcontextprotocol/sdk/dist/server/mcp.js not supported.
The correct fix is to convert your project to ESM — set "type": "module" in package.json and switch to NodeNext in tsconfig. Fighting this with dynamic await import() removes TypeScript's static analysis and is fragile. If you have existing CJS code that genuinely can't migrate, run the MCP server as a separate ESM subprocess.
With NodeNext, all imports must use .js extensions even in TypeScript source files:
// Correct — NodeNext requires .js extension
import { SearchTools } from "./tools/search-tools.js";
// Wrong — TypeScript accepts this but Node.js ESM will fail at runtime
import { SearchTools } from "./tools/search-tools";
esbuild for edge and Lambda bundles
esbuild produces a single bundled file in under 100ms for most MCP servers. Use it for edge runtimes and Lambda; use tsc for long-lived Node.js processes where tree-shaking isn't needed:
# Cloudflare Workers — browser-compatible bundle
esbuild src/worker.ts --bundle --platform=browser --target=es2022 --outfile=dist/worker.js
# Node.js Lambda — CJS bundle, AWS SDK externalized (available in Lambda runtime)
esbuild src/handler.ts --bundle --platform=node --target=node18 --format=cjs \
--outfile=dist/handler.js --external:@aws-sdk/*
# Type checking — esbuild doesn't type-check, run tsc separately in CI
tsc --noEmit
The tsc --noEmit step in CI is mandatory. esbuild is a transpiler, not a type checker — it will silently ignore type errors that would prevent the server from working correctly. Make both steps required for your deploy gate.
Monorepo composite builds
If your MCP server lives in a monorepo with shared packages, use TypeScript project references to get incremental builds and correct dependency ordering:
// packages/mcp-server/tsconfig.json
{
"compilerOptions": {
"composite": true, // Required for project references
"declaration": true, // Emit .d.ts for dependent packages
"declarationMap": true, // Source maps for .d.ts (go-to-definition works)
"outDir": "./dist"
},
"references": [
{ "path": "../shared-types" },
{ "path": "../db-client" }
]
}
// Build command — builds only changed packages in dependency order
// tsc --build packages/mcp-server
For the complete build config guide including npm publishing with a dual ESM+CJS+types exports map and Deno/edge runtime compatibility checks, see TypeScript build configuration for MCP servers.
Testing with InMemoryTransport: what it catches and what it misses
The SDK ships InMemoryTransport — a linked pair of in-process transports connected by an in-memory message queue. It lets you exercise the full protocol stack without an HTTP server:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
describe("lookup_user tool", () => {
let server: McpServer;
let client: Client;
beforeEach(async () => {
server = new McpServer({ name: "test", version: "1.0.0" });
server.tool("lookup_user", "Look up user", { id: z.string().uuid() }, async ({ id }) => {
if (id === "00000000-0000-0000-0000-000000000001")
return { content: [{ type: "text", text: JSON.stringify({ id, name: "Alice" }) }] };
return { isError: true, content: [{ type: "text", text: `Not found: ${id}` }] };
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
client = new Client({ name: "test-client", version: "1.0.0" }, {});
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
});
afterEach(async () => { await client.close(); await server.close(); });
it("rejects invalid UUID — SDK validation fires before handler", async () => {
await expect(
client.callTool({ name: "lookup_user", arguments: { id: "not-a-uuid" } })
).rejects.toThrow(); // -32602 InvalidParams from step 4; handler never called
});
it("returns isError:true for unknown IDs", async () => {
const result = await client.callTool({
name: "lookup_user",
arguments: { id: "00000000-0000-0000-0000-000000000099" },
});
expect(result.isError).toBe(true);
});
});
InMemoryTransport tests catch: schema validation behavior, handler logic, throw vs isError: true error handling, wrong tool names, and initialization handshake issues. They don't catch: HTTP routing errors, TLS configuration, environment variable issues, certificate expiry, DNS failures, or network-level failures that only appear when a real client connects over the wire.
That gap is not a testing problem — it's an operations problem. The tests validate the code. External protocol monitoring validates the deployment.
The monitoring gap: what compile-time correctness can't prevent
Strong types, thorough InMemoryTransport tests, and a correctly configured build all improve the probability that your MCP server works correctly when deployed. None of them guarantee it.
Here is a mapping of the failure modes that survive a well-typed, fully-tested, correctly-built TypeScript MCP server:
| Failure mode | TypeScript catches it? | InMemoryTransport catches it? | What catches it |
|---|---|---|---|
| Wrong environment variable in production (DB connection string, API key) | No — env vars are strings at compile time | No — tests use test config | External protocol probe after deploy |
| TLS certificate expiry | No | No | External protocol probe, certificate expiry check |
| Zod schema drift between server version and registered tools | Partially — within a session | Yes — but only the version you tested | External probe comparing tools/list hash across deployments |
| HTTP routing misconfiguration (wrong path, missing CORS header) | No | No — bypasses HTTP layer | External protocol probe that exercises the real HTTP path |
| Deploy succeeded but old process still running on wrong port | No | No | External protocol probe |
| Memory leak causing OOM restart loop (server starts and crashes) | No | No | External protocol probe + uptime monitoring |
| Decorator registration bug — tools registered in wrong order | No | Yes | InMemoryTransport test for tools/list order |
The practical implication: every MCP server deployment needs an external probe that exercises the actual protocol stack — TLS handshake, HTTP routing, MCP initialize, tools/list — against the real deployment URL. The TypeScript SDK's internals tell you how to build the server correctly; AliveMCP tells you when the deployment of that server stops working.
Where to go from here
Each section of this guide has a deep-dive companion in the AliveMCP SEO series:
- MCP TypeScript SDK internals — the full two-class design, transport interface definition, complete lifecycle state diagram, InMemoryTransport test setup, and SDK extension patterns (
Server.setRequestHandler, custom transports, handler wrappers) - MCP server tool dispatch — the complete 8-step dispatch path, all error code mappings, concurrent call behavior,
AbortSignal-based timeout pattern, and dynamic tool registries withnotifications/tools/listChanged - TypeScript type composition for MCP servers — discriminated unions, branded types,
z.lazy()recursive schemas,satisfiesoperator, and schema composition withz.merge/z.extend - TypeScript decorators for MCP servers — complete Stage 3 decorator implementation with
WeakMapregistry,@Timedand@RequiresAuthcross-cutting decorators, decorator execution order, and legacy experimental decorator compatibility for NestJS-adjacent projects - TypeScript build configuration for MCP servers — full Node.js tsconfig with all strict flags annotated, ESM vs CJS interop problem and solution, esbuild for all three targets (Workers, Lambda, Node.js),
tsc --noEmitin CI, monorepo project references, and npm publishing with dual ESM+CJS exports map
For the broader TypeScript patterns that apply to MCP servers at any scale — middleware chains, session handling, plugin systems — see MCP server TypeScript patterns. For TypeScript type safety patterns specifically focused on the interaction between the type system and protocol correctness, see the TypeScript type safety guide.