# Your First Tool

> What a tool is, how a model decides to call it, and what FrontMCP does between the call and your code.

Source: https://frontmcp.dev/learn/your-first-tool

A **tool** is a function an AI model can choose to call. The model never sees your code. It sees the tool's name, its description and its input schema, and decides from those alone whether to call it and with what. Most of writing a good tool is writing those three things well.

**You will learn**
- What a tool is, and who decides to call it
- The four parts of a `@Tool`
- What FrontMCP does between `tools/call` and your `execute()`
- How to return results, and errors the model can act on

## A tool the model can't use

This tool works. Given a ticket id, it returns the ticket. But open the **Capabilities** tab and look at it the way a model does:

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

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@Tool({ name: "lookup", inputSchema: { q: z.string() } })
export class Lookup extends ToolContext {
  async execute({ q }: { q: string }) {
    return tickets.find((t) => t.id === q) ?? "not found";
  }
}
```

A user asks, "What's the status of ticket T-2?" All the model has to go on is:

1. **A name that doesn't say what it looks up.** Tickets? Users? Orders?
2. **No description.** Nothing says what the tool returns or when to use it.
3. **An input called `q`.** Nothing says it expects an id like `T-2`, rather than a title or a search phrase.

The model might skip the tool, or call it with `"ticket T-2"` and get `"not found"`. The code isn't the problem. The description of it is.

## Declaring a tool

Here is the same tool, written for the model:

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

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id. Returns the ticket's title and status.",
  inputSchema: {
    id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"),
  },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return tickets.find((t) => t.id === id) ?? "not found";
  }
}
```

Every tool has four parts:

1. **`name`** is what the model calls. Use a verb and a noun in `snake_case`, like `get_ticket`, and keep it unique within your server.
2. **`description`** tells the model what the tool does, what it returns, and when to use it. Write it for a reader who has never seen your code, because that's exactly who reads it.
3. **`inputSchema`** declares each argument with Zod. Constraints like `.regex()` become part of the schema the model sees, and `.describe()` adds a note to the field.
4. **The class** extends `ToolContext` and implements `execute()`. It receives the validated arguments, and `ToolContext` gives it helpers like `this.get()` for [providers](https://frontmcp.dev/learn#sharing-a-service-with-a-provider).

> **Note**
`@Tool` also checks your types when TypeScript compiles. Type `execute()`'s parameter as `{ id: number }` against `z.string()`, or leave out `extends ToolContext`, and the decorator line gets an error that starts `Unable to resolve signature of class decorator`. The rest of the message names the problem, like `Parameter type does not match the input schema.` The Playground runs your code without type-checking, so you'll see these errors in your editor, not here.

> **Note**
For small tools that don't need providers or other context, FrontMCP also has a function form: `tool({ name, description, inputSchema })(async (input) => ...)`. This course uses classes, because most real tools grow into needing context.

## What happens when a tool is called

When the model decides to use your tool, FrontMCP stands between the call and your code:

*[Illustration: Seven steps across three lanes. The client sends tools/call with the tool's name and the model's arguments. FrontMCP finds the tool by name, then checks the arguments against inputSchema and drops unknown fields; if they don't match, the client gets an isError result, INVALID_INPUT, listing each problem, and execute never runs. Your code's execute runs with the validated arguments. FrontMCP checks the result against outputSchema if the tool has one; a mismatch returns INVALID_OUTPUT. FrontMCP wraps the result as structuredContent plus a text block, and the client hands it to the model.]*
The important step is the third one: **if the arguments don't match `inputSchema`, `execute()` never runs.** This call sent `"ticket two"` as the id, which doesn't match the pattern:

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

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id. Returns the ticket's title and status.",
  inputSchema: {
    id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"),
  },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return tickets.find((t) => t.id === id) ?? "not found";
  }
}
```

The error comes back as a normal result, not a protocol failure, so the model can read it and try again with `T-2`. Change the id in the form and call it yourself.

**Deep dive: What does the model actually receive?**
FrontMCP converts your Zod schema to JSON Schema for `tools/list`. Open the Wire tab above and expand `tools/list` to see it. For `get_ticket` it's:

```json
{
  "name": "get_ticket",
  "description": "Get one support ticket by its id. Returns the ticket's title and status.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "id": { "type": "string", "pattern": "^T-\\d+$", "description": "Ticket id, like T-1" }
    },
    "required": ["id"]
  }
}
```

(The real response also carries a `$schema` field.) That JSON is the whole contract. If a client sends fields that aren't in it, FrontMCP drops them before `execute()` runs, so your code only ever sees the fields you declared.

## Returning results and errors

Whatever `execute()` returns becomes the result. Objects arrive as `structuredContent`. Plain values, like strings and numbers, are wrapped as `{ "value": ... }`. FrontMCP also sends the same data as text, for clients that only read text.

Returning `"not found"` works, but the model can't tell it apart from a real answer. When a call can't succeed, fail with a message the model should read:

```ts get-ticket.tool.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id. Returns the ticket's title and status.",
  inputSchema: {
    id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"),
  },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}. Search by title with search_tickets.`));
    return ticket;
  }
}
```

