How to write tool descriptions for AI agents A developer published a detailed guide on writing tool descriptions for AI agents in MCP servers, arguing that the name, description and input schema are the only things a model sees when selecting and calling a tool. The writeup draws on Anthropic's tool-use documentation, which calls detailed descriptions "by far the most important factor in tool performance" and recommends at least three to four sentences per tool, and covers how MCP separates protocol errors from execution errors so agents can self-correct. It illustrates the guidance with a made-up bike rental server and proposes checking tool descriptions automatically before an agent encounters them. Tool descriptions for AI agents are the part of an MCP server that most people knock out in ten seconds. One sentence, done, on to the interesting code. Yet that sentence decides whether the agent finds your tool at all, whether it calls it with sensible parameters and whether it recovers after an error or gives up. In my article Building an MCP server with TypeScript https://sascha-ms.de/en/mcp-server-typescript?utm source=devto&utm medium=crosspost I only touched on the topic briefly. Here I go deep on tool descriptions for AI agents: What does the model actually see, which rules hold up, and how do you check tool descriptions automatically before an agent trips over them? Our example is a small, made-up bike rental. In short An AI agent picks tools solely from their name, description and input schema; it never sees the code behind them. Good descriptions therefore state what the tool does, when it fits and where its limits are, and error messages point the way to a fix. The most common pitfall is tools that overlap or silently assume a certain order. What you will learn in this article At its core, an AI agent is a language model running in a loop: it reads the task, picks a tool, gets a result back and decides again. For example, tools can be functions in your own application or tools offered by an MCP server. MCP, the Model Context Protocol, is the open standard that hosts such as Claude Code, IDEs or custom agents use to plug in tools like these. The crucial point: the model does not see your code. All it sees is what the server returns for tools/list . In its tool use documentation, Anthropic calls detailed descriptions “by far the most important factor in tool performance” and recommends at least three to four sentences per tool. On its engineering blog, the team also reports that even small changes to descriptions led to measurably better benchmark results. This also makes sense once you think about what a description really is. It is part of the prompt and not a comment for humans. In addition, it competes with every other tool for the model’s attention. As a result, if it is vague, the model guesses. And guessing is a bad plan for a tool that books, deletes or costs money. According to the MCP specification currently the version dated 2026-07-28 , a tool definition has only a handful of fields: name as a unique identifier, title as a display name for humans, description , inputSchema as a JSON Schema for the parameters, an optional outputSchema for structured results and annotations with hints about the tool’s behavior. Of these, the model mainly sees the name, the description and the input schema, including the description of every single parameter. Name, description and schema are seen by the model, the title by humans, annotations are only hints. After that, the flow is always the same. The host fetches the tool list, the model picks a tool and fills in the parameters, the host calls the tool and passes the result back to the model. When something goes wrong, MCP distinguishes two kinds of errors. First, protocol errors such as an unknown tool come back as JSON-RPC errors. Second, execution errors, for instance invalid input or a broken business rule, come back as a regular result with isError: true . The specification explicitly says clients should pass this second kind on to the model so that it can correct itself. 🎬 Flow of a tool call with self-correction – this animated diagram only runs on my site: watch it on sascha-ms.de https://sascha-ms.de/en/tool-descriptions-ai-agents?utm source=devto&utm medium=crosspost Our made-up bike rental has two stations, “ostbahnhof” and “hafen”, with city bikes, e-bikes and cargo bikes. An agent is supposed to find and book available bikes for users. This is what a tool often looks like in its first version: // Weak version: the model has to guess server.registerTool 'book', { description: 'Books a bike.', inputSchema: z.object { id: z.string , start: z.string , end: z.string } , }, async { id, start, end } = { // ... }, ; The model has no idea where id comes from. So, when in doubt, it invents one, say “e-bike 2”. It does not know which format start and end expect and might send “next Monday”. Nor does it know whether it has to ask first, whether there is a maximum duration or what happens on a second call. On top of that, the name “book” is so generic that it collides immediately with a calendar server or an accounting tool. The better version answers all of these questions right in the definition. You will find the complete server further down in the step-by-step guide; here is just the configuration of the booking tool: // Excerpt from src/server.ts: the booking tool server.registerTool 'rental book bike', { title: 'Book rental bike', description: 'Books an available rental bike for a period of up to 14 days. ' + 'Use it only after the user has explicitly confirmed the bike and the period. ' + 'The bikeId always comes from rental search bikes, never make one up. ' + 'The same requestKey returns the same booking, so a retry never books twice.', inputSchema: z.object { bikeId: z.string .describe 'ID from rental search bikes, e.g. "bike-hafen-01"' , from: isoDate.describe 'First rental day, YYYY-MM-DD' , to: isoDate.describe 'Last rental day, YYYY-MM-DD, same as or after "from"' , requestKey: z.string .min 8 .describe 'Unique key per booking request, e.g. a UUID' , } , annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, async { bikeId, from, to, requestKey } = { // ... see step 3 }, ; Four sentences are enough here. First, it says what the tool does, including a limit. Second, it says when the tool fits. Third, it prevents the most common misuse, and finally it explains how retries behave. For humans, there is also the title , which hosts show in their user interface. Some of the following rules come from Anthropic’s recommendations and the MCP specification; others I derive from them and add what seems important to me in examples like this one. None of them is complicated. Together, though, they make the difference between an agent that works reliably and one you constantly have to correct. A name like rental book bike reveals the area, the action and the object. Above all, the prefix helps when a host loads tools from several servers at once. The MCP specification points out explicitly that two servers may well both offer a tool called search . According to the specification, names may have 1 to 128 characters made of letters, digits, underscore, hyphen and dot. The Claude API is a little stricter and does not allow the dot. Therefore, snake case keeps you safe on both sides. A good description answers four questions: What does the tool do? When should the model use it? When not? What does it not return, or where does it stop? In other words, write as if you were onboarding a new colleague who has never seen the system. Spell out domain terms, internal abbreviations and the relationships between tools instead of taking them for granted. With Zod, you attach it directly to the field using .describe . As a result, the text ends up in the JSON Schema the model sees. An example such as “e.g. "hafen"” often does more than a whole paragraph of explanation. Also name parameters unambiguously: bikeId rather than id , and from and to with a clear meaning rather than start and end without context. Whatever can be checked by a machine, you check in the schema. z.iso.date turns a date into a string with format: "date" and a matching pattern, z.enum turns a bike type into a fixed set of options, and min and max turn a result count into a range. Consequently, the model sees these constraints, and the SDK rejects invalid input before your code runs. The SDK’s error message is generic, however, for example “Invalid ISO date”. That is why the expected format should also appear as an example in the parameter description. It is tempting to offer every API route as its own tool. Anthropic advises against this and recommends bundling several steps of a typical workflow into one tool. Overlap is the real problem: if there are list bikes , find bikes and search available bikes , the model has to guess how they differ. For this reason, a search tool with filters almost always beats a tool that lists everything. Every result lands in the model’s context window and costs tokens. So return only what the next step needs and set sensible defaults for limit . A parameter like responseFormat with “concise” and “detailed” lets the model decide how much it needs. In Anthropic’s example, the concise variant needed only about a third of the tokens. It also helps to return readable identifiers like bike-hafen-01 instead of internal database IDs. “Error 400” helps nobody, least of all a model. A good error message says what was wrong, what would be valid and what to do next. For an unknown station, our tool therefore lists the valid stations and asks the model to check with the user instead of guessing. You return errors like these as a result with isError: true , not as an exception, so that the host passes them on to the model. With readOnlyHint , destructiveHint , idempotentHint and openWorldHint you describe whether a tool only reads, can destroy data, returns the same result when repeated and talks to the outside world. Hosts use this, for example, to ask for confirmation before write operations. The specification makes clear, however, that clients must treat annotations as untrusted unless the server itself is trusted. They are hints, not a security boundary. Rules are good, a test is better. Below, you build the bike rental as an MCP server with the TypeScript SDK v2, plus a small check that finds weak tool definitions. The check connects to the server in memory, fetches the tool list exactly as a host would see it and tests it against simple rules. Moreover, it runs in milliseconds and fits into any CI. You need Node.js 22 or newer and npm. Basic TypeScript knowledge helps; you do not need an AWS account or API keys. I tested the example with @modelcontextprotocol/server and @modelcontextprotocol/client version 2.4.0, Zod 4.6 and TypeScript 7.0. mkdir bike-rental-mcp && cd bike-rental-mcp npm init -y npm install @modelcontextprotocol/server@2.4.0 @modelcontextprotocol/client@2.4.0 zod@4 npm install -D typescript tsx @types/node mkdir src test Then replace the generated package.json and create a tsconfig.json : { "name": "bike-rental-mcp", "version": "1.0.0", "private": true, "type": "module", "scripts": { "build": "tsc", "check:tools": "tsx src/check-tools.ts", "test": "node --import tsx --test test/ .test.ts" }, "dependencies": { "@modelcontextprotocol/client": "^2.4.0", "@modelcontextprotocol/server": "^2.4.0", "zod": "^4.6.7" }, "devDependencies": { "@types/node": "^26.6.5", "tsx": "^4.23.15", "typescript": "^7.0.2" } } { "compilerOptions": { "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "outDir": "dist", "skipLibCheck": true, "types": "node" }, "include": "src", "test" } To keep the example free of a database, four bikes live in memory. The class stores bookings under a requestKey so that a repeated call does not create a second booking. // src/bike-store.ts export type BikeType = 'city' | 'ebike' | 'cargo'; export interface Bike { id: string; type: BikeType; station: string; frameSizeCm: number; pricePerDayEur: number; } export interface Booking { bookingId: string; bikeId: string; from: string; to: string; } // Small in-memory fleet for the example – in real life this would be a database. const BIKES: Bike = { id: 'bike-ost-01', type: 'city', station: 'ostbahnhof', frameSizeCm: 52, pricePerDayEur: 12 }, { id: 'bike-ost-02', type: 'ebike', station: 'ostbahnhof', frameSizeCm: 56, pricePerDayEur: 29 }, { id: 'bike-hafen-01', type: 'cargo', station: 'hafen', frameSizeCm: 50, pricePerDayEur: 39 }, { id: 'bike-hafen-02', type: 'ebike', station: 'hafen', frameSizeCm: 48, pricePerDayEur: 29 }, ; export class BikeStore { private bookings = new Map