@Tool
@Tool declares an MCP tool: a function an AI model can decide to call. FrontMCP checks every call against the tool's input schema before your code runs.
@Tool(options)
class MyTool extends ToolContext {
async execute(input) { /* ... */ }
}
Reference
@Tool(options)
Apply @Tool to a class that extends ToolContext, and list the class in an app's tools array.
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "get_weather",
description: "Get current weather for a city",
inputSchema: {
city: z.string().describe("City name"),
units: z.enum(["celsius", "fahrenheit"]).default("celsius"),
},
})
class GetWeatherTool extends ToolContext {
async execute(input: { city: string; units: "celsius" | "fahrenheit" }) {
const weather = await fetchWeather(input.city, input.units);
return { temperature: weather.temp, conditions: weather.conditions };
}
}Options
Required:
| Option | Type | Description |
|---|---|---|
name | string | What the model calls. Unique within the server. Prefer verb_noun in snake_case. |
inputSchema | object of Zod types | One Zod type per argument, like { city: z.string() }. Becomes the JSON Schema clients see in tools/list. |
Optional:
| Option | Type | Description |
|---|---|---|
description | string | What the tool does, what it returns, and when to use it. Always set it; it's the model's only documentation. |
title | string | A display name for people, like "Search tickets". Sent in tools/list only when set. |
outputSchema | Zod schema, or one of "string", "number", "boolean", "date", "image", "audio", "resource", "resource_link" | The shape of the result. A Zod schema is advertised in tools/list, and every result is checked against it: fields it doesn't declare are dropped, and a result that doesn't match fails with INVALID_OUTPUT (see Shaping Tool Results). |
annotations | ToolAnnotations | Hints for clients: title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint. See describing side effects. |
examples | ToolExample[] | Sample calls: description, input and optionally output. |
tags | string[] | Labels for grouping and filtering. |
visibility | "public" | "hidden" | "internal" | hidden leaves the tool out of tools/list but it can still be called by name. internal keeps it for in-process callers only. |
rateLimit | RateLimitConfig | Per-tool rate limit: maxRequests, windowMs, partitionBy. See Guard options. |
concurrency | ConcurrencyConfig | Cap on simultaneous executions: maxConcurrent, optional queue. See Guard options. |
timeout | TimeoutConfig | Execution timeout: executeMs. See Guard options. |
authProviders | ToolAuthProviderRef[] | Credentials the tool needs, such as { name: "github", scopes: ["repo"] }. Required by default. See Progressive auth. |
availableWhen | EntryAvailability | Only offer the tool on certain platforms, runtimes or deployments, or to some callers: surface lists who may call it, like ["mcp"] for clients only, or ["agent"] for agents only. Since 1.8.4, surface holds for agents and jobs too. See Environment awareness. |
execution | { taskSupport } | Whether the tool can run as a background task: "required", "optional" or "forbidden". See Background tasks. |
ui | ToolUIConfig | A widget to render the result in clients that support it. With ui, outputSchema still drops the fields it doesn't declare (Fields the schema doesn't declare). |
Plugins add options of their own, which only take effect where the plugin is installed: cache (Cache), approval (Approval), featureFlag (Feature flags) and codecall (CodeCall). A server whose tool sets approval or featureFlag, with no plugin that enforces it, doesn't start: see Unenforced metadata.
execute(input)
FrontMCP calls execute() once the arguments pass validation. Inside it, this is a ToolContext: Context classes lists everything on it.
input: the validated arguments. Its TypeScript type must matchinputSchema.- Returns anything. Objects arrive as the result's
structuredContent. Plain values, like strings and numbers, arrive as{ "value": ... }, unlessoutputSchemais"string","number"or"boolean": then the text block is the value itself. The same data is also sent as text incontent, for clients that don't read structured output.
Inside execute(), this is the ToolContext: this.get() for providers, this.fetch() for outbound HTTP, this.notify() and this.progress() for status, this.elicit() to ask the user for input, and this.fail() to return an error.
Caveats
- The class must extend
ToolContext. - The type of
execute()'s parameter must matchinputSchemaexactly, and with anoutputSchema, its return type must match that.@Toolchecks all three when TypeScript compiles, and reports a mismatch on the decorator line (see Troubleshooting). - Arguments that aren't in
inputSchemaare dropped beforeexecute()runs. - If
execute()throws a plainError, clients see the message and stack in development, and a generic "Internal FrontMCP error" with an error ID in production. UsePublicMcpErrorfor messages the model should read. - The class can declare hooks that run for this tool's calls only: instance methods from
Did("createToolCallContext")on, and, since 1.9.4,staticmethods on any stage, likeparseInput. An instance method on an earlier stage stops the server from starting.
Usage
Validating input
Put every constraint in the schema. The model sees it in tools/list, and FrontMCP rejects calls that break it before execute() runs, with an isError result that lists each problem.
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "create_ticket",
description: "Open a new support ticket",
inputSchema: {
title: z.string().min(5).max(120).describe("One-line summary of the problem"),
priority: z.enum(["low", "normal", "high"]).default("normal"),
email: z.string().email().optional().describe("Where to send updates"),
},
})
export class CreateTicket extends ToolContext {
async execute(input: { title: string; priority: "low" | "normal" | "high"; email?: string }) {
return { id: "T-4", ...input };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Returning results
Return a plain value, an object, or an object that matches an outputSchema. Clients get objects as structuredContent, and plain values wrapped as { "value": ... }. Both also arrive as text. To send a plain value as it is, declare it with a primitive outputSchema.
Return values
Example 1 of 4
Plain value
A number, string or boolean is wrapped as { "value": ... }.
import { Tool, ToolContext } from "@frontmcp/sdk";
@Tool({ name: "count_open", description: "How many tickets are open", inputSchema: {} })
export class CountOpen extends ToolContext {
async execute() {
return 12;
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Returning an error the model can read
Call this.fail() with a PublicMcpError. The call ends, and the client gets an isError result with your message, in development and in production.
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "get_ticket",
description: "Get one support ticket by id",
inputSchema: { id: z.string() },
})
export class GetTicket extends ToolContext {
async execute({ id }: { id: string }) {
if (id !== "T-1") this.fail(new PublicMcpError(`There's no ticket ${id}.`));
return { id, title: "Cannot log in" };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Describing side effects with annotations
Annotations tell clients how a tool behaves, so they can decide how carefully to confirm a call with the user. They're hints: clients may rely on them, but FrontMCP doesn't enforce them.
| Annotation | Meaning when true |
|---|---|
readOnlyHint | The tool doesn't change anything. |
destructiveHint | The tool may delete or overwrite things. Only meaningful when it isn't read-only. |
idempotentHint | Calling it again with the same arguments has no further effect. |
openWorldHint | The tool reaches outside systems, like the web, rather than a closed set of data. |
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "delete_ticket",
description: "Permanently delete a ticket",
inputSchema: { id: z.string() },
annotations: { title: "Delete ticket", destructiveHint: true, idempotentHint: true },
})
export class DeleteTicket extends ToolContext {
async execute({ id }: { id: string }) {
return { deleted: id };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Using a provider
Get shared services with this.get(). Register the provider in the same app's providers array.
import { Tool, ToolContext } from "@frontmcp/sdk";
import { Directory } from "./directory";
@Tool({ name: "whoami", description: "Which support team is on duty", inputSchema: {} })
export class WhoAmI extends ToolContext {
async execute() {
return this.get(Directory).onDuty();
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Writing a tool as a function
For a small tool that doesn't need context, the function form is shorter. It takes the same options.
import { tool, z } from "@frontmcp/sdk";
export const shout = tool({
name: "shout",
description: "Uppercase some text",
inputSchema: { text: z.string() },
})(async ({ text }) => text.toUpperCase());Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Troubleshooting
My tool doesn't show up in tools/list
Check, in order:
- The class is in its app's
toolsarray, and the app is in@FrontMcp({ apps }). tsconfig.jsonsetsexperimentalDecoratorsandemitDecoratorMetadatatotrue.import "reflect-metadata"is the first line of your entry file.- The tool doesn't set
visibility: "hidden"or"internal", and itsavailableWhenmatches the environment.
TypeScript says Unable to resolve signature of class decorator
@Tool checks the class against its options, and error TS1238 on the decorator line means a check failed. The rest of the message says which one:
'execute() parameter error': "Parameter type does not match the input schema.":execute()'s parameter type disagrees withinputSchema."Parameter type is too wide"means it allows more than the schema, likelimit?: numberfor a field with.default().'execute() return type error': with anoutputSchema,execute()returns something that doesn't match it, orany.'Tool class error': "Class must extend ToolContext": the class doesn't extendToolContext.
The message also shows expected_input_type or expected_output_type, the type to use:
// 🚩 inputSchema says string, execute says number
@Tool({ name: "get_ticket", inputSchema: { id: z.string() } })
class GetTicket extends ToolContext {
async execute(input: { id: number }) { /* ... */ }
}
// ✅ The types match
@Tool({ name: "get_ticket", inputSchema: { id: z.string() } })
class GetTicket extends ToolContext {
async execute(input: { id: string }) { /* ... */ }
}
The client sees "Internal FrontMCP error" instead of my message
In production, FrontMCP hides the message of any error that isn't a PublicMcpError, like a plain Error thrown from execute(), so internals don't leak. The client gets an error ID, and FrontMCP logs the same ID with the original message and stack. If the model should see the message, fail with this.fail(new PublicMcpError("...")) instead.
Calls fail with INVALID_OUTPUT
The result didn't match outputSchema, so nothing from it was sent. In development the text names the first field that didn't match; in production it's only "Output validation failed. Please contact support." A NaN or Infinity in a number field fails the same way, with or without an outputSchema, because JSON can't carry it.
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "ticket_stats",
description: "Count open and closed tickets",
inputSchema: {},
outputSchema: z.object({ open: z.number(), closed: z.number() }),
})
export class TicketStats extends ToolContext {
async execute() {
const counts: Record<string, number> = { open: 12, pending: 3 }; // no "closed" today
return { open: counts.open, closed: counts.closed };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Fix the result, or change outputSchema if the result is right. Here, counts.closed ?? 0 would do.
Calls fail with "Invalid tool input"
The arguments didn't match inputSchema, so execute() never ran. The result text lists each problem with the field's path. If a model keeps sending bad arguments, the schema probably isn't telling it enough: add .describe() notes, and prefer z.enum() over free text where you can.
Calls fail with error -32001 and an authUrl
The tool lists a required entry in authProviders, and the user hasn't connected that credential yet. execute() doesn't run. Send the user to authUrl to connect it, or mark the provider required: false if the tool can work without it.