Defining Tools: Schemas, Validation, and Responses
- Design precise tool input schemas using Zod (TypeScript) and Annotated/Field (Python)
- Return structured error responses using isError: true instead of throwing exceptions for expected failures
- Apply tool annotations to declare side-effect behavior to clients
Tool Design Is Communication Design
A tool's input schema and description are not documentation for human readers — they are instructions for the AI model. Every field name, type constraint, and description string is text that gets included in the model's context when it decides whether and how to call your tool. Vague schemas lead to incorrect arguments. Missing descriptions lead to the model skipping your tool entirely when it should use it.
The goal of this lesson is to move beyond the minimal schema patterns from Lessons 06 and 07 and write tool definitions that are precise, self-documenting, and robust.
Schema Design Principles
Before looking at code, here are the principles that guide good tool schema design:
- Be specific about types. Use
numberinstead ofstringfor numeric inputs. Use enums when only a fixed set of values is valid. - Mark optional fields explicitly. Required fields that are actually optional cause unnecessary model failures when it can't determine the value.
- Add descriptions to every non-obvious field. The model cannot infer from the name alone that
statemeans a two-letter US state code. - Write descriptions as instructions, not labels. "Search keyword" is a label. "The keyword to search for — use plain English, not operator syntax" is an instruction.
- Set sensible defaults for optional fields. Defaults appear in the schema and tell the model what it gets if it omits the parameter.
TypeScript: Zod Schema Patterns
The TypeScript MCP SDK uses Zod for input schema definition. Here is a realistic example — a product search tool with multiple optional filters:
server.registerTool(
"search_products",
{
description: "Search the product catalog by keyword and optional filters. Use this tool when the user asks to find, browse, or look up products.",
inputSchema: {
query: z.string().min(1).describe("Search keyword — plain English, not operator syntax"),
category: z
.enum(["electronics", "books", "clothing"])
.optional()
.describe("Filter by category. Omit to search all categories."),
max_results: z
.number()
.int()
.min(1)
.max(50)
.default(10)
.describe("Maximum number of results to return (1–50). Default is 10."),
},
annotations: {
readOnlyHint: true,
},
},
async ({ query, category, max_results }) => {
const results = await db.search(query, category, max_results);
if (results.length === 0) {
return {
content: [{ type: "text", text: "No products found matching your search." }],
};
}
return {
content: [{
type: "text",
text: results.map(r => `${r.name}: $${r.price}`).join("
"),
}],
};
}
);
Key Zod methods to know:
.describe("...")— adds the field description to the JSON Schema; always use this.optional()— makes the field non-required in the schema.default(value)— provides a default and marks the field optionalz.enum([...])— restricts the value to a specific set; becomes a JSON Schemaenum.min(n)/.max(n)— adds validation constraints visible to the model
Python FastMCP: Annotated and Field
Python's basic type hints work well for simple parameters, but when you need field-level descriptions and constraints, use Annotated with Pydantic's Field:
from typing import Annotated
from pydantic import Field
@mcp.tool()
async def search_products(
query: Annotated[str, Field(description="Search keyword — plain English, not operator syntax", min_length=1)],
category: Annotated[str | None, Field(description="Filter by category. Omit to search all.")] = None,
max_results: Annotated[int, Field(description="Max results (1–50). Default 10.", ge=1, le=50)] = 10,
) -> str:
"""Search the product catalog. Use when the user wants to find or browse products."""
results = await db.search(query, category, max_results)
if not results:
return "No products found matching your search."
return "
".join(f"{r.name}: ${r.price}" for r in results)
The Annotated[type, Field(...)] pattern adds the same metadata as Zod's .describe() and constraint methods. FastMCP reads the Field metadata and includes it in the JSON Schema it sends to the client.
Default values go after the type annotation as normal Python defaults (= None, = 10). FastMCP treats parameters with defaults as optional.
Input Validation: What the SDK Handles For You
MCP clients validate inputs against your schema before calling your handler. If the model passes a string where you declared a number, or omits a required field, the client rejects the call and the model receives an error — your handler is never invoked.
Inside your handler, the arguments have already been coerced to the declared types. A float parameter in Python arrives as a Python float. A z.number().int() field in TypeScript arrives as a JavaScript number. You do not need to parse or cast them yourself.
What the SDK does not handle is business logic validation — checking that a user ID exists in your database, or that a date range is valid. Those checks belong in your handler.
Returning Errors: isError vs Throwing
There are two ways a tool can signal failure. Understanding which to use is important:
Expected failures — use isError: true. When a tool fails for a predictable reason ("user not found", "rate limit exceeded", "no results"), return a normal response with isError: true:
// TypeScript
return {
content: [{ type: "text", text: "Error: user ID 42 does not exist." }],
isError: true,
};
# Python FastMCP — raise McpError for expected failures
from mcp.server.fastmcp import McpError
from mcp.types import ErrorCode
raise McpError(ErrorCode.InvalidRequest, "User ID 42 does not exist.")
With isError: true, the model receives the error message and can respond sensibly — it might ask the user to try a different ID, or try another tool. The server stays running.
Unexpected failures — throw an exception. For truly unexpected errors (a database connection dropped, an API returned malformed JSON), let the exception propagate. The SDK catches it, wraps it in an error response, and keeps the server alive. The model will see a generic error and typically retry or inform the user.
The key rule: use isError: true for cases you anticipated; throw for cases you did not.
Tool Annotations
The MCP spec (updated 2025) supports tool annotations — hints that describe the side-effect behavior of a tool. Clients can use these to show warnings, require confirmation, or suppress tools from certain contexts:
readOnlyHint: true— the tool only reads data, it does not modify anythingdestructiveHint: true— the tool may delete or permanently modify data; clients may prompt for confirmationidempotentHint: true— calling the tool multiple times with the same inputs is safeopenWorldHint: false— the tool only accesses the resources declared in its schema, nothing else
In TypeScript, annotations go in the tool definition object:
annotations: {
readOnlyHint: true,
idempotentHint: true,
}
In Python FastMCP, use the annotations parameter on the decorator:
from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
async def search_products(...) -> str:
...
Annotations are advisory — they are hints, not enforced constraints. But they matter: tools marked destructiveHint: true may prompt the user for confirmation in supported clients, which is the right UX for anything that deletes data.
Response Content Types
A tool response is a list of content items. Each item has a type field that tells the client how to render it:
{ "type": "text", "text": "..." }— plain text, markdown supported{ "type": "image", "data": "<base64>", "mimeType": "image/png" }— inline image{ "type": "resource", "resource": { "uri": "...", "mimeType": "...", "text": "..." } }— embedded resource
You can include multiple items in one response. A tool that runs a chart query might return a text summary followed by a PNG image:
return {
content: [
{ type: "text", text: `Found ${results.length} records. Chart attached.` },
{ type: "image", data: chartBase64, mimeType: "image/png" },
],
};
The model can reference both the text and the image in its reply. This is especially useful for data visualization tools where a chart communicates more than a table of numbers.
What to Try Next
- Add a
z.enum()field to one of your existing tools. Restart Claude Desktop and observe how the model's behavior changes when calling that tool — does it reliably pass valid enum values? - Return
isError: truefrom yourget_alertstool when the NWS API returns no data, instead of the plain string "Failed to retrieve alerts data". Verify the model handles it differently. - Add
readOnlyHint: trueto both weather tools anddestructiveHint: trueto a hypothetical delete tool. Check whether your MCP client displays any visual difference.
- Tool descriptions are read by the AI model, not humans — write them as instructions: 'Use this tool when you need to...'
- The isError: true flag in the response content tells the model the tool failed without crashing the server; use it for expected failure cases like 'not found' or invalid input
- Zod's .describe() method annotates each input field for the AI model — always add descriptions to non-obvious parameters
- Tool annotations (readOnlyHint, destructiveHint, idempotentHint) let clients show warnings or add safety prompts before calling destructive tools
- A tool response can contain multiple content items — return text plus an image, or multiple text blocks, by putting them all in the content array