What Tools Really Are
In the previous module, you registered a simple tool. Now let us get precise about what tools are in the MCP protocol and why they are designed the way they are.
Tools are model-controlled functions. This is the single most important thing to understand. The AI model — not the user, not the application — decides when and how to call a tool. The user says “What is the weather in Tokyo?” and the model, reading the available tool descriptions, autonomously decides to call a weather tool with { city: "Tokyo" }.
This has a profound design implication: your tool must be describable enough that an AI can use it correctly without any prior training on it. The model sees three things:
What the AI model sees for each tool:
1. Name: "get_weather"
2. Description: "Get the current weather for a city. Returns temperature,
conditions, and humidity. Use ISO country codes for
disambiguation (e.g., 'Paris, FR' vs 'Paris, US')."
3. Input Schema: {
"city": { "type": "string", "description": "City name, optionally with country code" }
}
That is ALL the model knows. No source code. No documentation links.
No examples of previous calls. Just name + description + schema.This means tool design is fundamentally a communication problem. You are writing an interface for an AI reader, not a human developer reading API docs.
Input Schemas with Zod
The MCP TypeScript SDK uses Zod for input validation. When you define a tool schema, you are writing a Zod object schema that the SDK converts to JSON Schema for the protocol and uses for runtime validation.
import { z } from "zod";
// Simple schema — one required string
{
city: z.string().describe("City name, e.g., 'London' or 'Tokyo, JP'"),
}
// Multiple fields with different types
{
query: z.string().describe("Search query text"),
limit: z.number().min(1).max(100).describe("Maximum results to return"),
includeArchived: z.boolean().describe("Whether to include archived items"),
}
// Optional fields — use .optional()
{
city: z.string().describe("City name"),
units: z.enum(["celsius", "fahrenheit"])
.optional()
.describe("Temperature unit, defaults to celsius"),
}
// Nested objects
{
filter: z.object({
status: z.enum(["open", "closed", "all"]).describe("Issue state"),
label: z.string().optional().describe("Filter by label name"),
}).describe("Search filter criteria"),
}Every Zod type maps to a JSON Schema type. Here is the translation table you will use most often:
Zod Type → JSON Schema Type → Example
─────────────────────────────────────────────────────
z.string() → "string" → "hello"
z.number() → "number" → 42, 3.14
z.boolean() → "boolean" → true
z.enum([...]) → "string" + enum → "celsius"
z.array(z.string()) → "array" of strings → ["a", "b"]
z.object({...}) → nested "object" → { key: "val" }
z.optional() → removes "required" → field can be omittedThe .describe() call on each field is not optional in practice. Without it, the AI model sees a parameter name and type but has no idea what value to provide. A field named q of type string tells the model nothing. A field named q with description “SQL query to execute against the production database” tells it everything.
Validation in Depth
Zod does not just define types — it enforces constraints. The SDK runs validation before your handler is called. If validation fails, the SDK returns an error response automatically. Your handler never sees invalid data.
// Constrained types — the SDK rejects anything that does not match
{
email: z.string().email().describe("Valid email address"),
age: z.number().int().min(0).max(150).describe("Age in years"),
url: z.string().url().describe("Full URL including protocol"),
name: z.string().min(1).max(100).describe("Non-empty name, max 100 chars"),
}
// What happens with invalid input:
// Client sends: { "email": "not-an-email", "age": -5 }
// SDK responds with error BEFORE your handler runs:
// {
// "error": {
// "code": -32602, // Invalid params (JSON-RPC standard code)
// "message": "Invalid email at 'email'; Number must be >= 0 at 'age'"
// }
// }This is a significant safety feature. Your handler can trust that all input has been validated. You do not need defensive checks for type mismatches or missing required fields. But you should still validate business logic — Zod handles structure, your code handles semantics.
Return Types
Tools return content in a structured format. The content array can contain three types of blocks:
// 1. TextContent — the most common return type
{
content: [
{
type: "text",
text: "The temperature in Tokyo is 22°C with clear skies.",
},
],
}
// 2. ImageContent — base64-encoded image data
{
content: [
{
type: "image",
data: "iVBORw0KGgo...", // Base64-encoded image
mimeType: "image/png", // MUST specify the MIME type
},
],
}
// 3. EmbeddedResource — reference to an MCP resource
{
content: [
{
type: "resource",
resource: {
uri: "file:///path/to/report.pdf",
mimeType: "application/pdf",
// For text resources, include "text" field
// For binary resources, include base64 "blob" field
},
},
],
}
// You can mix types in a single response:
{
content: [
{ type: "text", text: "Here is the chart and raw data:" },
{ type: "image", data: chartBase64, mimeType: "image/png" },
{ type: "resource", resource: { uri: "data://report.csv", text: csvData, mimeType: "text/csv" } },
],
}In practice, you will use TextContent for 90% of tools. Use ImageContent when your tool generates charts, screenshots, or diagrams. Use EmbeddedResource when you want to reference a resource that the server also exposes through the resources capability.
Error Handling
Tools will fail. APIs time out, files do not exist, queries return no results. MCP gives you two mechanisms for communicating errors:
The isError Flag
The simplest approach: return a normal response but set isError: true. The AI model sees this flag and knows the tool call failed, so it can decide what to do next (retry, try a different approach, or tell the user).
server.tool(
"get_weather",
"Get current weather for a city",
{ city: z.string().describe("City name") },
async ({ city }) => {
try {
const weather = await fetchWeatherApi(city);
return {
content: [{ type: "text" as const, text: formatWeather(weather) }],
};
} catch (error) {
// Return an error result — the model sees isError: true
// and knows this tool call did not succeed
return {
isError: true,
content: [
{
type: "text" as const,
text: `Failed to get weather for "${city}": ${error instanceof Error ? error.message : "Unknown error"}`,
},
],
};
}
}
);The difference between isError: true and throwing an exception is important:
isError: true (RECOMMENDED for most cases)
→ The model sees a structured error message
→ The model can reason about the error and recover
→ The connection stays healthy
→ Use for: API errors, not found, invalid business logic
Throwing an exception (USE SPARINGLY)
→ The SDK catches it and returns a JSON-RPC error
→ Less information reaches the model
→ Use for: truly unexpected failures, programming bugsMcpError for Protocol-Level Errors
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
// Use McpError for protocol-level issues
throw new McpError(
ErrorCode.InvalidParams,
"The 'city' parameter cannot be empty"
);
// Common error codes:
// ErrorCode.InvalidParams — bad input that passed schema validation
// ErrorCode.InternalError — unexpected server failure
// ErrorCode.MethodNotFound — requested a tool that does not existTool Annotations
Tool annotations are metadata hints that help clients understand a tool's behavior without executing it. They do not change how the tool works — they inform the client about what the tool does.
// Annotations are an optional object in the tool definition
server.tool(
"delete_file",
"Permanently delete a file from the filesystem",
{ path: z.string().describe("Absolute path to the file to delete") },
// The tool handler
async ({ path }) => { /* ... */ },
// --- This extra object contains annotations ---
// (Note: exact API may vary — check SDK version)
);
// The five standard annotations:
// readOnlyHint (boolean, default false)
// "true" = this tool only reads data, never modifies anything
// Helps clients decide if user confirmation is needed
// Example: a search tool, a lookup tool
// destructiveHint (boolean, default true)
// "true" = this tool may cause irreversible changes
// Clients may show a confirmation dialog
// Example: delete file, drop table, send email
// idempotentHint (boolean, default false)
// "true" = calling this tool twice with the same input has the same effect
// Clients may auto-retry on failure
// Example: set a config value, upsert a record
// openWorldHint (boolean, default true)
// "true" = this tool interacts with external systems
// "false" = this tool only works within a closed environment
// Example: API calls (true), in-memory calculations (false)
// title (string)
// Human-readable title for display in UIs
// Example: "Delete File" instead of "delete_file"A well-annotated tool set helps clients build better UIs. Imagine a client that auto-approves read-only tools but requires confirmation for destructive ones. Annotations make that possible.
Annotation Decision Matrix:
Tool readOnly destructive idempotent openWorld
──────────────────────────────────────────────────────────────────
search_files true false true false
read_database true false true true
send_email false true false true
update_config false false true false
delete_record false true false true
calculate_sum true false true falseProgress Notifications
Some tools take a long time: processing large files, running complex queries, calling slow APIs. MCP supports progress notifications so clients can show feedback to the user.
server.tool(
"process_dataset",
"Process all records in a dataset",
{ datasetId: z.string().describe("Dataset identifier") },
async ({ datasetId }, { sendNotification, progressToken }) => {
const records = await loadDataset(datasetId);
const total = records.length;
for (let i = 0; i < total; i++) {
await processRecord(records[i]);
// Send a progress notification
// The client can display this as a progress bar
if (progressToken !== undefined) {
await sendNotification({
method: "notifications/progress",
params: {
progressToken,
progress: i + 1, // Current progress
total, // Total expected
// Optional: message for display
},
});
}
}
return {
content: [{
type: "text" as const,
text: `Processed ${total} records in dataset ${datasetId}.`,
}],
};
}
);The progressToken is provided by the client in the original tool call request. If the client does not include one, it does not want progress updates, and you should skip sending them. Always check that the token exists before sending notifications.
Complete Example: Weather Tool
Let us put everything together in a well-designed weather tool. Read through the code and notice how each concept from this module is applied:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({
name: "weather-server",
version: "1.0.0",
});
// Define the input schema separately for clarity
const weatherInputSchema = {
city: z.string()
.min(1)
.describe("City name. Include country code for disambiguation: 'Paris, FR'"),
units: z.enum(["celsius", "fahrenheit"])
.optional()
.describe("Temperature unit. Defaults to celsius if not specified."),
};
server.tool(
"get_weather",
// Description: specific, includes what it returns and edge cases
"Get the current weather conditions for a city. Returns temperature, " +
"humidity, wind speed, and a text description of conditions. " +
"If the city is ambiguous (e.g., 'Portland'), include the country " +
"or state code. Returns an error for unrecognized cities.",
weatherInputSchema,
async ({ city, units }) => {
// Default units
const tempUnit = units ?? "celsius";
try {
// --- Real implementation would call an API here ---
// This is educational — showing the pattern, not a real API call
const response = await fetch(
`https://api.weather.example.com/current?` +
`city=${encodeURIComponent(city)}&units=${tempUnit}`
);
// Handle HTTP errors as tool errors (not exceptions)
if (!response.ok) {
if (response.status === 404) {
return {
isError: true,
content: [{
type: "text" as const,
text: `City "${city}" not found. Try including a country code (e.g., "${city}, US").`,
}],
};
}
return {
isError: true,
content: [{
type: "text" as const,
text: `Weather service error (HTTP ${response.status}). Please try again.`,
}],
};
}
const data = await response.json();
// Format the result as clear, readable text
// The AI model will incorporate this into its response
const result = [
`Weather in ${data.name}, ${data.country}:`,
` Temperature: ${data.temp}°${tempUnit === "celsius" ? "C" : "F"}`,
` Conditions: ${data.description}`,
` Humidity: ${data.humidity}%`,
` Wind: ${data.windSpeed} km/h ${data.windDirection}`,
].join("\n");
return {
content: [{ type: "text" as const, text: result }],
};
} catch (error) {
// Network errors, timeouts, etc.
return {
isError: true,
content: [{
type: "text" as const,
text: `Unable to reach weather service: ${
error instanceof Error ? error.message : "Network error"
}. Check your internet connection.`,
}],
};
}
}
);Key design decisions in this example:
- Descriptive error messages that help the AI model recover (“Try including a country code”)
- isError flag instead of throwing exceptions for expected failures
- Formatted text output with clear labels the model can parse and present to the user
- Input encoding with
encodeURIComponentto handle city names with spaces or special characters - Separate error handling for HTTP errors vs network errors, with different messages for each
Try It Yourself
Design a tool schema (you do not need to implement the full logic) for each of these scenarios. Focus on the name, description, and Zod schema:
- A file search tool that searches a directory for files matching a pattern. Think about what parameters it needs: directory path, search pattern, case sensitivity, file type filter.
- A database query tool that runs a read-only SQL query. What constraints would you put on the input? How would you handle results that are too large?
- An email sending tool that sends an email. What annotations would you set? What validation beyond Zod types would you need?
For each tool, write out: (a) the tool name, (b) a description the AI model would use, (c) the full Zod schema with .describe() on every field, and (d) what annotations you would set.
Key Takeaway
Tools are model-controlled functions defined by name, description, and schema. The description quality determines whether the AI model uses your tool correctly. Use Zod for input validation,
isErrorfor recoverable failures, annotations to describe behavior, and progress notifications for long-running operations. Design tools for an AI audience, not a human developer audience.
Common Mistakes
- Generic tool descriptions. “Gets data” is useless. “Fetch the current stock price for a US-listed company by ticker symbol. Returns price, change, and volume. Use the standard ticker (e.g., AAPL, MSFT).” is good.
- Missing
.describe()on schema fields. The AI model sees parameter names and descriptions. Without descriptions, a field calledqis meaningless. Always describe every field. - Throwing exceptions for expected errors. If an API returns 404, that is an expected case. Use
isError: truewith a helpful message. Throw exceptions only for truly unexpected failures. - Returning raw JSON as text. The AI model has to parse your output. A formatted, labeled text response is much easier for the model to understand and present to the user than a raw JSON dump.
- Ignoring the progressToken check. Sending progress notifications when the client did not provide a token will cause errors. Always check
progressToken !== undefinedfirst. - Making tools too broad. A tool called
manage_everythingwith 15 parameters is hard for the model to use correctly. Break it into focused tools:create_item,update_item,delete_item.