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/calland yourexecute() - 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:
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";
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
A user asks, "What's the status of ticket T-2?" All the model has to go on is:
- A name that doesn't say what it looks up. Tickets? Users? Orders?
- No description. Nothing says what the tool returns or when to use it.
- An input called
q. Nothing says it expects an id likeT-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:
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";
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Every tool has four parts:
nameis what the model calls. Use a verb and a noun insnake_case, likeget_ticket, and keep it unique within your server.descriptiontells 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.inputSchemadeclares each argument with Zod. Constraints like.regex()become part of the schema the model sees, and.describe()adds a note to the field.- The class extends
ToolContextand implementsexecute(). It receives the validated arguments, andToolContextgives it helpers likethis.get()for providers.
What happens when a tool is called
When the model decides to use your tool, FrontMCP stands between the call and your code:
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:
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";
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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 diveWhat does the model actually receive?Show detailsHide details
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:
{
"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:
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;
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
Recap
- A tool is a function the model can choose to call. The model sees only its name, description and input schema.
- A
@Toolhas aname, adescription, aninputSchema, and a class that extendsToolContextwith anexecute()method. - FrontMCP validates arguments before
execute()runs. Invalid calls come back asisErrorresults 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 1 of 4
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.
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())) };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.