# Writing Descriptions Agents Understand

> How to write tool, argument and resource descriptions that tell a model when to use a capability, how to fill it in, and what comes back, and how to check them with tests.

Source: https://frontmcp.dev/learn/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 `name` and `title`, and how to check descriptions with tests

## Descriptions are instructions

Here are three help desk tools, written quickly. Open the **Capabilities** tab:

```ts tickets.tools.ts
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 };
  }
}
```

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:

```ts tickets.tools.ts
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 };
  }
}
```

Each description answers the questions a model has, roughly in the order it has them:

1. **What does it do?** Lead with one plain sentence in the user's terms: "Search support tickets by words in their title."
2. **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.
3. **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.
4. **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.

> **Note**
`outputSchema` tells clients the *shape* of a result, like `{ tickets, total }`. The description is still where you say what it *means*: how many results at most, in what order, and what an empty list means. See [Shaping Tool Results](https://frontmcp.dev/learn/shaping-tool-results) for `outputSchema`.

**Deep dive: Putting the result's shape in the description**
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:

```ts search.tool.ts
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 };
  }
}
```

```ts description.test.ts
import { test, expect } from "@frontmcp/testing";

test("the description ends with a summary of the result", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool.description).toContain("**Returns:**");
  expect(tool.description).toContain("`total`: integer (required) — How many tickets matched");
});

test("outputSchema is still listed too", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool.outputSchema?.properties).toHaveProperty("total");
});
```

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

```ts write.tools.ts
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" };
  }
}
```

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

```ts snooze.tool.ts
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() };
  }
}
```

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:

```ts snooze.tool.ts
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() };
  }
}
```

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](https://frontmcp.dev/learn/schemas-are-contracts).

**Deep dive: Does the examples option reach the model?**
`@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](https://frontmcp.dev/reference/plugins/codecall), which shows a tool's examples to a model that [looks the tool up](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall#finding-tools-search-then-describe). 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:

```ts snooze.tool.ts
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 };
  }
}
```

```ts examples.test.ts
import { test, expect } from "@frontmcp/testing";

test("`examples` isn't part of tools/list", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "snooze_ticket");
  expect(tool).not.toHaveProperty("examples");
  expect(JSON.stringify(tool)).not.toContain("Snooze T-1 for a day");
});
```

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

```ts search.tools.ts
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 }[] };
  }
}
```

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

```ts search.tool.ts active
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 };
  }
}
```

```ts tickets.ts
export const tickets = [
  { id: "T-4", title: "Login link expired", status: "open", customer: "dana@acme.com" },
  { id: "T-3", title: "Cannot log in", status: "closed", customer: "dana@acme.com" },
  { id: "T-2", title: "Invoice total is wrong", status: "open", customer: "dana@acme.com" },
  { id: "T-1", title: "Cannot log in", status: "open", customer: "sam@globex.com" },
];
```

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:

- **`name`** is the identifier the model uses to call the tool, like `search_tickets`. It should be a `snake_case` verb and noun, unique on your server, and stable: models, prompts and tests refer to it, so renaming it breaks them.
- **`title`** is 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:

```ts search.tool.ts
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[] };
  }
}
```

```ts refund-policy.resource.ts
import { Resource, ResourceContext } from "@frontmcp/sdk";

@Resource({
  name: "refund-policy",
  title: "Refund policy",
  uri: "docs://refund-policy",
  mimeType: "text/markdown",
  description: "When customers can get a refund, and how much. Read it before answering any question about refunds or charges.",
})
export class RefundPolicy extends ResourceContext {
  async execute(uri: string) {
    return { contents: [{ uri, mimeType: "text/markdown", text: "# Refunds\n\nFull refund within 30 days of purchase." }] };
  }
}
```

```ts titles.test.ts
import { test, expect } from "@frontmcp/testing";

test("search_tickets has the display name \"Search tickets\"", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "search_tickets");
  expect(tool?.title).toBe("Search tickets");
});

test("get_ticket has no title, so it's listed without one", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "get_ticket");
  expect(tool).not.toHaveProperty("title");
});

test("the resource has its own `title`", async ({ mcp }) => {
  const [resource] = await mcp.resources.list();
  expect(resource.title).toBe("Refund policy");
});
```

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](https://frontmcp.dev/learn/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()`:

```ts tickets.tools.ts
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[] };
  }
}
```

