Writing Descriptions Agents Understand
A description is an instruction to a model. When a model decides which tool to call, whether to call one at all, and what to put in each argument, the names and descriptions in tools/list are all it has to go on. A tool with a vague description gets skipped or misused however good its code is, and two tools with similar descriptions get mixed up. This lesson is about writing descriptions that steer a model to the right call.
You will learn
- What a tool description should say: what it does, what it returns, when to use it
- How to tell a model when not to use a tool
- How to describe arguments with units, formats and examples
- Why overlapping tools confuse models, and how to merge them
- The difference between
nameandtitle, and how to check descriptions with tests
Descriptions are instructions
Here are three help desk tools, written quickly. Open the Capabilities tab:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "search_tickets", inputSchema: { query: z.string() } })
export class SearchTickets extends ToolContext {
async execute({ query }: { query: string }) {
return { tickets: [{ id: "T-1", title: "Cannot log in", status: "open" }], query };
}
}
@Tool({ name: "get_ticket", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
async execute({ id }: { id: string }) {
return { id, title: "Cannot log in", status: "open", messages: [] as string[] };
}
}
@Tool({ name: "reply_to_ticket", inputSchema: { id: z.string(), body: z.string() } })
export class ReplyToTicket extends ToolContext {
async execute({ id }: { id: string; body: string }) {
return { id, sent: true };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Each card says No description. The model has only the name to go on. A user writes: "Tell Dana we're looking into her login problem." The model can guess that search_tickets finds tickets, but not whether it searches titles, messages or customer names, or what comes back. It can guess that reply_to_ticket replies, but not that the customer receives it by email the moment it's called.
Here are the same tools, described:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "search_tickets",
description:
"Search support tickets by words in their title. Returns up to 10 matches, newest first, each with its id, title and status. To read a whole ticket, pass its id to get_ticket.",
inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles, like \"log in\"") },
annotations: { readOnlyHint: true },
})
export class SearchTickets extends ToolContext {
async execute({ query }: { query: string }) {
return { tickets: [{ id: "T-1", title: "Cannot log in", status: "open" }], query };
}
}
@Tool({
name: "get_ticket",
description:
"Get one support ticket by its id, with every message in its conversation. Use it when you already have an id like T-12; to find an id, use search_tickets.",
inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") },
annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
async execute({ id }: { id: string }) {
return { id, title: "Cannot log in", status: "open", messages: [] as string[] };
}
}
@Tool({
name: "reply_to_ticket",
description:
"Email a reply to the customer who opened a ticket, and add it to the ticket's conversation. The customer receives it immediately, so only reply when the user asked you to.",
inputSchema: {
id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12"),
body: z.string().min(1).describe("The reply, in plain text, written to the customer"),
},
})
export class ReplyToTicket extends ToolContext {
async execute({ id }: { id: string; body: string }) {
return { id, sent: true };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Each description answers the questions a model has, roughly in the order it has them:
- What does it do? Lead with one plain sentence in the user's terms: "Search support tickets by words in their title."
- What comes back? "Up to 10 matches, newest first, each with its id, title and status." Now the model knows it may not see everything, and that the ids are there for the next step.
- When is it the right tool, and what comes next? "To read a whole ticket, pass its id to get_ticket." Point from one tool to the next by name.
- What happens as a side effect? "The customer receives it immediately." Anything a user would want to know before the call happens belongs here.
Write for a reader who has never seen your code or your database. "Queries tkt_main with an ILIKE" describes your implementation; "Search tickets by words in their title" describes what the model can do with it.
Deep divePutting the result's shape in the descriptionShow detailsHide details
Not every client passes outputSchema on to the model. FrontMCP can also write a summary of it at the end of the description, with the output option:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "search_tickets",
description: "Search support tickets by words in their title. Returns up to 10 matches, newest first.",
inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles") },
outputSchema: z.object({
tickets: z.array(z.object({ id: z.string(), title: z.string() })),
total: z.number().int().describe("How many tickets matched"),
}),
output: { schemaMode: "both" },
})
export class SearchTickets extends ToolContext {
async execute({ query }: { query: string }) {
return { tickets: [], total: 0 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
schemaMode can be "definition" (the default: outputSchema only), "description" (the summary instead of outputSchema), "both", or "none". The summary lists top-level fields with their types and .describe() notes, so describing output fields pays off here too.
Say when not to use a tool
Some tools are easy to mistake for each other because they do almost the same thing. A help desk has two ways to write on a ticket: a reply the customer receives, and a note only agents see. If a user says "Note that Dana is on the legacy plan," a model that picks the wrong one emails the customer your internal notes.
Each description should say what makes it different, and name the other tool:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "reply_to_ticket",
description:
"Email a reply to the customer who opened a ticket. The customer receives it immediately. For anything the customer shouldn't see, use add_internal_note instead.",
inputSchema: {
id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12"),
body: z.string().min(1).describe("The reply, in plain text, written to the customer"),
},
})
export class ReplyToTicket extends ToolContext {
async execute({ id }: { id: string; body: string }) {
return { id, emailed: true };
}
}
@Tool({
name: "add_internal_note",
description:
"Add a note to a ticket that only support agents can see. The customer is never told. Use it for context, handover notes and anything private; to answer the customer, use reply_to_ticket.",
inputSchema: {
id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12"),
note: z.string().min(1).describe("The note, in plain text"),
},
})
export class AddInternalNote extends ToolContext {
async execute({ id }: { id: string; note: string }) {
return { id, visibleTo: "agents" };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
"Instead" sentences are the most useful ones in a description. They turn a guess between two similar tools into a rule: this tool for customers, that one for agents. The same pattern works for any pair that's easy to confuse, like search and get, close and delete, or preview and send.
Describe arguments: units, formats and examples
A field's name and type are rarely enough. This tool snoozes a ticket, hiding it from the queue for a while:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "snooze_ticket",
description: "Hide a ticket from the queue until a later time, then bring it back as open.",
inputSchema: {
id: z.string(),
duration: z.number(),
},
})
export class SnoozeTicket extends ToolContext {
async execute({ id, duration }: { id: string; duration: number }) {
const until = new Date(Date.UTC(2026, 8, 24, 9, 0) + duration * 60_000);
return { id, snoozedUntil: until.toISOString() };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The user said "snooze it for three days", and the model sent duration: 3. The code reads minutes, so the ticket comes back three minutes later. Nothing in the schema said minutes, and id doesn't say what an id looks like either.
Put the unit in the name, say it again in the description, and give an example:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "snooze_ticket",
description: "Hide a ticket from the queue until a later time, then bring it back as open.",
inputSchema: {
id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12. Get one from search_tickets."),
minutes: z
.number()
.int()
.min(5)
.max(10080)
.describe("How long to snooze, in minutes: 60 is an hour, 1440 is a day. At most 7 days."),
},
})
export class SnoozeTicket extends ToolContext {
async execute({ id, minutes }: { id: string; minutes: number }) {
const until = new Date(Date.UTC(2026, 8, 24, 9, 0) + minutes * 60_000);
return { id, snoozedUntil: until.toISOString() };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
What to put in an argument's description:
- Units. Minutes or hours, cents or dollars, bytes or megabytes. Put the unit in the field name too (
minutes,amount_cents), because a name is harder to overlook than a note. - Formats.
T-12,YYYY-MM-DD, an email address. A.regex()enforces the format; the note shows the model what it looks like. - Examples. Short, realistic values: "like T-12", "60 is an hour". One example often says more than a sentence of explanation.
- Where the value comes from. "Get one from search_tickets" tells the model how to get an id it doesn't have yet.
The constraints matter as much as the words. .int().min(5).max(10080) appears in tools/list as minimum and maximum, and FrontMCP rejects anything outside that range before execute() runs, as covered in Schemas Are Contracts.
Deep diveDoes the examples option reach the model?Show detailsHide details
@Tool also has an examples option, with sample inputs. FrontMCP 1.8 doesn't send it in tools/list: it's used by FrontMCP's CodeCall plugin, which shows a tool's examples to a model that looks the tool up. A model connected over MCP only sees the examples you write into descriptions, like "60 is an hour" above. The test below shows examples missing from the listed tool:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "snooze_ticket",
description: "Hide a ticket from the queue until a later time, then bring it back as open.",
inputSchema: { id: z.string(), minutes: z.number().int() },
examples: [{ description: "Snooze T-1 for a day", input: { id: "T-1", minutes: 1440 } }],
})
export class SnoozeTicket extends ToolContext {
async execute({ id, minutes }: { id: string; minutes: number }) {
return { id, minutes };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
One tool per job
Every tool you add is one more option the model has to weigh. When several tools do nearly the same thing, it has to guess between them. These three all return tickets:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "search_tickets",
description: "Search support tickets by words in their title.",
inputSchema: { query: z.string().min(3) },
})
export class SearchTickets extends ToolContext {
async execute({ query }: { query: string }) {
return { tickets: [] as { id: string }[], query };
}
}
@Tool({
name: "find_customer_tickets",
description: "Find a customer's support tickets.",
inputSchema: { email: z.string().email() },
})
export class FindCustomerTickets extends ToolContext {
async execute({ email }: { email: string }) {
return { tickets: [] as { id: string }[], email };
}
}
@Tool({
name: "list_open_tickets",
description: "List open support tickets.",
inputSchema: {},
})
export class ListOpenTickets extends ToolContext {
async execute() {
return { tickets: [] as { id: string }[] };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
"Are there any open tickets from Dana about logging in?" needs all three filters, and no tool has them. The model picks one and filters the rest itself, or calls all three and merges the lists, or gives up. When tools differ only by a filter, merge them into one tool with optional arguments:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { tickets } from "./tickets";
@Tool({
name: "search_tickets",
description:
"Search support tickets. Every filter is optional, and filters combine: words in the title, status, and the customer's email. Returns up to 10 matches, newest first. With no filters, returns the 10 newest tickets.",
inputSchema: {
query: z.string().min(3).optional().describe("Words to look for in ticket titles, like \"log in\""),
status: z.enum(["open", "closed"]).optional().describe("Leave out to include both"),
customer: z.string().email().optional().describe("The customer's email address, like dana@acme.com"),
},
annotations: { readOnlyHint: true },
})
export class SearchTickets extends ToolContext {
async execute({ query, status, customer }: { query?: string; status?: "open" | "closed"; customer?: string }) {
const matches = tickets.filter(
(t) =>
(!query || t.title.toLowerCase().includes(query.toLowerCase())) &&
(!status || t.status === status) &&
(!customer || t.customer === customer),
);
return { tickets: matches.slice(0, 10), total: matches.length };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
One tool, one description, three described filters, and the question takes one call. Keep tools separate when they genuinely do different things: they have different side effects (reading and writing), different permissions, or return different kinds of results. Merge them when the only difference is which records they return.
name is for the model, title is for people
A tool has two names, and they have different jobs:
nameis the identifier the model uses to call the tool, likesearch_tickets. It should be asnake_caseverb and noun, unique on your server, and stable: models, prompts and tests refer to it, so renaming it breaks them.titleis a display name for people, like "Search tickets". Clients can show it in their UI, for example when they ask the user to approve a call.
Set it with @Tool({ title }). tools/list sends title only for tools that have one; for the others, clients show the name. Resources and prompts take a title option too:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "search_tickets",
title: "Search tickets",
description: "Search support tickets by words in their title. Returns up to 10 matches, newest first.",
inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles") },
annotations: { readOnlyHint: true },
})
export class SearchTickets extends ToolContext {
async execute({ query }: { query: string }) {
return { tickets: [] as { id: string }[], query };
}
}
@Tool({
name: "get_ticket",
description: "Get one support ticket by its id, with every message in its conversation.",
inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") },
annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
async execute({ id }: { id: string }) {
return { id, messages: [] as string[] };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Open the Wire tab to see both lists. MCP also has an older place for a tool's display name, annotations.title. FrontMCP sends it as you set it, but title is the one to use. The resource's description follows the same rules as a tool's: say what's in it and when it's useful. Resources are usually picked by an application or a user rather than by the model, so the reader of that description may be a person choosing what to attach to a conversation. See Exposing Data with Resources.
Checking descriptions with tests
Descriptions drift. A tool gets renamed and another tool's description still points to the old name; someone adds an argument without a note. Tests over tools/list catch these, the same way they catch bugs in execute():
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "search_tickets",
description:
"Search support tickets by words in their title. Returns up to 10 matches, newest first, each with its id, title and status. To read a whole ticket, pass its id to get_ticket.",
inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles, like \"log in\"") },
annotations: { readOnlyHint: true },
})
export class SearchTickets extends ToolContext {
async execute({ query }: { query: string }) {
return { tickets: [] as { id: string }[], query };
}
}
@Tool({
name: "get_ticket",
description:
"Get one support ticket by its id, with every message in its conversation. Use it when you already have an id like T-12; to find an id, use search_tickets.",
inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") },
annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
async execute({ id }: { id: string }) {
return { id, messages: [] as string[] };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The Tests tab runs three checks: every tool has a real description, every argument has a note, and every snake_case tool name mentioned in a description is a tool that exists. Try renaming get_ticket to read_ticket and run the tests again: the third check fails, because search_tickets still sends the model to get_ticket. The second check puts the tool and field names in its comparison, so a failure says which argument is missing its note.
Tests like these check that the words are there, not that a model understands them. For that, connect a real client (see Installation), ask the questions your users ask, and watch which tools the model picks and what it sends.
Recap
- A model chooses tools and fills in arguments from names, descriptions and schemas alone. Descriptions are instructions.
- A tool description says what it does, what it returns, when to use it (and which tool to use instead), and any side effects.
- Name the other tool in an "instead" sentence when two tools are easy to confuse.
- Argument descriptions give units, formats, examples and where values come from. Put units in field names too.
@Tool({ examples })isn't sent intools/listin FrontMCP 1.8. Write examples into descriptions.- Merge tools that differ only by a filter into one tool with optional arguments.
nameis the stable identifier the model calls;titleis a display name for people. Tools, resources and prompts all take atitleoption.- Test descriptions over
tools/list: presence, length, argument notes, and references to other tools.
Try some challenges
Challenge 1 of 4
Keep the model from deleting real tickets
close_ticket and delete_ticket have descriptions a model can't tell apart. Rewrite both descriptions so that close_ticket says it's for solved problems and names delete_ticket as the one not to use, and delete_ticket says it's permanent, is only for spam, and names close_ticket as the one to use instead. Each should be at least 60 characters.
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "close_ticket",
description: "Close a ticket.",
inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") },
})
export class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
return { id, status: "closed" };
}
}
@Tool({
name: "delete_ticket",
description: "Remove a ticket.",
inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") },
annotations: { destructiveHint: true },
})
export class DeleteTicket extends ToolContext {
async execute({ id }: { id: string }) {
return { id, deleted: true };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.