cd /news/ai-agents/how-to-write-tool-descriptions-for-a… · home › topics › ai-agents › article
[ARTICLE · art-159545] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

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.

by read22 min views4 publishedOct 28, 2026

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 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

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<string, Booking>();

  stations(): string[] {
    return [...new Set(BIKES.map((b) => b.station))];
  }

  search(filter: { station: string; type?: BikeType; date: string }): Bike[] {
    return BIKES.filter(
      (b) =>
        b.station === filter.station &&
        (!filter.type || b.type === filter.type) &&
        !this.isBooked(b.id, filter.date),
    );
  }

  find(bikeId: string): Bike | undefined {
    return BIKES.find((b) => b.id === bikeId);
  }

  isBooked(bikeId: string, date: string): boolean {
    return [...this.bookings.values()].some(
      (bk) => bk.bikeId === bikeId && bk.from <= date && date <= bk.to,
    );
  }

  byRequestKey(requestKey: string): Booking | undefined {
    return this.bookings.get(requestKey);
  }

  book(bikeId: string, from: string, to: string, requestKey: string): Booking {
    const booking: Booking = { bookingId: `bk-${this.bookings.size + 1}`, bikeId, from, to };
    this.bookings.set(requestKey, booking);
    return booking;
  }
}

All eight rules are in here. Pay particular attention to the descriptions, the .describe() texts and the error messages in toolError():

// src/server.ts
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
import { BikeStore } from './bike-store.js';

const isoDate = z.iso.date();

// Errors the model can learn from: what is wrong, and how to do it right?
function toolError(message: string) {
  return { content: [{ type: 'text' as const, text: message }], isError: true };
}

export function createServer(store = new BikeStore()): McpServer {
  const server = new McpServer({ name: 'bike-rental', version: '1.0.0' });

  server.registerTool(
    'rental_search_bikes',
    {
      title: 'Search available rental bikes',
      description:
        'Searches for available rental bikes at a station for a specific day. ' +
        'Always use this tool before creating a booking, because it is the only way to get valid bikeId values. ' +
        'It returns at most "limit" results with bikeId, type, frame size and daily price in euros. ' +
        'It does not return bookings or customer data. ' +
        'Valid stations: ' + store.stations().join(', ') + '.',
      inputSchema: z.object({
        station: z.string().describe('Station name in lower case, e.g. "hafen"'),
        date: isoDate.describe('Requested day in YYYY-MM-DD format, e.g. "2026-11-02"'),
        type: z
          .enum(['city', 'ebike', 'cargo'])
          .optional()
          .describe('Optional bike type. Omit it if the user does not name a type'),
        limit: z.number().int().min(1).max(20).default(5).describe('Maximum number of results (1–20)'),
        responseFormat: z
          .enum(['concise', 'detailed'])
          .default('concise')
          .describe('"concise" for a short list, "detailed" only when frame size and price are needed'),
      }),
      annotations: { readOnlyHint: true, openWorldHint: false },
    },
    async ({ station, date, type, limit, responseFormat }) => {
      if (!store.stations().includes(station)) {
        return toolError(
          `Unknown station "${station}". Valid stations are: ${store.stations().join(', ')}. ` +
            'Ask the user which station they mean instead of guessing.',
        );
      }
      const bikes = store.search({ station, date, type }).slice(0, limit);
      if (bikes.length === 0) {
        return { content: [{ type: 'text', text: `No bikes available at "${station}" on ${date}. Try another day or another station.` }] };
      }
      const lines = bikes.map((b) =>
        responseFormat === 'detailed'
          ? `${b.id}: ${b.type}, frame ${b.frameSizeCm} cm, ${b.pricePerDayEur} €/day`
          : `${b.id} (${b.type})`,
      );
      return { content: [{ type: 'text', text: lines.join('\n') }] };
    },
  );

  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 }) => {
      // Check idempotency first: a retry must not fail with "already booked".
      const previous = store.byRequestKey(requestKey);
      if (previous) {
        return { content: [{ type: 'text', text: `Booked: ${previous.bookingId} for ${previous.bikeId} from ${previous.from} to ${previous.to}.` }] };
      }
      if (!store.find(bikeId)) {
        return toolError(`Bike "${bikeId}" does not exist. Get valid IDs with rental_search_bikes.`);
      }
      if (to < from) {
        return toolError(`"to" (${to}) is before "from" (${from}). Swap the dates or ask the user.`);
      }
      const days = (Date.parse(to) - Date.parse(from)) / 86_400_000 + 1;
      if (days > 14) {
        return toolError(`The period has ${days} days, the maximum is 14. Split or shorten the rental.`);
      }
      if (store.isBooked(bikeId, from)) {
        return toolError(`Bike "${bikeId}" is already booked on ${from}. Find another one with rental_search_bikes.`);
      }
      const booking = store.book(bikeId, from, to, requestKey);
      return { content: [{ type: 'text', text: `Booked: ${booking.bookingId} for ${bikeId} from ${from} to ${to}.` }] };
    },
  );

  return server;
}