`this.fail()` stops `execute()` and sends the error as a result with `isError: true`. `PublicMcpError` marks the message as safe to show, so it reaches the model word for word.

> **Pitfall: Raw errors are hidden in production**
If `execute()` throws a plain `Error`, what the client sees depends on the environment. In development, FrontMCP includes the message and stack trace to help you debug, which is what the Playground shows. In production (`NODE_ENV=production`) it replaces them with "Internal FrontMCP error. Please contact support with error ID: …", so database errors and secrets don't leak to the model. Use `PublicMcpError` for anything the model should read.

## Recap

- A tool is a function the model can choose to call. The model sees only its name, description and input schema.
- A `@Tool` has a `name`, a `description`, an `inputSchema`, and a class that extends `ToolContext` with an `execute()` method.
- FrontMCP validates arguments before `execute()` runs. Invalid calls come back as `isError` results that list what was wrong.
- Return objects or plain values; they arrive as `structuredContent`.
- Use `this.fail(new PublicMcpError(...))` for errors the model should read. Raw errors are hidden in production.

## Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press **Check**. The checks are ordinary `@frontmcp/testing` tests, run against a fresh copy of your server.

### Challenge: Rename a vague tool
This tool searches tickets, but its name and input don't say so. Rename it to `search_tickets`, add a description, and rename `x` to `query` with a `.describe()` note. Check the Capabilities tab when you're done.

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

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
  { id: "T-3", title: "Login link expired", status: "open" },
];

@Tool({ name: "find", inputSchema: { x: z.string() } })
export class Find extends ToolContext {
  async execute({ x }: { x: string }) {
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(x.toLowerCase())) };
  }
}
```

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

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
  { id: "T-3", title: "Login link expired", status: "open" },
];

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title. Returns matching tickets with their status.",
  inputSchema: { query: z.string().describe("Words to look for in ticket titles") },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) };
  }
}
```

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

test("the tool is called search_tickets", async ({ mcp }) => {
  expect(await mcp.tools.list()).toContainTool("search_tickets");
});

test("it has a description", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "search_tickets");
  expect(tool?.description?.length ?? 0).toBeGreaterThan(20);
});

test("its input is a described `query`", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "search_tickets");
  expect(tool?.inputSchema.required).toEqual(["query"]);
  expect(tool?.inputSchema.properties?.query).toHaveProperty("description");
});

