Your First Tool

Beginner

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:

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

  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:

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

  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.

What happens when a tool is called

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

What happens when a model calls your tool

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:

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

Open
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 @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 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.

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