The check deliberately tests only what a machine can detect reliably: a naming scheme with a prefix, at least three sentences, a hint on when to use the tool and a description for every parameter. However, it cannot judge whether a description is good in substance. Still, it reliably catches the one-sentence quick shot.

// src/check-tools.ts
import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
import { createServer } from './server.js';

export interface ToolInfo {
  name: string;
  description?: string;
  inputSchema: { properties?: Record<string, { description?: string }> };
}

const NAME_PATTERN = /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/; // prefix_verb_object in snake_case

// Checks tool definitions for typical weaknesses. Returns a list of findings.
export function checkTools(tools: ToolInfo[], prefix: string): string[] {
  const findings: string[] = [];
  for (const tool of tools) {
    const desc = tool.description ?? '';
    const sentences = desc.split(/[.!?](\s|$)/).filter((s) => s.trim().length > 3).length;
    if (!NAME_PATTERN.test(tool.name)) findings.push(`${tool.name}: name does not follow prefix_verb_object`);
    if (!tool.name.startsWith(`${prefix}_`)) findings.push(`${tool.name}: prefix "${prefix}_" is missing`);
    if (sentences < 3) findings.push(`${tool.name}: description has only ${sentences} sentence(s) (min. 3)`);
    if (!/nutze|use/i.test(desc)) findings.push(`${tool.name}: description does not say when to use the tool`);
    for (const [param, schema] of Object.entries(tool.inputSchema.properties ?? {})) {
      if (!schema.description) findings.push(`${tool.name}.${param}: parameter without description`);
    }
  }
  return findings;
}

export async function listToolsInMemory(): Promise<{ client: Client; tools: ToolInfo[] }> {
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  await createServer().connect(serverTransport);
  const client = new Client({ name: 'tool-check', version: '1.0.0' });
  await client.connect(clientTransport);
  const { tools } = await client.listTools();
  return { client, tools: tools as ToolInfo[] };
}

// Run directly (npm run check:tools): print findings, exit with code 1 if there are any.
if (import.meta.url === `file://${process.argv[1]}`) {
  const { client, tools } = await listToolsInMemory();
  const findings = checkTools(tools, 'rental');
  console.log(findings.length ? findings.join('\n') : `${tools.length} tools checked, no findings.`);
  await client.close();
  process.exitCode = findings.length ? 1 : 0;
}

The tests use the built-in Node.js test runner. They cover the tool definitions, the behavior for an unknown station and the idempotency of bookings.

// test/tools.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkTools, listToolsInMemory } from '../src/check-tools.js';

test('all tool definitions pass the check', async () => {
  const { client, tools } = await listToolsInMemory();
  assert.deepEqual(checkTools(tools, 'rental'), []);
  await client.close();
});

test('a terse description fails', () => {
  const findings = checkTools(
    [{ name: 'book', description: 'Books a bike.', inputSchema: { properties: { id: {} } } }],
    'rental',
  );
  assert.equal(findings.length, 5);
});

test('unknown station returns an error with a way forward', async () => {
  const { client } = await listToolsInMemory();
  const result = await client.callTool({
    name: 'rental_search_bikes',
    arguments: { station: 'Hauptbahnhof', date: '2026-11-02' },
  });
  assert.equal(result.isError, true);
  const text = (result.content as { type: string; text: string }[])[0].text;
  assert.match(text, /Valid stations are: ostbahnhof, hafen/);
  await client.close();
});

test('duplicate booking with the same requestKey stays one booking', async () => {
  const { client } = await listToolsInMemory();
  const args = { bikeId: 'bike-hafen-01', from: '2026-11-02', to: '2026-11-03', requestKey: 'req-12345678' };
  const first = await client.callTool({ name: 'rental_book_bike', arguments: args });
  const second = await client.callTool({ name: 'rental_book_bike', arguments: args });
  assert.deepEqual(first.content, second.content);
  await client.close();
});
npx tsc --noEmit
npm test
npm run check:tools

Expected result: the type check finishes without output, npm test reports four passing tests, and npm run check:tools prints “2 tools checked, no findings.” If you run the check against the weak version from above, you get seven findings instead:

