Introduction
Tool handlers are where your server's logic lives. They receive validated input, perform operations, and return results. Understanding the handler signature, context object, content types, and error handling patterns is essential for building robust MCP tools.
Key Concepts
- Handler signature: The async function receives the parsed input as its first argument and a context object as its second.
- Context object: Provides access to
ctx.mcpReq.log()for sending log messages back to the client. - Content types: Tool results can include
text,image(base64-encoded), andresourcelink items. - Error handling: Return
isError: truefor expected errors; throw exceptions for unexpected failures.
Real World Context
A production MCP server for a monitoring system might have a tool that fetches metrics from an API. The handler needs to log progress, handle API timeouts gracefully, return structured data on success, and signal clear errors when the API is unreachable. These patterns apply to any tool that interacts with external systems.
Deep Dive
The handler function receives two arguments. The first is the validated input object, destructured to match your Zod schema fields. The second is an extra object containing ctx (the request context) and other utilities.
Here is a tool that fetches data from an external API and uses the context for logging:
typescriptimport { z } from 'zod'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; server.registerTool('fetch-data', { description: 'Fetch data from an API', inputSchema: z.object({ url: z.string() }) }, async ({ url }, { ctx }): Promise<CallToolResult> => { await ctx.mcpReq.log('info', `Fetching ${url}`); const res = await fetch(url); return { content: [{ type: 'text', text: await res.text() }] }; });
The ctx.mcpReq.log() method sends log messages to the client during execution. This is useful for long-running tools where you want to provide progress updates.
Tool results support multiple content types. The most common is text, but you can also return images and resource references.
Here is an example returning an image alongside text:
typescriptserver.registerTool('generate-chart', { description: 'Generate a chart image', inputSchema: z.object({ data: z.array(z.number()) }) }, async ({ data }): Promise<CallToolResult> => { const imageBuffer = await renderChart(data); const base64 = imageBuffer.toString('base64'); return { content: [ { type: 'text', text: `Chart with ${data.length} data points` }, { type: 'image', data: base64, mimeType: 'image/png' } ] }; });
The image content type requires a data field with the base64-encoded image and a mimeType field. You can mix multiple content items of different types in a single response.
For error handling, the MCP protocol distinguishes between expected errors (like "user not found") and unexpected failures (like database connection lost). For expected errors, return a result with isError: true.
Here is the pattern for graceful error handling:
typescriptserver.registerTool('lookup-user', { description: 'Look up a user by ID', inputSchema: z.object({ userId: z.string() }) }, async ({ userId }): Promise<CallToolResult> => { const user = await db.findUser(userId); if (!user) { return { content: [{ type: 'text', text: `User ${userId} not found` }], isError: true }; } return { content: [{ type: 'text', text: JSON.stringify(user, null, 2) }] }; });
When isError is true, the client knows the operation failed but in an expected way. The LLM can read the error message and decide how to proceed. For truly unexpected errors, let the exception propagate and the SDK will handle it.
Common Pitfalls
- Swallowing all errors: Do not catch every exception and return
isError: true. Let unexpected errors throw so the SDK can report them properly with stack traces. - Returning non-string text: The
textfield in content items must be a string. If you are returning JSON, stringify it first. - Forgetting mimeType for images: Image content items require a
mimeTypefield or the client cannot render them.
Best Practices
- Use
ctx.mcpReq.log()to provide progress updates for operations that take more than a second. - Return
isError: truefor business logic errors (not found, validation failed, permission denied) and let infrastructure errors throw. - When returning JSON data as text, use
JSON.stringify(data, null, 2)for readable formatting. - Combine text and image content items when a visual result benefits from a text description.
Summary
You learned how tool handlers receive parsed input and context, how to use logging for progress updates, the three content types (text, image, resource), and the error handling pattern of isError: true for expected failures versus thrown exceptions for unexpected ones.
Code Examples
server.registerTool('fetch-data', {
description: 'Fetch data from an API',
inputSchema: z.object({ url: z.string() })
}, async ({ url }, { ctx }): Promise<CallToolResult> => {
await ctx.mcpReq.log('info', `Fetching ${url}`);
const res = await fetch(url);
return { content: [{ type: 'text', text: await res.text() }] };
});if (!user) {
return {
content: [{ type: 'text', text: `User ${userId} not found` }],
isError: true
};
}