```ts descriptions.test.ts
import { test, expect } from "@frontmcp/testing";

test("every tool has a description of at least 60 characters", async ({ mcp }) => {
  for (const tool of await mcp.tools.list()) {
    expect(tool.description?.length ?? 0).toBeGreaterThanOrEqual(60);
  }
});

test("every argument has a description", async ({ mcp }) => {
  for (const tool of await mcp.tools.list()) {
    for (const [name, field] of Object.entries<any>(tool.inputSchema.properties ?? {})) {
      expect([tool.name, name, field.description?.length > 0]).toEqual([tool.name, name, true]);
    }
  }
});

test("tools named in descriptions exist", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  const names = tools.map((t) => t.name);
  for (const tool of tools) {
    for (const mentioned of tool.description?.match(/\b[a-z]+(?:_[a-z]+)+\b/g) ?? []) {
      expect(names).toContain(mentioned);
    }
  }
});
```

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](https://frontmcp.dev/learn/installation#connecting-a-client)), 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 in `tools/list` in FrontMCP 1.8. Write examples into descriptions.
- Merge tools that differ only by a filter into one tool with optional arguments.
- `name` is the stable identifier the model calls; `title` is a display name for people. Tools, resources and prompts all take a `title` option.
- Test descriptions over `tools/list`: presence, length, argument notes, and references to other tools.

## Try some challenges

### Challenge: 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.

```ts tickets.tools.ts
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 };
  }
}
```

```ts tickets.tools.ts solution
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "close_ticket",
  description:
    "Close a support ticket once the customer's problem is solved. The ticket stays in the history and can be reopened. Don't use delete_ticket for this.",
  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:
    "Permanently delete a ticket and its conversation. Only for spam or tickets opened by mistake; to finish a real ticket, use close_ticket instead. This can't be undone.",
  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 };
  }
}
```

```ts descriptions.test.ts hidden
import { test, expect } from "@frontmcp/testing";

async function descriptionOf(mcp: any, name: string): Promise<string> {
  const tool = (await mcp.tools.list()).find((t: any) => t.name === name);
  return tool?.description ?? "";
}

test("both descriptions are at least 60 characters", async ({ mcp }) => {
  expect((await descriptionOf(mcp, "close_ticket")).length).toBeGreaterThanOrEqual(60);
  expect((await descriptionOf(mcp, "delete_ticket")).length).toBeGreaterThanOrEqual(60);
});

test("close_ticket says it's for solved problems", async ({ mcp }) => {
  expect(await descriptionOf(mcp, "close_ticket")).toMatch(/solved|resolved|fixed/i);
});

test("close_ticket names delete_ticket", async ({ mcp }) => {
  expect(await descriptionOf(mcp, "close_ticket")).toContain("delete_ticket");
});

test("delete_ticket says it's permanent", async ({ mcp }) => {
  expect(await descriptionOf(mcp, "delete_ticket")).toMatch(/permanent|can't be undone|cannot be undone/i);
});

test("delete_ticket says it's only for spam", async ({ mcp }) => {
  expect(await descriptionOf(mcp, "delete_ticket")).toMatch(/spam/i);
});

test("delete_ticket names close_ticket as the alternative", async ({ mcp }) => {
  expect(await descriptionOf(mcp, "delete_ticket")).toContain("close_ticket");
});
```

**Hint:**
Write each description in the order from this lesson: what it does, then when to use the other tool instead. The riskier tool needs the clearer warning.

**Solution:**
Each description now says what the tool is for and names the other one, so "we fixed Dana's problem" leads to `close_ticket` and "this ticket is spam" leads to `delete_ticket`. The `destructiveHint` annotation tells clients the call is risky, but only the description tells the model *when* it's the right call.

### Challenge: Say which unit
`issue_refund` takes an `amount`, and the code reads it in cents. A model asked to "refund $19.99" will send `19.99`, and the customer gets 20 cents back. Rename the argument to `amount_cents`, make it a whole number of at least 1, and describe it with the unit and an example.

```ts refund.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "issue_refund",
  description: "Refund part or all of a customer's payment on a ticket. The money goes back to the card they paid with.",
  inputSchema: {
    id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12"),
    amount: z.number(),
  },
})
export class IssueRefund extends ToolContext {
  async execute({ id, amount }: { id: string; amount: number }) {
    return { id, refunded: `$${(amount / 100).toFixed(2)}` };
  }
}
```

```ts refund.tool.ts solution
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "issue_refund",
  description: "Refund part or all of a customer's payment on a ticket. The money goes back to the card they paid with.",
  inputSchema: {
    id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12"),
    amount_cents: z.number().int().min(1).describe("How much to refund, in cents: 1999 is $19.99"),
  },
})
export class IssueRefund extends ToolContext {
  async execute({ id, amount_cents }: { id: string; amount_cents: number }) {
    return { id, refunded: `$${(amount_cents / 100).toFixed(2)}` };
  }
}
```

```ts refund.test.ts hidden
import { test, expect } from "@frontmcp/testing";

async function fields(mcp: any) {
  const tool = (await mcp.tools.list()).find((t: any) => t.name === "issue_refund");
  return tool.inputSchema.properties;
}

test("the argument is called `amount_cents`", async ({ mcp }) => {
  const props = await fields(mcp);
  expect(props).toHaveProperty("amount_cents");
  expect(props).not.toHaveProperty("amount");
});

test("its description says \"cents\"", async ({ mcp }) => {
  expect((await fields(mcp)).amount_cents?.description).toMatch(/cents/i);
});

test("it's a whole number of at least 1", async ({ mcp }) => {
  const field = (await fields(mcp)).amount_cents;
  expect(field?.type).toBe("integer");
  expect(field?.minimum).toBe(1);
});

test("refunding 1999 cents refunds $19.99", async ({ mcp }) => {
  const result = await mcp.tools.call("issue_refund", { id: "T-2", amount_cents: 1999 });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({ id: "T-2", refunded: "$19.99" });
});

test("19.99 is rejected instead of refunding 20 cents", async ({ mcp }) => {
  const result = await mcp.tools.call("issue_refund", { id: "T-2", amount_cents: 19.99 });
  expect(result.raw._meta?.code).toBe("INVALID_INPUT");
});
```

**Hint:**
`.int()` makes a number whole, and shows up in `tools/list` as `"type": "integer"`. Rename the field in `execute()` too.

**Solution:**
The unit is now in three places a model reads: the name `amount_cents`, the note "in cents: 1999 is $19.99", and `"type": "integer"`, which rules out `19.99`. If a model still sends dollars, FrontMCP rejects the call instead of refunding the wrong amount.

### Challenge: Make the description checks pass
These tools fail the description checks from this lesson. `search_tickets` points to a tool that was renamed, `get_ticket` has no description, and `reply_to_ticket` has an argument without a note. Fix the descriptions until every check passes.

```ts tickets.tools.ts
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. Use fetch_ticket for details.",
  inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles") },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: [] as { id: string }[], query };
  }
}

@Tool({
  name: "get_ticket",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, messages: [] as string[] };
  }
}

@Tool({
  name: "reply_to_ticket",
  description: "Email a reply to the customer who opened a ticket. The customer receives it immediately.",
  inputSchema: {
    id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12"),
    body: z.string().min(1),
  },
})
export class ReplyToTicket extends ToolContext {
  async execute({ id }: { id: string; body: string }) {
    return { id, sent: true };
  }
}
```

```ts tickets.tools.ts solution
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. Use get_ticket for details.",
  inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles") },
})
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. To find an id, use search_tickets.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, messages: [] as string[] };
  }
}

@Tool({
  name: "reply_to_ticket",
  description: "Email a reply to the customer who opened a ticket. The customer receives it immediately.",
  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 };
  }
}
```

```ts descriptions.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("every tool has a description of at least 60 characters", async ({ mcp }) => {
  for (const tool of await mcp.tools.list()) {
    expect([tool.name, tool.description?.length >= 60]).toEqual([tool.name, true]);
  }
});

test("every argument has a description", async ({ mcp }) => {
  for (const tool of await mcp.tools.list()) {
    for (const [name, field] of Object.entries<any>(tool.inputSchema.properties ?? {})) {
      expect([tool.name, name, field.description?.length > 0]).toEqual([tool.name, name, true]);
    }
  }
});

test("tools named in descriptions exist", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  const names = tools.map((t) => t.name);
  for (const tool of tools) {
    for (const mentioned of tool.description?.match(/\b[a-z]+(?:_[a-z]+)+\b/g) ?? []) {
      expect(names).toContain(mentioned);
    }
  }
});
```

**Hint:**
Open the **Checks** tab after pressing Check: each failure names the tool, and for arguments, the field.

**Solution:**
Three small fixes: point `search_tickets` at `get_ticket`, give `get_ticket` a description that says what it returns and where ids come from, and describe `body`. Checks like these are cheap to keep in your own test suite, so the next rename fails a test instead of confusing a model.

### Challenge: Give a tool a display title
Clients show tool names to users, for example when asking them to approve a call, and `close_ticket` isn't friendly. Give the tool the display title "Close ticket" without changing its `name`.

```ts close.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "close_ticket",
  description: "Close a support ticket once the customer's problem is solved. The ticket can be reopened later.",
  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" };
  }
}
```

```ts close.tool.ts solution
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "close_ticket",
  description: "Close a support ticket once the customer's problem is solved. The ticket can be reopened later.",
  title: "Close 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" };
  }
}
```

```ts title.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("the display title is \"Close ticket\"", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "close_ticket");
  expect(tool?.title).toBe("Close ticket");
});

test("the name is still close_ticket", async ({ mcp }) => {
  expect(await mcp.tools.list()).toContainTool("close_ticket");
});
```

**Hint:**
Look at how the lesson gave `search_tickets` its display name.

**Solution:**
`title` goes out in `tools/list` next to the name, for clients to show. The `name` stays `close_ticket`, so nothing that calls the tool has to change.
