Guide · Debugging
TypeScript MCP server debugging
TypeScript MCP servers have a set of failure modes that don't appear in plain JavaScript: Zod schema validation errors that surface as opaque MCP errors, async stack traces that don't show the originating call, and source map mismatches that put breakpoints on the wrong line. This guide covers the TypeScript-specific debugging patterns that come up most often when building MCP servers with the official TypeScript SDK.
TL;DR
Use ZodError.flatten() to get field-level validation error messages. Enable --stack-trace-limit=50 to see full async stack traces. Use ts-node for debugging (no build step, inline source maps). Run vitest --inspect-brk to debug test-time tool calls. The most common TypeError in MCP handlers is accessing .content on a null result from an external API — always check for null before accessing nested properties.
Debugging Zod schema validation errors
The MCP TypeScript SDK validates tool arguments against your Zod schema before calling the handler. When validation fails, it throws a ZodError. The default error message (ZodError: [...]) is a JSON array that's hard to read. Use ZodError.flatten() to get a structured breakdown of which fields failed:
import { z } from 'zod';
const searchSchema = z.object({
query: z.string().min(1, 'query must not be empty'),
limit: z.number().int().min(1).max(100).optional(),
filter: z.enum(['active', 'archived', 'all']).optional(),
});
// In a catch block or error handler:
try {
searchSchema.parse(args);
} catch (err) {
if (err instanceof z.ZodError) {
// Flatten gives you fieldErrors and formErrors separately
const flat = err.flatten();
console.error('Validation failed:', JSON.stringify(flat, null, 2));
// Output:
// {
// "fieldErrors": {
// "query": ["query must not be empty"],
// "filter": ["Invalid enum value. Expected 'active' | 'archived' | 'all', received 'deleted'"]
// },
// "formErrors": []
// }
}
}
The MCP SDK calls parse() on your schema before invoking the handler — you never see these errors in your handler code. To observe them during development, wrap the schema parse in your own validation step and log the result:
server.tool('search_documents', searchSchema, async (args) => {
// At this point, args is already validated and typed by Zod
// But if you want to debug what the raw args look like before validation:
// Use logger.debug() or a DEBUG flag — never console.log() in stdio mode
logger.debug({ rawArgs: args }, 'handler called with typed args');
});
When the MCP client sends arguments that fail Zod validation, the SDK returns a JSON-RPC error to the client (not an isError: true tool result). The MCP Inspector shows this as a red error in the Protocol tab rather than a yellow error in the Result panel.
Improving async stack traces
TypeScript MCP server handlers are async functions. When an error is thrown inside an await call several levels deep, the default Node.js stack trace often starts at the Promise boundary and doesn't show the originating call. In Node.js 22, async stack traces are significantly better, but you can still tune them:
# Increase stack trace depth (default is 10 frames, which is often too few)
NODE_OPTIONS="--stack-trace-limit=50" node dist/index.js
# In code — set this at startup before any async operations begin
Error.stackTraceLimit = 50;
For async errors that still show truncated stacks, use the cause property to chain errors with context:
server.tool('get_document', getDocSchema, async (args) => {
try {
const row = await db.query('SELECT * FROM documents WHERE id = $1', [args.id]);
if (!row) {
throw new Error(`Document ${args.id} not found`);
}
return { content: [{ type: 'text', text: JSON.stringify(row) }] };
} catch (err) {
// Chain the original error as `cause` to preserve the original stack
throw new Error(`get_document failed for id=${args.id}`, { cause: err });
}
});
The { cause: err } pattern (available in Node 16.9+, TypeScript 4.6+) creates a causal chain in the error. When you log or inspect the error, you see both the handler-level context and the original database error with its stack.
ts-node vs tsc: source maps for debugging
The key difference for debugging:
| Property | ts-node | tsc (compiled) |
|---|---|---|
| Build step before debugging | None — transpiles on the fly | Required — must run npm run build |
| Source map type | Inline source maps in memory | External .map files in dist/ |
| Breakpoint accuracy | Exact — directly maps to .ts source | Exact if sourceMap: true in tsconfig |
| Startup speed | Slower (transpilation overhead) | Faster once built |
| Production use | Not recommended (memory overhead) | Standard |
| Stale build risk | None — always uses current source | High — easy to debug old compiled code |
For debugging sessions, ts-node eliminates the most common mistake: hitting a breakpoint in compiled code that's one commit behind your current source. Configure ts-node for debugging in VS Code's launch.json:
// .vscode/launch.json — ts-node configuration
{
"name": "Debug MCP Server (ts-node, no build)",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/ts-node",
"runtimeArgs": ["--esm"],
"program": "${workspaceFolder}/src/index.ts",
"env": {
"DATABASE_URL": "postgres://localhost/mydb",
"TS_NODE_PROJECT": "${workspaceFolder}/tsconfig.json"
},
"sourceMaps": true,
"console": "integratedTerminal"
}
For ESM projects (the standard for new TypeScript MCP servers), use --esm flag with ts-node. For CommonJS projects, omit it.
Debugging with Vitest (test-time debugging)
The fastest way to debug a specific tool handler bug is to write a Vitest test that reproduces it, then run Vitest with the Node.js inspector. This lets you debug the tool handler code with a breakpoint, without involving the MCP protocol at all:
# Run Vitest with the Node debugger (pauses at start)
node --inspect-brk ./node_modules/.bin/vitest run src/tools/search.test.ts
# Or using the vitest CLI flag (Vitest 1.0+)
vitest --inspect-brk run src/tools/search.test.ts
Then attach VS Code with the attach configuration (port 9229). The test runs normally until it hits a breakpoint in your tool handler — you can step through the handler, inspect variables, and diagnose the failure at the exact line where it goes wrong.
// src/tools/search.test.ts — minimal reproduction test
import { describe, it, expect } from 'vitest';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { createServer } from '../index.js';
describe('search_documents', () => {
it('returns empty array for unknown filter value', async () => {
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
const server = createServer({ db: mockDb });
await server.connect(serverTransport);
const client = new Client({ name: 'test', version: '1' }, { capabilities: {} });
await client.connect(clientTransport);
// Set a breakpoint in the search handler and call it with the failing args
const result = await client.callTool('search_documents', {
query: '', // empty string — does this trigger the right error?
filter: 'deleted', // invalid enum — does Zod catch this?
});
expect(result.isError).toBe(true);
await client.close();
});
});
The InMemoryTransport keeps everything in-process — the test, the client, and the server handler all run in one Node.js process, making it easy to set breakpoints in handler code without any MCP transport complexity.
Common TypeErrors in MCP handlers
These are the most frequent TypeErrors developers encounter in TypeScript MCP handlers, with their root causes and fixes:
| Error message | Root cause | Fix |
|---|---|---|
Cannot read properties of undefined (reading 'content') | An external API returned null or undefined when a result object was expected; code immediately accesses .content without null-checking | Add a null check: if (!result) return { content: [...], isError: true } |
Cannot read properties of null (reading '0') | Accessing index [0] on a null array (database query returned null instead of an empty array) | Check for null: const rows = result ?? [] before indexing |
result.content is not iterable | Tool handler returned an object that doesn't match the MCP result shape — missing content array or content is null | Ensure all code paths return { content: [{ type: 'text', text: '...' }] } |
ZodError: Expected string, received undefined | A required tool argument wasn't sent by the AI model; Zod's parse fails before the handler runs | Check whether the argument is actually required in the schema; if optional, add .optional() to the Zod schema field |
Cannot convert undefined or null to object from Object.entries() | Calling Object.entries() on a result that's null or undefined | Guard: Object.entries(result ?? {}) |
TypeScript strict mode gotchas in MCP handlers
TypeScript strict mode ("strict": true in tsconfig.json) is strongly recommended for MCP servers — it catches the null/undefined access patterns above at compile time. But it introduces some friction in MCP handler code:
// PROBLEM: strict mode flags this because args.filter could be undefined
server.tool('search', schema, async (args: z.infer) => {
const results = await db.search(args.query, args.filter.toUpperCase()); // ERROR: filter possibly undefined
});
// FIX: use optional chaining or explicit null check
const results = await db.search(args.query, args.filter?.toUpperCase());
// or
const results = await db.search(args.query, args.filter ?? 'active');
// PROBLEM: TypeScript doesn't know the MCP result shape satisfies the SDK type
return { content: [{ type: 'text', text: output }] }; // type 'string' is not assignable to type 'TextContent'
// FIX: explicit type annotation matches the SDK's ContentBlock type
return {
content: [{ type: 'text' as const, text: output }], // 'as const' narrows 'string' to literal 'text'
};
The as const pattern for the type field is the most common TypeScript-specific fix in MCP handler code — without it, TypeScript infers type: string instead of type: 'text', which doesn't satisfy the SDK's TextContent type.
Related pages
FAQ
Why do my TypeScript types look correct but the handler still fails at runtime?
TypeScript types are erased at runtime — if the Zod schema doesn't match the TypeScript type, or if an external API returns a shape that differs from its TypeScript type declaration, runtime behavior diverges from what TypeScript inferred. The fix is to always use Zod's parse() or safeParse() at runtime boundaries (API responses, database results) rather than relying solely on TypeScript types. TypeScript types prevent bugs in code you control; Zod validates data you don't control at runtime.
How do I debug Zod validation failures that happen in the MCP SDK before my handler runs?
The MCP SDK validates tool arguments against your Zod schema before calling the handler. If validation fails, your handler never runs — the SDK returns a JSON-RPC error directly. To observe these failures during development: (1) use the MCP Inspector and check the Protocol tab for JSON-RPC errors on tools/call requests; (2) temporarily add a console.error call outside your handler to log the raw args before they hit Zod (only safe in HTTP-mode servers); or (3) write a Vitest test that calls yourSchema.safeParse(badArgs) directly to get the ZodError with flatten().
My async stack traces don't show the tool handler function. Why?
The MCP SDK dispatches tool calls through several layers of async callbacks — your handler is called from within the SDK's request dispatcher, not directly from your code. This means the call stack at an error point starts inside the SDK, and the originating tool call site isn't in the stack. To work around this: (1) increase Error.stackTraceLimit to 50+ to capture more frames; (2) use the VS Code debugger with a breakpoint at the start of your handler to see the full call stack from within the handler; or (3) add { cause: originalError } when re-throwing to chain context.
Should I use ts-node or tsx for TypeScript MCP server development?
tsx is faster than ts-node for development (uses esbuild for transpilation instead of the TypeScript compiler) and handles ESM correctly without the --esm flag. Use tsx for the dev/watch command and ts-node when you need full TypeScript type checking during execution (which tsx skips for speed). For debugging with VS Code, both work — configure runtimeExecutable in launch.json to point to ./node_modules/.bin/tsx for tsx. The source map behavior is the same as ts-node.
How does TypeScript type safety relate to MCP protocol robustness?
TypeScript ensures your handler code is internally consistent, but the MCP protocol is external to TypeScript's type system. An AI model can send any JSON as tool arguments — Zod's runtime validation is what prevents bad data from reaching your TypeScript-typed handler code. Think of it as two layers: TypeScript catches bugs in code you write; Zod catches bad data from the network. AliveMCP adds a third layer — protocol-level probes that verify the entire server (TypeScript code + runtime + network) is serving requests correctly after deployment.