Rules of FrontMCP
These rules come from how FrontMCP and the MCP protocol work. Following them keeps your tools predictable for models and safe in production.
Describe tools for the model
A model decides whether and how to call a tool from its name, description and schema alone. Give every tool a specific verb_noun name and a description that says what it does, what it returns and when to use it.
// 🚩 The model can't tell what this looks up
@Tool({ name: "lookup", inputSchema: { q: z.string() } })
// ✅ Says what, returns what, and in what format
@Tool({
name: "get_ticket",
description: "Get one support ticket by its id. Returns the title and status.",
inputSchema: { id: z.string().describe("Ticket id, like T-1") },
})
Read more in Your First Tool.
Put constraints in the schema
The model can read your schema but not your code, and FrontMCP enforces the schema before execute() runs. A limit written as an if statement is invisible until the model hits it.
// 🚩 The model only finds out after calling
async execute({ query }) {
if (query.length < 3) throw new Error("query too short");
}
// ✅ The model sees minLength: 3 in tools/list
inputSchema: { query: z.string().min(3) }
Let the schema type the handler
The type of execute()'s parameter must match inputSchema. FrontMCP checks this at compile time. Write the schema first, then type the parameter to match.
Fail with errors the model can read
Use this.fail(new PublicMcpError("...")) for failures the model should know about. A plain Error is hidden in production and becomes "Internal FrontMCP error" with an error ID.
// 🚩 In production the model sees "Internal FrontMCP error"
throw new Error("Ticket not found");
// ✅ The model sees your message and can recover
this.fail(new PublicMcpError("There's no ticket T-9. Search by title with search_tickets."));
Declare side effects
Set readOnlyHint, destructiveHint, idempotentHint and openWorldHint so clients can decide how carefully to confirm a call. Annotations are hints, not enforcement: a read-only hint on a tool that writes is a lie clients may act on.
Keep a tool idempotent until it asks for input MCP 2026-07-28
Under MCP 2026-07-28 there's no session to pause. When a tool calls this.elicit() to ask the user something, the client re-sends the whole request with the answer, and execute() runs again from the top. Anything before this.elicit() runs once per round trip.
// 🚩 The email is sent twice: once before asking, once on the replay
async execute({ id }) {
await this.get(Mailer).send(`Closing ${id}`);
const answer = await this.elicit("Close this ticket?", z.object({ confirmed: z.boolean() }));
// ...
}
// ✅ Ask first, then act
async execute({ id }) {
const answer = await this.elicit("Close this ticket?", z.object({ confirmed: z.boolean() }));
if (answer.status === "accept" && answer.content.confirmed) {
await this.get(Mailer).send(`Closing ${id}`);
}
}
Load reflect-metadata first
FrontMCP's decorators store their configuration with reflect-metadata. Import it once, as the first line of your entry file, and set experimentalDecorators and emitDecoratorMetadata in tsconfig.json. npx frontmcp init does the tsconfig part for you.