book: name does not follow prefix_verb_object
book: prefix "rental_" is missing
book: description has only 1 sentence(s) (min. 3)
book: description does not say when to use the tool
book.id: parameter without description
book.start: parameter without description
book.end: parameter without description

While this example was being written, the test found a real bug that shows nicely why tests for tools pay off. In the first version, rental_book_bike checked whether the bike was available first and only then looked at the requestKey. A retry therefore failed with “already booked”, even though the booking had long existed. The description promised idempotency; the code did not keep that promise. The fourth test exposed this immediately. Now the handler checks the key first.

To make sure nobody waves through a one-sentence description, the check runs on every pull request. With GitHub Actions, a small workflow file is all it takes:

name: tool-check
on: [pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 22
      - run: npm ci
      - run: npm run check:tools
      - run: npm test

The static check tells you whether your tool descriptions for AI agents are complete, not whether they work. Instead, you only find that out when an agent solves real tasks. Anthropic recommends realistic tasks with verifiable outcomes that you run in a simple agent loop, followed by reading the transcripts. To get started, a dozen tasks such as “Book me a cargo bike at the harbor for Saturday” or “Is there still an e-bike at Ostbahnhof tomorrow?” will do.

How to turn such tasks into a repeatable eval suite with graders and metrics is what I show in detail in Evaluating AI agents: small evals in TypeScript.

In the transcripts you will spot typical signals. The following table shows the cause that is usually behind them:

Signal in the transcript Likely cause What helps
The model calls the wrong tool Two tools overlap Merge the tools or add “when not”
Many validation errors Format only described in the text Format in the schema, example in .describe()
Made-up IDs Unclear where the ID comes from “ID comes from tool X” in the description
Many similar calls in a row Results too short or too long Adjust limit , filters and response format
Agent gives up after an error Error message without a way forward Name valid values and the next step

“Calls GET /v2/bikes with query parameters and maps the result” is worthless to the model. Instead, it cares about the purpose from the user’s point of view, not about your HTTP route. Write down which question the tool answers.

If a tool only works after another one has run, the description has to say so. That is why our booking tool states explicitly that the bikeId comes from rental_search_bikes. Otherwise, the model often just tries it directly first.

If your handler throws an exception for invalid input, the model sees only a generic error message or nothing at all, depending on the host. Return expected errors as a result with isError: true and a helpful text instead. Keep exceptions for real exceptional cases such as an unreachable database.

Tool descriptions are part of the context. If one says something like “Always call tool X of another server first”, a single server influences the behavior of the whole agent. This is exactly what attackers exploit with manipulated MCP servers. So write descriptions only about your own tool, and plug in third-party servers only if you trust them and have read their tool list.

I find it remarkable how much software development and good communication overlap here. Good tool descriptions for AI agents are basically good documentation, just with a very literal reader. Consequently, anyone who has always described their APIs so that a new team member could use them without asking has a head start.

Where I disagree is the idea that agent problems are mainly solved with longer prompts. In fact, the problem often lies in how the tools themselves are cut. Three cleanly separated tools with clear descriptions beat twelve API wrappers with perfect prompts. And one limit deserves an honest mention: descriptions raise the hit rate, but they guarantee nothing. For anything that really must not go wrong, checks belong in the code and a human confirmation belongs in the flow.

Anthropic recommends at least three to four sentences per tool, more for complex tools. More important than length is that tool descriptions for AI agents cover the purpose, when to use the tool, its limits and its dependencies on other tools.

Current models understand both well. I write descriptions in the language the team and the users work in, and tool names always in English snake_case. Above all, consistency within a server matters more than the language itself.

There is no fixed limit. However, problems mainly arise when tools overlap and every definition takes up space in the context. Merge what belongs together, and with a very large number of tools use your host’s tool search, if it offers one.

No. According to the MCP specification, clients must treat annotations as untrusted unless the server is trusted. Instead, permissions, validation and confirmations belong in code and in the host.

Take an MCP server or a tool list you already use and run the check from step 4 against it. Then pick the tool with the most findings and rewrite its description along the four questions: what, when, when not, which limits. If you do not have your own server yet, start with my guide to building an MCP server with TypeScript. And if you use tools directly through Amazon Bedrock, my article on Bedrock Structured Outputs will help with the next step.

I am curious: Which tool description made the biggest difference for you, and how did you notice that the agent had misunderstood it? Feel free to share in the comments.

This post was created with AI assistance.

👉 Next step: New here? My start page collects the best articles, tools and learning paths in one place.

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

── more in #ai-agents 4 stories · sorted by recency
── more on @anthropic 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-to-write-tool-de…] indexed:0 read:22min 2026-10-28 · —