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:

  1. 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).
  2. Server validates JSON-RPC structure. The Server instance (set as the transport's onmessage handler 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.
  3. Method name lookup. For requests (not notifications), the server looks up request.method in 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 sends MethodNotFound (-32601).
  4. Request parameter validation. The registered handler for "tools/call" validates request.params against the CallToolRequestSchema (Zod schema for the MCP protocol's tool call request). This validates that params.name is a string and params.arguments is an object — it does not validate your tool's specific argument shape yet.
  5. Tool name lookup. The McpServer's "tools/call" handler looks up params.name in its tool registry (a Map<string, ToolDefinition>). If not found → isError: true result with a "Tool not found" message (note: this is a tool result, not a JSON-RPC error).
  6. Tool argument schema validation. Your tool's Zod schema parses params.arguments. Validation failure → isError: true result with Zod's error messages. Your handler is never called.
  7. Handler invocation. Your handler is called with the validated (and coerced) arguments object. The return value must be a CallToolResult.
  8. 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:

MechanismHow client sees itLLM behaviorUse when
return { isError: true, content: [...] }Successful JSON-RPC response with isError: trueReads the error content and can retry with different args or give up gracefullyExpected 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: trueUnexpected 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 implementationProtocol-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:

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 modeWhat you seeCaught by
Transport down (server crashed, TLS expired)No HTTP responseAliveMCP protocol probe
Server starts but initialize failsHTTP 200 with JSON-RPC error on initializeAliveMCP protocol probe
Tool not registered (registration bug, wrong name)isError: true "Tool not found"AliveMCP tools/list hash check
Schema validation rejects all argsisError: true with Zod errorsTool-level error rate logging
Handler timeout / hungClient receives no response until its own timeoutAliveMCP response time alert + p95 threshold
Database errors in handlerisError: true with error messageStructured 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

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free