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

get-weather.tool.ts
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 };
  }
}

See more examples below.

Options

Required:

OptionTypeDescription
namestringWhat the model calls. Unique within the server. Prefer verb_noun in snake_case.
inputSchemaobject of Zod typesOne Zod type per argument, like { city: z.string() }. Becomes the JSON Schema clients see in tools/list.

Optional:

OptionTypeDescription
descriptionstringWhat the tool does, what it returns, and when to use it. Always set it; it's the model's only documentation.
titlestringA display name for people, like "Search tickets". Sent in tools/list only when set.
outputSchemaZod 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).
annotationsToolAnnotationsHints for clients: title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint. See describing side effects.
examplesToolExample[]Sample calls: description, input and optionally output.
tagsstring[]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.
rateLimitRateLimitConfigPer-tool rate limit: maxRequests, windowMs, partitionBy. See Guard options.
concurrencyConcurrencyConfigCap on simultaneous executions: maxConcurrent, optional queue. See Guard options.
timeoutTimeoutConfigExecution timeout: executeMs. See Guard options.
authProvidersToolAuthProviderRef[]Credentials the tool needs, such as { name: "github", scopes: ["repo"] }. Required by default. See Progressive auth.
availableWhenEntryAvailabilityOnly 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.
uiToolUIConfigA 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 match inputSchema.
  • Returns anything. Objects arrive as the result's structuredContent. Plain values, like strings and numbers, arrive as { "value": ... }, unless outputSchema is "string", "number" or "boolean": then the text block is the value itself. The same data is also sent as text in content, 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 match inputSchema exactly, and with an outputSchema, its return type must match that. @Tool checks all three when TypeScript compiles, and reports a mismatch on the decorator line (see Troubleshooting).
  • Arguments that aren't in inputSchema are dropped before execute() runs.
  • If execute() throws a plain Error, clients see the message and stack in development, and a generic "Internal FrontMCP error" with an error ID in production. Use PublicMcpError for 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, static methods on any stage, like parseInput. 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.

Open
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": ... }.

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

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

AnnotationMeaning when true
readOnlyHintThe tool doesn't change anything.
destructiveHintThe tool may delete or overwrite things. Only meaningful when it isn't read-only.
idempotentHintCalling it again with the same arguments has no further effect.
openWorldHintThe tool reaches outside systems, like the web, rather than a closed set of data.
Open
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.

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

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

  1. The class is in its app's tools array, and the app is in @FrontMcp({ apps }).
  2. tsconfig.json sets experimentalDecorators and emitDecoratorMetadata to true.
  3. import "reflect-metadata" is the first line of your entry file.
  4. The tool doesn't set visibility: "hidden" or "internal", and its availableWhen matches 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 with inputSchema. "Parameter type is too wide" means it allows more than the schema, like limit?: number for a field with .default().
  • 'execute() return type error': with an outputSchema, execute() returns something that doesn't match it, or any.
  • 'Tool class error': "Class must extend ToolContext": the class doesn't extend ToolContext.

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.

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