Guide · TypeScript SDK
MCP server tool dispatch
Tool dispatch is the path a tool call takes from the moment a JSON-RPC request arrives at your MCP server to the moment your handler returns a result. Understanding this path — request routing, schema validation order, handler invocation, error mapping, and concurrent call handling — lets you make correct decisions about error handling strategy, validation placement, and timeout management.
TL;DR
Dispatch path: transport receives message → Server validates JSON-RPC structure → method name lookup in handler registry → schema validation (Zod parse, before your handler runs) → handler invocation → result serialization → transport.send(). Handler throws map to isError: true tool results, not JSON-RPC errors. Unknown tool names produce a MethodNotFound JSON-RPC error. Concurrent tool calls from the same client share a session but are dispatched as independent async operations — no built-in queuing or serialization. Use AliveMCP to monitor the end-to-end dispatch path in production — it probes the actual MCP protocol, not just HTTP liveness.
The dispatch path, step by step
A tool call from an MCP client takes this exact path through the TypeScript SDK:
- Transport receives the raw message. For HTTP transports, the HTTP request body is deserialized from JSON. The transport's internal logic calls
this.onmessage(parsedMessage). - Server validates JSON-RPC structure. The
Serverinstance (set as the transport'sonmessagehandler at connect time) checks that the message is a valid JSON-RPC 2.0 object with the required fields. Invalid structure → JSON-RPC parse error (-32700) sent immediately; your handlers are not involved. - Method name lookup. For requests (not notifications), the server looks up
request.methodin an internal handler registry. For a tool call, the method is"tools/call". If no handler is registered for the method (which shouldn't happen for a properly set up server), it sendsMethodNotFound(-32601). - Request parameter validation. The registered handler for
"tools/call"validatesrequest.paramsagainst theCallToolRequestSchema(Zod schema for the MCP protocol's tool call request). This validates thatparams.nameis a string andparams.argumentsis an object — it does not validate your tool's specific argument shape yet. - Tool name lookup. The
McpServer's"tools/call"handler looks upparams.namein its tool registry (aMap<string, ToolDefinition>). If not found →isError: trueresult with a "Tool not found" message (note: this is a tool result, not a JSON-RPC error). - Tool argument schema validation. Your tool's Zod schema parses
params.arguments. Validation failure →isError: trueresult with Zod's error messages. Your handler is never called. - Handler invocation. Your handler is called with the validated (and coerced) arguments object. The return value must be a
CallToolResult. - Result serialization and send. The result is wrapped in a JSON-RPC response and sent via
transport.send().
Error handling: throw vs isError
Tool handlers have two error-signaling mechanisms. Understanding when to use each is critical for correct LLM behavior:
| Mechanism | How client sees it | LLM behavior | Use when |
|---|---|---|---|
return { isError: true, content: [...] } | Successful JSON-RPC response with isError: true | Reads the error content and can retry with different args or give up gracefully | Expected error conditions: not found, unauthorized, validation failed, rate limited |
throw new Error("...") | Successful JSON-RPC response with isError: true (SDK catches the throw) | Same as above — the SDK maps throws to isError: true | Unexpected internal errors where you don't control the error format |
throw new McpError(code, message) | JSON-RPC error response (not a tool result) | May not handle gracefully — depends on client implementation | Protocol-level errors: MethodNotFound, InvalidParams for structural issues, not domain errors |
The practical rule: domain errors (record not found, rate limit, auth failure, validation failure) → return isError: true. Unexpected internal errors (database connection lost, programming error) → throw, let the SDK map it to isError: true, log the error server-side. Never throw McpError for domain errors — it breaks the LLM's ability to read and act on the error message.
server.tool("get_order", "Get an order by ID", { orderId: z.string().uuid() },
async ({ orderId }) => {
// Domain error: not found — isError: true, LLM can retry or inform user
const order = await db.orders.findById(orderId);
if (!order) {
return {
isError: true,
content: [{ type: "text", text: `Order not found: ${orderId}` }]
};
}
// Domain error: authorization — isError: true
if (order.status === "archived") {
return {
isError: true,
content: [{ type: "text", text: "This order has been archived and is no longer accessible" }]
};
}
// Success path
return { content: [{ type: "text", text: JSON.stringify(order) }] };
// Internal errors (db.findById throws network error, etc.) propagate up
// and are caught by SDK → mapped to isError: true with raw error.message
// Log these server-side via Server.onerror
}
);
Concurrent tool calls and request isolation
LLM clients frequently issue multiple tool calls in parallel. The MCP server receives these as independent JSON-RPC requests over the same transport connection. The SDK dispatches each as a separate async operation — there is no built-in serialization or queuing:
// Client sends three concurrent tool calls — all three dispatch simultaneously
// Your handler runs three times concurrently, as independent async operations
server.tool("fetch_data", "Fetch data from external API", { key: z.string() },
async ({ key }) => {
// This handler may run 3 times concurrently for 3 simultaneous calls
// Handler state must be request-scoped, not module-scoped
const result = await externalApi.get(key); // concurrent I/O is fine
return { content: [{ type: "text", text: result }] };
}
);
Implications:
- Don't use module-level mutable state in handlers. If two concurrent tool calls both read and write a module-level variable, you have a race condition. Handler state must be request-scoped.
- Database connection pools are shared. Concurrent tool calls draw from the same pool. Size the pool for your expected concurrency:
poolSize = expectedConcurrentTools × averageQueriesPerToolCall. - Rate limits apply per tool call, not per session. If an external API has a rate limit, concurrent tool calls can exhaust it faster than expected. Build rate limiting at the handler level if needed.
If you need to serialize tool calls for a specific use case (e.g., a stateful workflow where step 2 must not run until step 1 commits), implement a per-session lock at the application level — a Map<sessionId, Mutex> or a Redis lock keyed on session ID.
Timeout handling in tool handlers
The MCP SDK does not impose a timeout on tool handler execution — if your handler never resolves, the JSON-RPC response is never sent and the client eventually times out on its own. Implement timeouts explicitly:
// AbortSignal-based timeout — cancels the operation cleanly
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
return Promise.race([
promise,
new Promise<T>((_, reject) =>
controller.signal.addEventListener("abort", () =>
reject(new Error(`Tool ${label} timed out after ${ms}ms`))
)
),
]).finally(() => clearTimeout(timer));
}
server.tool("slow_report", "Generate a report (may take up to 30s)", { filters: z.object({}) },
async (args) => {
try {
const result = await withTimeout(
generateReport(args.filters),
25_000, // 25s — leaves buffer before client's own timeout
"slow_report"
);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (err) {
if (err instanceof Error && err.message.includes("timed out")) {
return {
isError: true,
content: [{ type: "text", text: "Report generation timed out. Try a smaller date range." }]
};
}
throw err; // unexpected error — let SDK map to isError
}
}
);
On edge runtimes with CPU time limits (Cloudflare Workers: 10–30ms CPU; Lambda: 15 min wall clock), the runtime itself will kill the handler. Use the async dispatch pattern (start job → return job ID → poll for result) for any operation that might exceed the runtime's limit.
Custom tool registries: dynamic tool sets
The standard McpServer.tool() registration is static — tools are registered once at startup. For dynamic tool sets (plugin systems, per-tenant tool lists, tools that appear/disappear based on feature flags), implement a custom dispatcher using the low-level Server API:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
const server = new Server(
{ name: "dynamic-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// Dynamic tool registry — can be updated after server starts
const dynamicTools = new Map<string, { description: string; handler: Function }>();
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: Array.from(dynamicTools.entries()).map(([name, def]) => ({
name,
description: def.description,
inputSchema: { type: "object", properties: {}, required: [] }
}))
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args = {} } = request.params;
const tool = dynamicTools.get(name);
if (!tool) {
return { isError: true, content: [{ type: "text", text: `Unknown tool: ${name}` }] };
}
return tool.handler(args);
});
// Register tools dynamically — even after server.connect() is called
dynamicTools.set("hello", {
description: "Say hello",
handler: async () => ({ content: [{ type: "text", text: "Hello!" }] })
});
When using dynamic tool sets, send a notifications/tools/listChanged notification to the client whenever the tool list changes — this tells the client to re-fetch tools/list. The notification requires the server's capabilities to include tools: { listChanged: true } in the initialize response.
Monitoring the dispatch path in production
Tool dispatch failures manifest in several ways in production, and different monitoring approaches catch different failure modes:
| Failure mode | What you see | Caught by |
|---|---|---|
| Transport down (server crashed, TLS expired) | No HTTP response | AliveMCP protocol probe |
| Server starts but initialize fails | HTTP 200 with JSON-RPC error on initialize | AliveMCP protocol probe |
| Tool not registered (registration bug, wrong name) | isError: true "Tool not found" | AliveMCP tools/list hash check |
| Schema validation rejects all args | isError: true with Zod errors | Tool-level error rate logging |
| Handler timeout / hung | Client receives no response until its own timeout | AliveMCP response time alert + p95 threshold |
| Database errors in handler | isError: true with error message | Structured logging + error rate alert |
AliveMCP covers the first three rows — the protocol-level failures that don't show up in application logs because the request never reaches your handler. Set up structured logging in your handlers for the last three rows, and correlate by session ID for debugging.
Frequently asked questions
What happens if my tool handler returns undefined instead of a CallToolResult?
The SDK expects handlers to return a CallToolResult object with a content array. Returning undefined or null causes the SDK to throw when it tries to serialize the result, which is caught and mapped to an isError: true response. TypeScript catches this at compile time if your handler is typed correctly — the return type should be Promise<CallToolResult>, which requires a content field. Always return an explicit result from all code paths.
Can I call one tool handler from another tool handler?
Yes — tool handlers are just async functions. You can call one directly: const result = await lookupUserHandler({ id: userId }). Extract shared logic into a plain async function (not a tool handler) and call that from multiple handlers. Don't call client.callTool() from a server handler — that would require a client connection from within the server, which is an anti-pattern and creates a dependency cycle. Share logic at the function level, not the tool level.
How do I add per-request context (auth, trace ID) to tool handlers?
The MCP SDK's request context is limited — it includes client info from the initialize handshake but not per-request headers. For auth context injected via HTTP headers (e.g., a JWT in Authorization), extract it at the transport layer (in your Express/Hono middleware) and pass it to the tool via a thread-local-style mechanism: an AsyncLocalStorage context that stores request-scoped data. Tool handlers read from requestContext.getStore(). This pattern is documented in more detail in the middleware guide.
Does the SDK support request prioritization for tool calls?
No — the SDK dispatches tool calls in the order the transport delivers them, with no built-in priority queue. All concurrent tool calls are dispatched immediately as they arrive. If you need prioritization (e.g., interactive tool calls should preempt background tool calls), implement it at the handler level with an application-level priority queue that delays low-priority work when high-priority work is in flight.
What's the maximum response size for a tool result?
The SDK itself has no size limit for CallToolResult.content. Practical limits come from: the transport (HTTP transports have request/response size limits configurable in your HTTP server); the client (LLM context windows limit how much content is useful — content beyond ~100K tokens is truncated or ignored); and memory (very large responses allocate memory before serialization). For large data sets, return a summary and a reference (pagination cursor, pre-signed URL) rather than the full payload.
Further reading
- MCP TypeScript SDK internals — Server class, transport layer, and InMemoryTransport testing
- MCP server error handling — isError vs exceptions, error codes, and recovery patterns
- MCP server middleware — auth, logging, and error normalization wrappers
- MCP server concurrency — concurrent tool calls, shared state, and connection pools
- MCP server timeout patterns — AbortSignal, async dispatch, and edge runtime limits
- MCP server long-running tasks — async dispatch and job polling patterns
- AliveMCP — protocol monitoring for MCP servers