test("searching for \"log\" finds both login tickets", async ({ mcp }) => {
  const result = await mcp.tools.call("search_tickets", { query: "log" });
  expect(result).toBeSuccessful();
  expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-1", "T-3"]);
});
```

**Hint:**
Remember to rename the field in both places: `inputSchema` and the `execute()` parameter.

**Solution:**
The name says what it does, the description says what comes back, and `query` explains itself in the schema the model reads. The Playground above now shows the solution; press **Check** to see it pass.

### Challenge: Add an optional filter
Let the model narrow results to open or closed tickets. Add a `status` argument that can be `"open"` or `"closed"`, and leave it optional so the model can omit it.

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

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
  { id: "T-3", title: "Login link expired", status: "closed" },
];

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title.",
  inputSchema: { query: z.string() },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) };
  }
}
```

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

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
  { id: "T-3", title: "Login link expired", status: "closed" },
];

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title. Optionally only open or only closed tickets.",
  inputSchema: {
    query: z.string(),
    status: z.enum(["open", "closed"]).optional().describe("Leave out to include both"),
  },
})
export class SearchTickets extends ToolContext {
  async execute({ query, status }: { query: string; status?: "open" | "closed" }) {
    const q = query.toLowerCase();
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(q) && (!status || t.status === status)) };
  }
}
```

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

const ids = (result: { json(): any }) => result.json().tickets.map((t: { id: string }) => t.id);

test("`status` is in the schema, as \"open\" or \"closed\"", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "search_tickets");
  expect(tool?.inputSchema.properties?.status?.enum).toEqual(["open", "closed"]);
});

test("`status` is optional", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "search_tickets");
  expect(tool?.inputSchema.required ?? []).not.toContain("status");
  expect(ids(await mcp.tools.call("search_tickets", { query: "log" }))).toEqual(["T-1", "T-3"]);
});

test("status \"open\" leaves out closed tickets", async ({ mcp }) => {
  expect(ids(await mcp.tools.call("search_tickets", { query: "log", status: "open" }))).toEqual(["T-1"]);
});

test("status \"closed\" leaves out open tickets", async ({ mcp }) => {
  expect(ids(await mcp.tools.call("search_tickets", { query: "log", status: "closed" }))).toEqual(["T-3"]);
});
```

**Hint:**
`z.enum(["open", "closed"]).optional()` makes a field the model may leave out. Its type in `execute()` is `"open" | "closed" | undefined`.

**Solution:**
`z.enum()` puts the allowed values in the JSON Schema, so the model knows exactly what it may send, and `.optional()` keeps `status` out of `required`. In `execute()`, a missing `status` means "don't filter".

### Challenge: Reject queries that are too short
A one-letter query matches almost everything. Make FrontMCP reject any `query` shorter than 3 characters, without adding a check inside `execute()`. Then call the tool with `"a"` to see the error.

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

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title.",
  inputSchema: { query: z.string() },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) };
  }
}
```

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

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title.",
  inputSchema: { query: z.string().min(3).describe("At least 3 characters") },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) };
  }
}
```

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

test("a 1-letter query is rejected", async ({ mcp }) => {
  const result = await mcp.tools.call("search_tickets", { query: "a" });
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("INVALID_INPUT");
});

test("the rule is in the schema the model reads", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "search_tickets");
  expect(tool?.inputSchema.properties?.query?.minLength).toBe(3);
});

test("a 3-letter query still works", async ({ mcp }) => {
  const result = await mcp.tools.call("search_tickets", { query: "log" });
  expect(result).toBeSuccessful();
  expect(result.json().tickets).toHaveLength(1);
});
```

**Solution:**
Put the rule in the schema with `.min(3)`. FrontMCP rejects the call with `INVALID_INPUT` before `execute()` runs, and because the rule is in `tools/list` as `minLength`, a model can follow it before it ever calls the tool. A check inside `execute()` would only tell it afterwards.

### Challenge: Tell clients the tool is read-only
Searching never changes anything. Add the annotation that tells clients so, and find where it shows up in the Capabilities tab.

```ts search.tool.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; title: string }[], query };
  }
}
```

```ts search.tool.ts solution
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) },
  annotations: { readOnlyHint: true },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: [] as { id: string; title: string }[], query };
  }
}
```

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

test("tools/list marks search_tickets read-only", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "search_tickets");
  expect(tool?.annotations).toMatchObject({ readOnlyHint: true });
});

test("it doesn't claim to be destructive", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "search_tickets");
  expect(tool?.annotations?.destructiveHint).not.toBe(true);
});
```

**Hint:**
Annotations go in `@Tool({ annotations: { ... } })`. MCP defines `readOnlyHint`, `destructiveHint`, `idempotentHint` and `openWorldHint`.

**Solution:**
`annotations: { readOnlyHint: true }` goes out with the tool in `tools/list`, and the card now shows a **read-only** chip. Hints are hints: clients may use them to decide how carefully to confirm a call, but they don't enforce anything.
