# FrontMCP > FrontMCP is the TypeScript framework for MCP servers: typed tools with Zod, dependency injection, auth, testing, agents and jobs, and builds for Node, Vercel, AWS Lambda and Cloudflare Workers. The full text of the MDX documentation and blog posts on https://frontmcp.dev, as Markdown, in reading order: Learn, Reference, Examples, Compare, then the blog. It documents FrontMCP 1.10.11. The index of pages is at https://frontmcp.dev/llms.txt. This bulk file excludes the homepage and standalone indexes; read /index.md, /versions.md, /releases.md, /blog.md and /playground.md separately. Prefer a page or docs MCP section read when context is limited. Historical articles retain their own scope and are not automatically verified against the snapshot release. --- # Quick Start: build an MCP server in TypeScript > Build an MCP server in TypeScript with FrontMCP. The 80% you'll use every day, with every example running in your browser. Source: https://frontmcp.dev/learn [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Welcome to FrontMCP. This page covers the concepts you'll use most days when building an MCP server. Every example runs the real FrontMCP SDK in your browser: change the code and the server restarts with your edits. **You will learn** - How to define a tool, and what an AI model sees when it looks at one - How schemas check input before your code runs - How to return structured results - How to expose data with resources and reusable prompts - How apps and a server fit together - How to share a service between tools with a provider ## The quick path Six steps take you from an empty folder to an MCP client calling your own tool. You need Node.js 24 or later. The sections after this list teach each piece in the browser first. 1. **Create the project.** Run `npx frontmcp create my-mcp-server`, then `cd my-mcp-server` and `npm install`: `create` writes the files but installs nothing ([Installation](https://frontmcp.dev/learn/installation#creating-a-new-server), [`frontmcp create`](https://frontmcp.dev/reference/cli#frontmcp-create)). 2. **Run it.** `npm run dev` runs `frontmcp dev`, which restarts the server when you save a file. The server answers at `http://localhost:3000/mcp` ([`frontmcp dev`](https://frontmcp.dev/reference/cli#frontmcp-dev)). 3. **Write a tool.** The project starts with one, `add`, in `src/tools/add.tool.ts`: a class with `@Tool` that extends `ToolContext`. Add yours beside it and list it in its app's `tools` ([Creating a tool](https://frontmcp.dev/learn#creating-a-tool), [Your First Tool](https://frontmcp.dev/learn/your-first-tool)). 4. **Inspect and test it.** With the server running, `npm run inspect` opens the MCP Inspector, where you can connect and call your tool. `npm test` runs the project's end-to-end tests with `frontmcp test` ([Running and testing your server](https://frontmcp.dev/learn/installation#running-and-testing-your-server), [Testing Your Server](https://frontmcp.dev/learn/testing-your-server)). 5. **Connect a client.** Add `http://localhost:3000/mcp` to the MCP servers in your client's settings, like Claude Code or Cursor. The field that marks an entry as a URL differs between clients ([Connecting a client](https://frontmcp.dev/learn/installation#connecting-a-client), [Connecting to `frontmcp dev`](https://frontmcp.dev/reference/deployment/mcp-clients#connecting-to-frontmcp-dev)). 6. **Go further.** Learn tools properly in [Describing Capabilities](https://frontmcp.dev/learn/describing-capabilities), then decide who may call them in [Securing a Server](https://frontmcp.dev/learn/securing-a-server) and put the server online in [Deploying Your Server](https://frontmcp.dev/learn/deploying-your-server). ## Creating a tool An MCP server gives AI clients, like Claude or Cursor, a set of **capabilities**. The most common kind is a **tool**: a function the model can decide to call when it needs to act or look something up. In FrontMCP, a tool is a class decorated with `@Tool` that extends `ToolContext` and implements `execute()`: Initial call configuration: ```js { tool: "add", arguments: { a: 2, b: 3 } } ``` ```ts add.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "add", description: "Adds two numbers together", inputSchema: { a: z.number(), b: z.number() }, }) export class AddTool extends ToolContext { async execute(input: { a: number; b: number }) { return input.a + input.b; } } ``` The panel on the right is an MCP client talking to your server: - **Capabilities** shows what `tools/list` returns. That card is everything the model knows about your tool, so the name and description are the model's only documentation. - **Call** sends `tools/call`. The page already sent `{ "a": 2, "b": 3 }`, and the result came back as `{ "value": 5 }`. - **Wire** shows each JSON-RPC request and response exactly as a client would send and receive them. Try changing `description` and watch the Capabilities card change. ## Checking input with schemas `inputSchema` is written with [Zod](https://zod.dev), re-exported from `@frontmcp/sdk` as `z`. FrontMCP turns it into the JSON Schema the model reads, and checks every call against it **before** `execute()` runs. Initial call configuration: ```js { tool: "greet", arguments: { name: "Ada", style: "formal" } } ``` ```ts greet.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "greet", description: "Greets someone by name", inputSchema: { name: z.string().min(1).describe("Who to greet"), style: z.enum(["casual", "formal"]).default("casual"), }, }) export class GreetTool extends ToolContext { async execute({ name, style }: { name: string; style: "casual" | "formal" }) { return style === "formal" ? `Good day, ${name}.` : `Hi ${name}!`; } } ``` Clear the **name** field and call it again. FrontMCP rejects the call with `Invalid tool input` and a list of what's wrong, and your `execute()` never runs. The model gets that message back and can correct itself. `.describe()` text shows up next to the field in the Capabilities tab. Models use it the same way you'd use a parameter comment. ## Returning structured results Whatever `execute()` returns is sent back to the client. Plain values, like the number and string above, arrive wrapped as `{ "value": ... }`. When the result has a shape, declare it with `outputSchema`, so clients know what fields to expect before they call: Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts search-tickets.tool.ts 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" }, ] as const; @Tool({ name: "search_tickets", description: "Search support tickets by words in the title", inputSchema: { query: z.string().min(1) }, outputSchema: z.object({ tickets: z.array(z.object({ id: z.string(), title: z.string(), status: z.enum(["open", "closed"]) })), }), annotations: { readOnlyHint: true }, }) export class SearchTicketsTool extends ToolContext { async execute({ query }: { query: string }) { const q = query.toLowerCase(); return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(q)) }; } } ``` The result arrives as `structuredContent`, and FrontMCP also includes it as text for clients that only read text. The `readOnlyHint` annotation tells clients this tool doesn't change anything. Clients can use hints like this to decide how carefully to confirm a call with the user. ## Exposing data with resources and prompts Tools are for actions the **model** decides to take. MCP has two other kinds of capability: - A **resource** is data the client application can read and attach to a conversation, addressed by a URI. `@ResourceTemplate` covers a whole family of URIs, like `ticket://{id}`. - A **prompt** is a reusable message template the **user** picks, often from a slash menu. Initial call configuration: ```js { resource: "policy://sla" } ``` ```ts help-desk.ts import { Prompt, PromptContext, Resource, ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; @Resource({ name: "sla", uri: "policy://sla", mimeType: "text/plain", description: "How fast we reply" }) export class SlaPolicy extends ResourceContext { async execute(uri: string) { return { contents: [{ uri, mimeType: "text/plain", text: "Reply to every ticket within 4 business hours." }] }; } } @ResourceTemplate({ name: "ticket", uriTemplate: "ticket://{id}", mimeType: "application/json", description: "One ticket by id" }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, params: { id: string }) { const ticket = { id: params.id, title: "Cannot log in", status: "open" }; return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] }; } } @Prompt({ name: "triage", description: "Triage a ticket", arguments: [{ name: "id", description: "Ticket id", required: true }], }) export class TriagePrompt extends PromptContext { async execute(args: Record) { return { messages: [{ role: "user" as const, content: { type: "text" as const, text: `Read ticket://${args.id}, then suggest a priority and a next step.` } }], }; } } ``` Use the **Request** menu in the Call tab to read `ticket://{id}` with any id, or to get the `triage` prompt. ## Grouping capabilities into an app and a server So far the Playground has wrapped your exports in a server for you. In a real project you write that wiring yourself, in two layers: - An **app** (`@App`) groups related tools, resources, prompts and providers. Apps can have their own auth and settings. - The **server** (`@FrontMcp`) is the entry point. It lists your apps and holds server-wide settings such as `info` and `http`. Initial call configuration: ```js { tool: "add", arguments: { a: 40, b: 2 } } ``` ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { CalcApp } from "./calc.app"; @FrontMcp({ info: { name: "Hello MCP", version: "0.1.0" }, apps: [CalcApp], }) export default class Server {} ``` ```ts calc.app.ts import { App } from "@frontmcp/sdk"; import { AddTool } from "./add.tool"; @App({ id: "calc", name: "Calculator", tools: [AddTool] }) export class CalcApp {} ``` ```ts add.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "add", description: "Adds two numbers together", inputSchema: { a: z.number(), b: z.number() }, }) export class AddTool extends ToolContext { async execute(input: { a: number; b: number }) { return input.a + input.b; } } ``` `npx frontmcp create` generates this same structure, with the tool in a `tools/` folder. Open the Wire tab: the `_meta.serverInfo` in each response now reads `Hello MCP`, from `info` in `main.ts`. ## Sharing a service with a provider Tools often need the same thing: a database client, an API wrapper, a cache. Put it in a **provider** (`@Provider`), register it on the app, and get it inside any tool with `this.get()`: Initial call configuration: ```js { tool: "count_open_tickets", arguments: {} } ``` ```ts tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Tool({ name: "count_open_tickets", description: "How many tickets are still open", inputSchema: {} }) export class CountOpenTickets extends ToolContext { async execute() { return this.get(TicketStore).open().length; } } @Tool({ name: "close_ticket", description: "Close a ticket by id", inputSchema: { id: z.string() }, annotations: { destructiveHint: true, idempotentHint: true }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return this.get(TicketStore).close(id); } } ``` ```ts ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; type Ticket = { id: string; title: string; open: boolean }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", open: true }, { id: "T-2", title: "Invoice total is wrong", open: false }, { id: "T-3", title: "Login link expired", open: true }, ]; open() { return this.tickets.filter((t) => t.open); } close(id: string) { const ticket = this.tickets.find((t) => t.id === id); if (ticket) ticket.open = false; return { id, open: false }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket, CountOpenTickets } from "./tools"; import { TicketStore } from "./ticket-store"; @App({ id: "help-desk", name: "Help Desk", providers: [TicketStore], tools: [CountOpenTickets, CloseTicket] }) export class HelpDeskApp {} ``` Call `close_ticket` with `T-1`, then call `count_open_tickets` again: the count drops from 2 to 1. Both tools share one `TicketStore`, because `ProviderScope.GLOBAL` creates one instance for the whole server. `ProviderScope.CONTEXT` would create one per request instead. ## Next steps You now know the pieces you'll use most: tools, schemas, structured results, resources, prompts, apps, servers and providers. - To run a server on your own machine and connect a real client, read [Installation](https://frontmcp.dev/learn/installation). - To learn tools properly, start the [Describing Capabilities](https://frontmcp.dev/learn/describing-capabilities) chapter with [Your First Tool](https://frontmcp.dev/learn/your-first-tool). - To look up every `@Tool` option, see the [`@Tool` reference](https://frontmcp.dev/reference/sdk/tool). - Coming from the official MCP SDK or another framework? See [when FrontMCP is worth it](https://frontmcp.dev/compare/why-frontmcp) and [how it compares](https://frontmcp.dev/compare). --- # Tutorial: Help Desk Server > Build a complete MCP server for a support team one step at a time, with tools, clear errors, annotations, a confirmation, a resource, a prompt, an app and tests. Source: https://frontmcp.dev/learn/tutorial-help-desk [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 In this tutorial you'll build a small MCP server for a help desk with FrontMCP. An AI assistant connected to it can search a support team's tickets, read them, reply to customers and close tickets, and it asks the user before it closes anything. You don't need to know MCP yet. Each idea is introduced when you first need it, and every step runs in your browser. > **Note** > > This tutorial is for people who like to learn by doing. If you'd rather learn each idea on its own, start with [Describing Capabilities](https://frontmcp.dev/learn/describing-capabilities). The two cover the same ground, and each step here links to the lesson that explains its idea in depth. **You will learn** - How to keep data in a provider that every tool shares - How to write tools a model can call, and errors it can act on - How to tell clients which tools change data, and ask the user before an important change - How to offer data as a resource, and a workflow as a prompt - How to wire everything into an app and a server, and test it end to end ## What are you building? Here is the server you'll have at the end. Don't worry about the code yet. Only the two files that tie it together are shown, and you'll write every file yourself. Open the **Capabilities** tab to see what a client sees, then try the **Call** tab: search for `log`, read `T-1`, or call `close_ticket` and answer the question that comes back. ```ts main.ts active import "reflect-metadata"; import { FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true }, }) export default class HelpDeskServer {} ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { DraftReply } from "./draft-reply.prompt"; import { TicketResource } from "./ticket.resource"; import { TicketStore } from "./ticket-store"; import { AddReply, CloseTicket, GetTicket, SearchTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", providers: [TicketStore], tools: [SearchTickets, GetTicket, AddReply, CloseTicket], resources: [TicketResource], prompts: [DraftReply], }) export class HelpDeskApp {} ``` **Hidden fixture/test file (runs with the example):** ```ts tools.ts hidden import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; const ticketId = z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"); function notFound(id: string) { return new PublicMcpError(`There's no ticket ${id}. Use search_tickets to find the right id.`, "TICKET_NOT_FOUND"); } @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Returns each match's id, title and status.", inputSchema: { query: z.string().min(2).describe('Words to look for in ticket titles, like "login"'), }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: this.get(TicketStore).search(query) }; } } @Tool({ name: "get_ticket", description: "Get one ticket by id: the customer, the status, their message and every reply so far.", inputSchema: { id: ticketId }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(notFound(id)); return ticket; } } @Tool({ name: "add_reply", description: "Add a reply to an open ticket. The customer reads it, so write it to them. Returns the updated ticket.", inputSchema: { id: ticketId, text: z.string().min(1).max(2000).describe("The reply, as the customer will read it"), }, annotations: { destructiveHint: false }, }) export class AddReply extends ToolContext { async execute({ id, text }: { id: string; text: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") { this.fail(new PublicMcpError(`${id} is closed. Replies can only go on open tickets.`, "TICKET_CLOSED")); } return store.addReply(id, text); } } @Tool({ name: "close_ticket", description: "Close a ticket whose problem is solved. The customer is told it's resolved, so the user is asked to confirm first.", inputSchema: { id: ticketId }, annotations: { destructiveHint: true, idempotentHint: true }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") return { id, status: "closed", note: `${id} was already closed.` }; const answer = await this.elicit( `Close ${id} "${ticket.title}"? ${ticket.customer} will be told it's resolved.`, z.object({ confirmed: z.boolean().describe("Yes, close this ticket") }), ); if (answer.status !== "accept" || !answer.content?.confirmed) { return { id, status: "open", note: "The user didn't confirm, so the ticket is still open." }; } store.close(id); return { id, status: "closed" }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts ticket.resource.ts hidden import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "A support ticket with the customer's message and every reply", }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new ResourceNotFoundError(uri); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts draft-reply.prompt.ts hidden import { type GetPromptResult, Prompt, PromptContext, PublicMcpError } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Prompt({ name: "draft_reply", description: "Draft a reply to a ticket, for you to review before it's sent", arguments: [ { name: "id", description: "Ticket id, like T-1", required: true }, { name: "tone", description: "friendly (the default) or formal" }, ], }) export class DraftReply extends PromptContext { async execute({ id, tone = "friendly" }: Record): Promise { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`, "TICKET_NOT_FOUND"); return { messages: [ { role: "user", content: { type: "resource", resource: { uri: `tickets://${id}`, mimeType: "application/json", text: JSON.stringify(ticket) } }, }, { role: "user", content: { type: "text", text: `Draft a ${tone} reply to ${ticket.customer} about this ticket. Show me the draft and wait. Only send it with add_reply once I approve it.`, }, }, ], }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts ticket-store.ts hidden import { Provider, ProviderScope } from "@frontmcp/sdk"; export type Ticket = { id: string; title: string; customer: string; status: "open" | "closed"; message: string; replies: string[]; }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", customer: "Ada", status: "open", message: "I reset my password twice and the login page still says it's wrong.", replies: [], }, { id: "T-2", title: "Invoice total is wrong", customer: "Grace", status: "closed", message: "My March invoice charges for 12 seats. We only have 10.", replies: ["You're right, sorry! We've refunded the two extra seats."], }, { id: "T-3", title: "Login link expired", customer: "Linus", status: "open", message: "The sign-in link in your email says it has expired, even when I click it right away.", replies: [], }, ]; search(query: string) { const q = query.toLowerCase(); return this.tickets .filter((t) => t.title.toLowerCase().includes(q)) .map(({ id, title, status }) => ({ id, title, status })); } get(id: string) { return this.tickets.find((t) => t.id === id); } addReply(id: string, text: string) { const ticket = this.get(id); ticket?.replies.push(text); return ticket; } close(id: string) { const ticket = this.get(id); if (ticket) ticket.status = "closed"; return ticket; } } ``` The server offers: - four **tools** the model can call: `search_tickets`, `get_ticket`, `add_reply` and `close_ticket`; - a **resource template**, `tickets://{id}`, so a client app can attach a ticket to the conversation; - a **prompt**, `draft_reply`, that a user picks to start a reply they'll review. By the end you'll also have tests that check all of it. Next, you'll build it from nothing, one step at a time. ## Setup for the tutorial There's nothing to install. Each step has a Playground: FrontMCP running in your browser, with an MCP client attached. Here's how to use one. **The code** is on the left, one tab per file. Press **Edit**, or double-click the code, to change it. The server restarts with your edits a moment after you stop typing, and **Reset** brings back the original. **The client** is on the right: - **Capabilities** shows what the server lists: its tools, resources and prompts. That's everything a model or a client app knows about your server. - **Call** sends a request the way a client would. Pick the request from the menu, fill in the form, and send it. Some Playgrounds send a request as soon as they start, so the result is already there. - **Tests** appears when an example has a test file. The tests run when you open the tab. - **Wire** shows every JSON-RPC message the client and the server exchanged. - **Logs** shows what FrontMCP printed. Each Playground shows the files its step changes. Files that didn't change are still part of the example and still run, but they aren't shown again. And each Playground runs its own server, starting from the same three tickets, so a reply you add in one step isn't there in the next. If you'd rather follow along in your own editor, [Installation](https://frontmcp.dev/learn/installation) sets up a project. The files at the end of step 6 are the ones you'd have. ## Step 1: A ticket store and a search tool A support agent's first question is usually "what's going on with…?", so the assistant needs to find tickets. Before the tool, the tickets need somewhere to live. Put them in a **provider**: a class FrontMCP creates once and hands to any tool that asks for it. `ProviderScope.GLOBAL` means there's a single `TicketStore` for the whole server, so when a tool changes a ticket in step 3, every other tool sees the change. Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Returns each match's id, title and status.", inputSchema: { query: z.string().min(2).describe('Words to look for in ticket titles, like "login"'), }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: this.get(TicketStore).search(query) }; } } ``` ```ts ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; export type Ticket = { id: string; title: string; customer: string; status: "open" | "closed"; message: string; replies: string[]; }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", customer: "Ada", status: "open", message: "I reset my password twice and the login page still says it's wrong.", replies: [], }, { id: "T-2", title: "Invoice total is wrong", customer: "Grace", status: "closed", message: "My March invoice charges for 12 seats. We only have 10.", replies: ["You're right, sorry! We've refunded the two extra seats."], }, { id: "T-3", title: "Login link expired", customer: "Linus", status: "open", message: "The sign-in link in your email says it has expired, even when I click it right away.", replies: [], }, ]; search(query: string) { const q = query.toLowerCase(); return this.tickets .filter((t) => t.title.toLowerCase().includes(q)) .map(({ id, title, status }) => ({ id, title, status })); } } ``` The page searched for `log` and found two tickets. Here's what the two files do: - **`ticket-store.ts`** declares the provider with `@Provider`. The tickets are a plain array. In a real server this class would wrap your database or your help desk's API, and the tools wouldn't change. - **`tools.ts`** declares `search_tickets`. Its `name` is what the model calls, its `description` says what comes back, and its `inputSchema`, written with Zod, becomes the JSON Schema the model reads. `.min(2)` and `.describe()` are part of that schema. - Inside `execute()`, `this.get(TicketStore)` gets the store. - The tool returns an object, `{ tickets: [...] }`, which arrives as `structuredContent`. Each match has only its id, title and status: enough for the model to choose one, and short enough to read quickly. The whole ticket comes from the next tool. You didn't write a server. The Playground found the exported tool and provider and wrapped them in one for you. You'll write that part yourself in step 6. Try searching for `invoice`, and then for `a`. The second call never reaches `execute()`: FrontMCP checks the arguments against the schema first, and `a` is shorter than 2 characters. [Your First Tool](https://frontmcp.dev/learn/your-first-tool) explains what the model sees and what FrontMCP checks. ## Step 2: Read a ticket, and fail clearly Search gives the model ids. Next it needs to read one ticket in full. Add a `get()` method to the store and a `get_ticket` tool. Here's a first version: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-9" } } ``` ```ts tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Returns each match's id, title and status.", inputSchema: { query: z.string().min(2).describe('Words to look for in ticket titles, like "login"'), }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: this.get(TicketStore).search(query) }; } } @Tool({ name: "get_ticket", description: "Get one ticket by id: the customer, the status, their message and every reply so far.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"), }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return this.get(TicketStore).get(id); } } ``` ```ts ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; export type Ticket = { id: string; title: string; customer: string; status: "open" | "closed"; message: string; replies: string[]; }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", customer: "Ada", status: "open", message: "I reset my password twice and the login page still says it's wrong.", replies: [], }, { id: "T-2", title: "Invoice total is wrong", customer: "Grace", status: "closed", message: "My March invoice charges for 12 seats. We only have 10.", replies: ["You're right, sorry! We've refunded the two extra seats."], }, { id: "T-3", title: "Login link expired", customer: "Linus", status: "open", message: "The sign-in link in your email says it has expired, even when I click it right away.", replies: [], }, ]; search(query: string) { const q = query.toLowerCase(); return this.tickets .filter((t) => t.title.toLowerCase().includes(q)) .map(({ id, title, status }) => ({ id, title, status })); } get(id: string) { return this.tickets.find((t) => t.id === id); } } ``` Called with `T-1`, this works. But the page asked for `T-9`, which doesn't exist. `find()` returned `undefined`, and FrontMCP answered with "Flow exited without producing output". That message is about FrontMCP's internals, not about tickets. A model reading it can't tell whether the ticket is missing, the server is broken, or it should try the same call again. When a call can't succeed, fail with a message the model can act on: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-9" } } ``` ```ts tools.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; const ticketId = z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"); function notFound(id: string) { return new PublicMcpError(`There's no ticket ${id}. Use search_tickets to find the right id.`, "TICKET_NOT_FOUND"); } @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Returns each match's id, title and status.", inputSchema: { query: z.string().min(2).describe('Words to look for in ticket titles, like "login"'), }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: this.get(TicketStore).search(query) }; } } @Tool({ name: "get_ticket", description: "Get one ticket by id: the customer, the status, their message and every reply so far.", inputSchema: { id: ticketId }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(notFound(id)); return ticket; } } ``` **Hidden fixture/test file (runs with the example):** ```ts ticket-store.ts hidden import { Provider, ProviderScope } from "@frontmcp/sdk"; export type Ticket = { id: string; title: string; customer: string; status: "open" | "closed"; message: string; replies: string[]; }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", customer: "Ada", status: "open", message: "I reset my password twice and the login page still says it's wrong.", replies: [], }, { id: "T-2", title: "Invoice total is wrong", customer: "Grace", status: "closed", message: "My March invoice charges for 12 seats. We only have 10.", replies: ["You're right, sorry! We've refunded the two extra seats."], }, { id: "T-3", title: "Login link expired", customer: "Linus", status: "open", message: "The sign-in link in your email says it has expired, even when I click it right away.", replies: [], }, ]; search(query: string) { const q = query.toLowerCase(); return this.tickets .filter((t) => t.title.toLowerCase().includes(q)) .map(({ id, title, status }) => ({ id, title, status })); } get(id: string) { return this.tickets.find((t) => t.id === id); } } ``` - `this.fail()` ends the call 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. It says what's wrong and what to do next, so the model can search instead of guessing. - Its second argument, `"TICKET_NOT_FOUND"`, is a code for programs rather than models. It arrives as `_meta.code`, and the Call tab shows it next to the error. You'll check it in a test in step 6. - `ticketId` and `notFound()` are shared because the next two tools need the same id rule and the same error. There are now two layers of checking. The `.regex()` in the schema turns away anything that isn't shaped like `T-1` before your code runs, and `execute()` only has to handle ids that look right but don't exist. Call `get_ticket` with `T-2` to see a whole ticket, reply included. [Your First Tool](https://frontmcp.dev/learn/your-first-tool#returning-results-and-errors) covers results and errors in more detail. ## Step 3: Add a reply, and say which tools change data Nothing has changed any data so far. `add_reply` will: it adds a reply that the customer will read. The store gets an `addReply()` method, and the tool checks the ticket before writing to it: Initial call configuration: ```js { tool: "add_reply", arguments: { id: "T-1", text: "Could you try the reset link we just sent? It works for an hour." } } ``` ```ts tools.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; const ticketId = z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"); function notFound(id: string) { return new PublicMcpError(`There's no ticket ${id}. Use search_tickets to find the right id.`, "TICKET_NOT_FOUND"); } @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Returns each match's id, title and status.", inputSchema: { query: z.string().min(2).describe('Words to look for in ticket titles, like "login"'), }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: this.get(TicketStore).search(query) }; } } @Tool({ name: "get_ticket", description: "Get one ticket by id: the customer, the status, their message and every reply so far.", inputSchema: { id: ticketId }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(notFound(id)); return ticket; } } @Tool({ name: "add_reply", description: "Add a reply to an open ticket. The customer reads it, so write it to them. Returns the updated ticket.", inputSchema: { id: ticketId, text: z.string().min(1).max(2000).describe("The reply, as the customer will read it"), }, annotations: { destructiveHint: false }, }) export class AddReply extends ToolContext { async execute({ id, text }: { id: string; text: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") { this.fail(new PublicMcpError(`${id} is closed. Replies can only go on open tickets.`, "TICKET_CLOSED")); } return store.addReply(id, text); } } ``` ```ts ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; export type Ticket = { id: string; title: string; customer: string; status: "open" | "closed"; message: string; replies: string[]; }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", customer: "Ada", status: "open", message: "I reset my password twice and the login page still says it's wrong.", replies: [], }, { id: "T-2", title: "Invoice total is wrong", customer: "Grace", status: "closed", message: "My March invoice charges for 12 seats. We only have 10.", replies: ["You're right, sorry! We've refunded the two extra seats."], }, { id: "T-3", title: "Login link expired", customer: "Linus", status: "open", message: "The sign-in link in your email says it has expired, even when I click it right away.", replies: [], }, ]; search(query: string) { const q = query.toLowerCase(); return this.tickets .filter((t) => t.title.toLowerCase().includes(q)) .map(({ id, title, status }) => ({ id, title, status })); } get(id: string) { return this.tickets.find((t) => t.id === id); } addReply(id: string, text: string) { const ticket = this.get(id); ticket?.replies.push(text); return ticket; } } ``` - The tool returns the updated ticket, so the model can see that its reply landed. - A reply to a closed ticket fails with its own code, `TICKET_CLOSED`, and a message that says why. Try it with `T-2`. - Now call `get_ticket` with `T-1`. The reply is there, because both tools use the same `TicketStore`. Now open the Capabilities tab. Each tool has **annotations**, hints that tell clients how it behaves: | Tool | Annotation | What it tells clients | | --- | --- | --- | | `search_tickets`, `get_ticket` | `readOnlyHint: true` | It doesn't change anything. The card shows a **read-only** chip. | | `add_reply` | `destructiveHint: false` | It changes data, but only by adding to it. | Without annotations, a client knows nothing about how a tool behaves, and MCP's defaults assume the worst: that any tool may change or delete things. Clients can use hints to decide how carefully to confirm a call with the user. They're only hints, though. FrontMCP sends them in `tools/list` but doesn't enforce them, and a client is free to ignore them. The next step shows how to make sure the user is asked. ## Step 4: Close a ticket once the user confirms Closing a ticket tells the customer their problem is solved. If the model closes the wrong one, a customer who still needs help is told they're done. That's a decision for the person using the assistant, not the model, so `close_ticket` asks them with `this.elicit()`: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts tools.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; const ticketId = z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"); function notFound(id: string) { return new PublicMcpError(`There's no ticket ${id}. Use search_tickets to find the right id.`, "TICKET_NOT_FOUND"); } @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Returns each match's id, title and status.", inputSchema: { query: z.string().min(2).describe('Words to look for in ticket titles, like "login"'), }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: this.get(TicketStore).search(query) }; } } @Tool({ name: "get_ticket", description: "Get one ticket by id: the customer, the status, their message and every reply so far.", inputSchema: { id: ticketId }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(notFound(id)); return ticket; } } @Tool({ name: "add_reply", description: "Add a reply to an open ticket. The customer reads it, so write it to them. Returns the updated ticket.", inputSchema: { id: ticketId, text: z.string().min(1).max(2000).describe("The reply, as the customer will read it"), }, annotations: { destructiveHint: false }, }) export class AddReply extends ToolContext { async execute({ id, text }: { id: string; text: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") { this.fail(new PublicMcpError(`${id} is closed. Replies can only go on open tickets.`, "TICKET_CLOSED")); } return store.addReply(id, text); } } @Tool({ name: "close_ticket", description: "Close a ticket whose problem is solved. The customer is told it's resolved, so the user is asked to confirm first.", inputSchema: { id: ticketId }, annotations: { destructiveHint: true, idempotentHint: true }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") return { id, status: "closed", note: `${id} was already closed.` }; const answer = await this.elicit( `Close ${id} "${ticket.title}"? ${ticket.customer} will be told it's resolved.`, z.object({ confirmed: z.boolean().describe("Yes, close this ticket") }), ); if (answer.status !== "accept" || !answer.content?.confirmed) { return { id, status: "open", note: "The user didn't confirm, so the ticket is still open." }; } store.close(id); return { id, status: "closed" }; } } ``` ```ts ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; export type Ticket = { id: string; title: string; customer: string; status: "open" | "closed"; message: string; replies: string[]; }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", customer: "Ada", status: "open", message: "I reset my password twice and the login page still says it's wrong.", replies: [], }, { id: "T-2", title: "Invoice total is wrong", customer: "Grace", status: "closed", message: "My March invoice charges for 12 seats. We only have 10.", replies: ["You're right, sorry! We've refunded the two extra seats."], }, { id: "T-3", title: "Login link expired", customer: "Linus", status: "open", message: "The sign-in link in your email says it has expired, even when I click it right away.", replies: [], }, ]; search(query: string) { const q = query.toLowerCase(); return this.tickets .filter((t) => t.title.toLowerCase().includes(q)) .map(({ id, title, status }) => ({ id, title, status })); } get(id: string) { return this.tickets.find((t) => t.id === id); } addReply(id: string, text: string) { const ticket = this.get(id); ticket?.replies.push(text); return ticket; } close(id: string) { const ticket = this.get(id); if (ticket) ticket.status = "closed"; return ticket; } } ``` The page called `close_ticket` for `T-1`, and instead of a result, the Call tab shows a form: the question the tool is asking the user. Tick **confirmed** and press **Accept**, and the ticket closes. Then call it again with `T-3` and press **Decline**: the ticket stays open, and the result says so. `this.elicit(message, schema)` sends a question with a Zod schema for the answer, and the client turns the schema into a form. It returns an object with a `status`, which is `"accept"`, `"decline"` or `"cancel"`, and, when the user accepted, the answers in `content`. `close_ticket` closes the ticket only if the user accepted and ticked the box. Any other answer is a normal result, not an error: the user said no, so the model should tell them the ticket is still open rather than try again. The checks come before the question. A missing ticket fails, and a closed one returns at once, so the user is never asked about something that can't happen. Try `T-2`. The annotations say what the tool does, too. `destructiveHint: true` because closing overwrites the ticket's status rather than adding to it, and `idempotentHint: true` because closing a ticket twice changes nothing the second time. How does a tool wait for the user? It doesn't. Under MCP 2026-07-28, the first `tools/call` comes back at once with `resultType: "input_required"`, the question, and a `requestState` token. The client shows the form, then sends the same `tools/call` again with the answer in `inputResponses`. FrontMCP runs `execute()` again from the top, and this time `this.elicit()` returns the answer. Open the Wire tab to see both requests. Because `execute()` runs twice, do your reads before `this.elicit()` and your writes after it. [Asking the User](https://frontmcp.dev/learn/asking-the-user) covers elicitation in depth. > **Note** > > The Playground turns elicitation on for you when your code calls `this.elicit()`. On your own server you turn it on in `@FrontMcp`, which is part of step 6. ## Step 5: Attach tickets as resources, and add a prompt Tools are for the model: it decides when to call them. Sometimes, though, the user already knows which ticket they mean and wants their client to attach it to the conversation, the way you'd attach a file. That's a **resource**: data a client app reads by URI. A **resource template** covers a whole family of URIs, here one per ticket: Initial call configuration: ```js { resource: "tickets://T-1" } ``` ```ts ticket.resource.ts active import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "A support ticket with the customer's message and every reply", }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new ResourceNotFoundError(uri); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts tools.ts hidden import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; const ticketId = z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"); function notFound(id: string) { return new PublicMcpError(`There's no ticket ${id}. Use search_tickets to find the right id.`, "TICKET_NOT_FOUND"); } @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Returns each match's id, title and status.", inputSchema: { query: z.string().min(2).describe('Words to look for in ticket titles, like "login"'), }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: this.get(TicketStore).search(query) }; } } @Tool({ name: "get_ticket", description: "Get one ticket by id: the customer, the status, their message and every reply so far.", inputSchema: { id: ticketId }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(notFound(id)); return ticket; } } @Tool({ name: "add_reply", description: "Add a reply to an open ticket. The customer reads it, so write it to them. Returns the updated ticket.", inputSchema: { id: ticketId, text: z.string().min(1).max(2000).describe("The reply, as the customer will read it"), }, annotations: { destructiveHint: false }, }) export class AddReply extends ToolContext { async execute({ id, text }: { id: string; text: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") { this.fail(new PublicMcpError(`${id} is closed. Replies can only go on open tickets.`, "TICKET_CLOSED")); } return store.addReply(id, text); } } @Tool({ name: "close_ticket", description: "Close a ticket whose problem is solved. The customer is told it's resolved, so the user is asked to confirm first.", inputSchema: { id: ticketId }, annotations: { destructiveHint: true, idempotentHint: true }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") return { id, status: "closed", note: `${id} was already closed.` }; const answer = await this.elicit( `Close ${id} "${ticket.title}"? ${ticket.customer} will be told it's resolved.`, z.object({ confirmed: z.boolean().describe("Yes, close this ticket") }), ); if (answer.status !== "accept" || !answer.content?.confirmed) { return { id, status: "open", note: "The user didn't confirm, so the ticket is still open." }; } store.close(id); return { id, status: "closed" }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts ticket-store.ts hidden import { Provider, ProviderScope } from "@frontmcp/sdk"; export type Ticket = { id: string; title: string; customer: string; status: "open" | "closed"; message: string; replies: string[]; }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", customer: "Ada", status: "open", message: "I reset my password twice and the login page still says it's wrong.", replies: [], }, { id: "T-2", title: "Invoice total is wrong", customer: "Grace", status: "closed", message: "My March invoice charges for 12 seats. We only have 10.", replies: ["You're right, sorry! We've refunded the two extra seats."], }, { id: "T-3", title: "Login link expired", customer: "Linus", status: "open", message: "The sign-in link in your email says it has expired, even when I click it right away.", replies: [], }, ]; search(query: string) { const q = query.toLowerCase(); return this.tickets .filter((t) => t.title.toLowerCase().includes(q)) .map(({ id, title, status }) => ({ id, title, status })); } get(id: string) { return this.tickets.find((t) => t.id === id); } addReply(id: string, text: string) { const ticket = this.get(id); ticket?.replies.push(text); return ticket; } close(id: string) { const ticket = this.get(id); if (ticket) ticket.status = "closed"; return ticket; } } ``` - `uriTemplate: "tickets://{id}"` matches any `tickets://` URI, and FrontMCP passes the `id` part to `execute()`. - `execute()` returns `contents`: the URI that was read, its MIME type, and the text. The text is the same JSON that `get_ticket` returns. - Reading a resource has no `isError` result the way a tool call does. For a ticket that doesn't exist, `execute()` throws `ResourceNotFoundError`, and the client gets a JSON-RPC error instead of a result. In the Call tab, pick `resources/read · tickets://{id}` and read `T-9` to see it. Offering a ticket both as a tool and as a resource isn't duplication. `get_ticket` is for when the model decides it needs a ticket, and `tickets://{id}` is for when the user or the app decides. [Exposing Data with Resources](https://frontmcp.dev/learn/exposing-data-with-resources) covers resources in depth. The last piece is for the user too. A good reply draft needs the same instructions every time: read the ticket, match the tone, and don't send anything until the user approves. A **prompt** packages those instructions so the user can pick them, usually from a slash menu in their client, and fill in the blanks: Initial call configuration: ```js { prompt: "draft_reply", arguments: { id: "T-3" } } ``` ```ts draft-reply.prompt.ts active import { type GetPromptResult, Prompt, PromptContext, PublicMcpError } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Prompt({ name: "draft_reply", description: "Draft a reply to a ticket, for you to review before it's sent", arguments: [ { name: "id", description: "Ticket id, like T-1", required: true }, { name: "tone", description: "friendly (the default) or formal" }, ], }) export class DraftReply extends PromptContext { async execute({ id, tone = "friendly" }: Record): Promise { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`, "TICKET_NOT_FOUND"); return { messages: [ { role: "user", content: { type: "resource", resource: { uri: `tickets://${id}`, mimeType: "application/json", text: JSON.stringify(ticket) } }, }, { role: "user", content: { type: "text", text: `Draft a ${tone} reply to ${ticket.customer} about this ticket. Show me the draft and wait. Only send it with add_reply once I approve it.`, }, }, ], }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts ticket.resource.ts hidden import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "A support ticket with the customer's message and every reply", }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new ResourceNotFoundError(uri); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts tools.ts hidden import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; const ticketId = z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"); function notFound(id: string) { return new PublicMcpError(`There's no ticket ${id}. Use search_tickets to find the right id.`, "TICKET_NOT_FOUND"); } @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Returns each match's id, title and status.", inputSchema: { query: z.string().min(2).describe('Words to look for in ticket titles, like "login"'), }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: this.get(TicketStore).search(query) }; } } @Tool({ name: "get_ticket", description: "Get one ticket by id: the customer, the status, their message and every reply so far.", inputSchema: { id: ticketId }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(notFound(id)); return ticket; } } @Tool({ name: "add_reply", description: "Add a reply to an open ticket. The customer reads it, so write it to them. Returns the updated ticket.", inputSchema: { id: ticketId, text: z.string().min(1).max(2000).describe("The reply, as the customer will read it"), }, annotations: { destructiveHint: false }, }) export class AddReply extends ToolContext { async execute({ id, text }: { id: string; text: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") { this.fail(new PublicMcpError(`${id} is closed. Replies can only go on open tickets.`, "TICKET_CLOSED")); } return store.addReply(id, text); } } @Tool({ name: "close_ticket", description: "Close a ticket whose problem is solved. The customer is told it's resolved, so the user is asked to confirm first.", inputSchema: { id: ticketId }, annotations: { destructiveHint: true, idempotentHint: true }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") return { id, status: "closed", note: `${id} was already closed.` }; const answer = await this.elicit( `Close ${id} "${ticket.title}"? ${ticket.customer} will be told it's resolved.`, z.object({ confirmed: z.boolean().describe("Yes, close this ticket") }), ); if (answer.status !== "accept" || !answer.content?.confirmed) { return { id, status: "open", note: "The user didn't confirm, so the ticket is still open." }; } store.close(id); return { id, status: "closed" }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts ticket-store.ts hidden import { Provider, ProviderScope } from "@frontmcp/sdk"; export type Ticket = { id: string; title: string; customer: string; status: "open" | "closed"; message: string; replies: string[]; }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", customer: "Ada", status: "open", message: "I reset my password twice and the login page still says it's wrong.", replies: [], }, { id: "T-2", title: "Invoice total is wrong", customer: "Grace", status: "closed", message: "My March invoice charges for 12 seats. We only have 10.", replies: ["You're right, sorry! We've refunded the two extra seats."], }, { id: "T-3", title: "Login link expired", customer: "Linus", status: "open", message: "The sign-in link in your email says it has expired, even when I click it right away.", replies: [], }, ]; search(query: string) { const q = query.toLowerCase(); return this.tickets .filter((t) => t.title.toLowerCase().includes(q)) .map(({ id, title, status }) => ({ id, title, status })); } get(id: string) { return this.tickets.find((t) => t.id === id); } addReply(id: string, text: string) { const ticket = this.get(id); ticket?.replies.push(text); return ticket; } close(id: string) { const ticket = this.get(id); if (ticket) ticket.status = "closed"; return ticket; } } ``` - `arguments` are the blanks the user fills in. `id` is required. `tone` is optional, and `execute()` uses `"friendly"` when it's left out. Prompt arguments are always strings. - `execute()` returns the messages that start the conversation. The first attaches the ticket, just as reading `tickets://T-3` would. The second says what to do with it. - The instructions name `add_reply`. A prompt is a good place to say how your tools fit together. - The return type, `GetPromptResult`, lets TypeScript check the shape of each message. Try `tone` set to `formal`, or an id that doesn't exist. [Reusable Prompts](https://frontmcp.dev/learn/reusable-prompts) covers prompts in depth. ## Step 6: Put it in an app and a server, and test it Until now, the Playground has found your exports and wrapped them in a server. A real project does that wiring in two files: - **`help-desk.app.ts`** declares an `@App`: the provider, tools, resource and prompt that belong together. - **`main.ts`** declares the `@FrontMcp` server: its name and version, and its apps. `import "reflect-metadata"` comes first, because FrontMCP's decorators rely on it. Here's a first try. It lists everything, and the page calls `close_ticket`: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts main.ts active import "reflect-metadata"; import { FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class HelpDeskServer {} ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { DraftReply } from "./draft-reply.prompt"; import { TicketResource } from "./ticket.resource"; import { TicketStore } from "./ticket-store"; import { AddReply, CloseTicket, GetTicket, SearchTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", providers: [TicketStore], tools: [SearchTickets, GetTicket, AddReply, CloseTicket], resources: [TicketResource], prompts: [DraftReply], }) export class HelpDeskApp {} ``` **Hidden fixture/test file (runs with the example):** ```ts tools.ts hidden import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; const ticketId = z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"); function notFound(id: string) { return new PublicMcpError(`There's no ticket ${id}. Use search_tickets to find the right id.`, "TICKET_NOT_FOUND"); } @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Returns each match's id, title and status.", inputSchema: { query: z.string().min(2).describe('Words to look for in ticket titles, like "login"'), }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: this.get(TicketStore).search(query) }; } } @Tool({ name: "get_ticket", description: "Get one ticket by id: the customer, the status, their message and every reply so far.", inputSchema: { id: ticketId }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(notFound(id)); return ticket; } } @Tool({ name: "add_reply", description: "Add a reply to an open ticket. The customer reads it, so write it to them. Returns the updated ticket.", inputSchema: { id: ticketId, text: z.string().min(1).max(2000).describe("The reply, as the customer will read it"), }, annotations: { destructiveHint: false }, }) export class AddReply extends ToolContext { async execute({ id, text }: { id: string; text: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") { this.fail(new PublicMcpError(`${id} is closed. Replies can only go on open tickets.`, "TICKET_CLOSED")); } return store.addReply(id, text); } } @Tool({ name: "close_ticket", description: "Close a ticket whose problem is solved. The customer is told it's resolved, so the user is asked to confirm first.", inputSchema: { id: ticketId }, annotations: { destructiveHint: true, idempotentHint: true }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") return { id, status: "closed", note: `${id} was already closed.` }; const answer = await this.elicit( `Close ${id} "${ticket.title}"? ${ticket.customer} will be told it's resolved.`, z.object({ confirmed: z.boolean().describe("Yes, close this ticket") }), ); if (answer.status !== "accept" || !answer.content?.confirmed) { return { id, status: "open", note: "The user didn't confirm, so the ticket is still open." }; } store.close(id); return { id, status: "closed" }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts ticket.resource.ts hidden import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "A support ticket with the customer's message and every reply", }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new ResourceNotFoundError(uri); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts draft-reply.prompt.ts hidden import { type GetPromptResult, Prompt, PromptContext, PublicMcpError } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Prompt({ name: "draft_reply", description: "Draft a reply to a ticket, for you to review before it's sent", arguments: [ { name: "id", description: "Ticket id, like T-1", required: true }, { name: "tone", description: "friendly (the default) or formal" }, ], }) export class DraftReply extends PromptContext { async execute({ id, tone = "friendly" }: Record): Promise { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`, "TICKET_NOT_FOUND"); return { messages: [ { role: "user", content: { type: "resource", resource: { uri: `tickets://${id}`, mimeType: "application/json", text: JSON.stringify(ticket) } }, }, { role: "user", content: { type: "text", text: `Draft a ${tone} reply to ${ticket.customer} about this ticket. Show me the draft and wait. Only send it with add_reply once I approve it.`, }, }, ], }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts ticket-store.ts hidden import { Provider, ProviderScope } from "@frontmcp/sdk"; export type Ticket = { id: string; title: string; customer: string; status: "open" | "closed"; message: string; replies: string[]; }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", customer: "Ada", status: "open", message: "I reset my password twice and the login page still says it's wrong.", replies: [], }, { id: "T-2", title: "Invoice total is wrong", customer: "Grace", status: "closed", message: "My March invoice charges for 12 seats. We only have 10.", replies: ["You're right, sorry! We've refunded the two extra seats."], }, { id: "T-3", title: "Login link expired", customer: "Linus", status: "open", message: "The sign-in link in your email says it has expired, even when I click it right away.", replies: [], }, ]; search(query: string) { const q = query.toLowerCase(); return this.tickets .filter((t) => t.title.toLowerCase().includes(q)) .map(({ id, title, status }) => ({ id, title, status })); } get(id: string) { return this.tickets.find((t) => t.id === id); } addReply(id: string, text: string) { const ticket = this.get(id); ticket?.replies.push(text); return ticket; } close(id: string) { const ticket = this.get(id); if (ticket) ticket.status = "closed"; return ticket; } } ``` The call fails: "Elicitation is disabled in server configuration". Asking the user is off unless the server turns it on, and until now the Playground had been doing that for you. Add `elicitation: { enabled: true }` to `@FrontMcp`, and add a test file: ```ts main.ts import "reflect-metadata"; import { FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true }, }) export default class HelpDeskServer {} ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { DraftReply } from "./draft-reply.prompt"; import { TicketResource } from "./ticket.resource"; import { TicketStore } from "./ticket-store"; import { AddReply, CloseTicket, GetTicket, SearchTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", providers: [TicketStore], tools: [SearchTickets, GetTicket, AddReply, CloseTicket], resources: [TicketResource], prompts: [DraftReply], }) export class HelpDeskApp {} ``` ```ts tools.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; const ticketId = z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"); function notFound(id: string) { return new PublicMcpError(`There's no ticket ${id}. Use search_tickets to find the right id.`, "TICKET_NOT_FOUND"); } @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Returns each match's id, title and status.", inputSchema: { query: z.string().min(2).describe('Words to look for in ticket titles, like "login"'), }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: this.get(TicketStore).search(query) }; } } @Tool({ name: "get_ticket", description: "Get one ticket by id: the customer, the status, their message and every reply so far.", inputSchema: { id: ticketId }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(notFound(id)); return ticket; } } @Tool({ name: "add_reply", description: "Add a reply to an open ticket. The customer reads it, so write it to them. Returns the updated ticket.", inputSchema: { id: ticketId, text: z.string().min(1).max(2000).describe("The reply, as the customer will read it"), }, annotations: { destructiveHint: false }, }) export class AddReply extends ToolContext { async execute({ id, text }: { id: string; text: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") { this.fail(new PublicMcpError(`${id} is closed. Replies can only go on open tickets.`, "TICKET_CLOSED")); } return store.addReply(id, text); } } @Tool({ name: "close_ticket", description: "Close a ticket whose problem is solved. The customer is told it's resolved, so the user is asked to confirm first.", inputSchema: { id: ticketId }, annotations: { destructiveHint: true, idempotentHint: true }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") return { id, status: "closed", note: `${id} was already closed.` }; const answer = await this.elicit( `Close ${id} "${ticket.title}"? ${ticket.customer} will be told it's resolved.`, z.object({ confirmed: z.boolean().describe("Yes, close this ticket") }), ); if (answer.status !== "accept" || !answer.content?.confirmed) { return { id, status: "open", note: "The user didn't confirm, so the ticket is still open." }; } store.close(id); return { id, status: "closed" }; } } ``` ```ts ticket.resource.ts import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "A support ticket with the customer's message and every reply", }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new ResourceNotFoundError(uri); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] }; } } ``` ```ts draft-reply.prompt.ts import { type GetPromptResult, Prompt, PromptContext, PublicMcpError } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Prompt({ name: "draft_reply", description: "Draft a reply to a ticket, for you to review before it's sent", arguments: [ { name: "id", description: "Ticket id, like T-1", required: true }, { name: "tone", description: "friendly (the default) or formal" }, ], }) export class DraftReply extends PromptContext { async execute({ id, tone = "friendly" }: Record): Promise { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`, "TICKET_NOT_FOUND"); return { messages: [ { role: "user", content: { type: "resource", resource: { uri: `tickets://${id}`, mimeType: "application/json", text: JSON.stringify(ticket) } }, }, { role: "user", content: { type: "text", text: `Draft a ${tone} reply to ${ticket.customer} about this ticket. Show me the draft and wait. Only send it with add_reply once I approve it.`, }, }, ], }; } } ``` ```ts ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; export type Ticket = { id: string; title: string; customer: string; status: "open" | "closed"; message: string; replies: string[]; }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", customer: "Ada", status: "open", message: "I reset my password twice and the login page still says it's wrong.", replies: [], }, { id: "T-2", title: "Invoice total is wrong", customer: "Grace", status: "closed", message: "My March invoice charges for 12 seats. We only have 10.", replies: ["You're right, sorry! We've refunded the two extra seats."], }, { id: "T-3", title: "Login link expired", customer: "Linus", status: "open", message: "The sign-in link in your email says it has expired, even when I click it right away.", replies: [], }, ]; search(query: string) { const q = query.toLowerCase(); return this.tickets .filter((t) => t.title.toLowerCase().includes(q)) .map(({ id, title, status }) => ({ id, title, status })); } get(id: string) { return this.tickets.find((t) => t.id === id); } addReply(id: string, text: string) { const ticket = this.get(id); ticket?.replies.push(text); return ticket; } close(id: string) { const ticket = this.get(id); if (ticket) ticket.status = "closed"; return ticket; } } ``` ```ts helpdesk.test.ts active import { test, expect } from "@frontmcp/testing"; test("search_tickets finds both login tickets", async ({ mcp }) => { const result = await mcp.tools.call("search_tickets", { query: "log" }); expect(result).toBeSuccessful(); expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-1", "T-3"]); }); test("get_ticket explains a missing ticket", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-9" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("TICKET_NOT_FOUND"); expect(result).toHaveTextContent("search_tickets"); }); test("a reply shows up on the ticket", async ({ mcp }) => { await mcp.tools.call("add_reply", { id: "T-3", text: "Here's a fresh link. It works for an hour." }); const ticket = (await mcp.tools.call("get_ticket", { id: "T-3" })).json(); expect(ticket.replies).toContain("Here's a fresh link. It works for an hour."); }); test("close_ticket closes the ticket when the user confirms", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "accept", content: { confirmed: true } })); const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.json()).toEqual({ id: "T-1", status: "closed" }); }); test("close_ticket leaves the ticket open when the user declines", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "decline" })); const result = await mcp.tools.call("close_ticket", { id: "T-3" }); expect(result.json()).toMatchObject({ id: "T-3", status: "open" }); }); test("tickets://T-2 is the ticket as JSON", async ({ mcp }) => { const content = await mcp.resources.read("tickets://T-2"); expect(content).toHaveMimeType("application/json"); expect(content.json().customer).toBe("Grace"); }); test("draft_reply attaches the ticket and asks for a draft", async ({ mcp }) => { const prompt = await mcp.prompts.get("draft_reply", { id: "T-3" }); expect(prompt).toHaveMessages(2); expect(prompt.messages[0].content.resource.uri).toBe("tickets://T-3"); }); ``` This is the finished server from the top of the page, with every file shown. In the Call tab, `close_ticket` asks the user again. In the Wire tab, each response's `_meta.serverInfo` now says `Help Desk`, from `info` in `main.ts`. Open the **Tests** tab. `helpdesk.test.ts` is written with `@frontmcp/testing`. Each test gets `mcp`, a client connected to this server, calls the server through it, and checks the results with `expect`: - `toBeSuccessful()` and `toBeError()` check whether a tool call worked, and `result.json()` reads the result's data. - `result.raw._meta?.code` is where the `TICKET_NOT_FOUND` code from step 2 arrives. - `mcp.onElicitation()` plays the user: whenever a tool asks a question, the test answers it. One test accepts and one declines. - `mcp.resources.read()` and `mcp.prompts.get()` test the resource and the prompt the same way. All the tests in a file talk to the same running server, so a ticket that one test closes stays closed for the next. That's why the test that closes a ticket uses `T-1`, and the one that declines uses `T-3`. To see a test fail, change `"TICKET_NOT_FOUND"` in `tools.ts` to something else and open the Tests tab again. In a project, you run the same tests with `frontmcp test`, once the file says where your server is. [Testing Your Server](https://frontmcp.dev/learn/testing-your-server) shows the setup. ## Wrapping up Congratulations! You've built an MCP server that: - keeps its data in a provider that every capability shares; - gives the model four tools with clear names, schemas, results and errors; - tells clients which tools change data, and asks the user before a ticket is closed; - lets a client attach tickets as resources, and lets a user start a reply with a prompt; - wires everything into an app and a server, with tests that check each part. Each idea has its own lesson: - Tools, schemas, results and errors: [Your First Tool](https://frontmcp.dev/learn/your-first-tool), [Schemas Are Contracts](https://frontmcp.dev/learn/schemas-are-contracts) and [Shaping Tool Results](https://frontmcp.dev/learn/shaping-tool-results) - Resources: [Exposing Data with Resources](https://frontmcp.dev/learn/exposing-data-with-resources) - Prompts: [Reusable Prompts](https://frontmcp.dev/learn/reusable-prompts) - Apps, servers and providers: [Grouping Capabilities into Apps](https://frontmcp.dev/learn/grouping-capabilities-into-apps) - Asking the user: [Asking the User](https://frontmcp.dev/learn/asking-the-user) - Tests: [Testing Your Server](https://frontmcp.dev/learn/testing-your-server) - Deciding what to build in the first place: [Thinking in MCP](https://frontmcp.dev/learn/thinking-in-mcp) If you'd like more practice, here are some ideas to try in the last Playground, roughly from easiest to hardest. 1. Let the model narrow a search to open or closed tickets, with an optional `status` argument on `search_tickets`. Look at how the new argument shows up in the Capabilities tab. 2. A user asking about "Ada's login problem" gets nothing if the model searches for `Ada`. Make `search_tickets` match the customer's name as well as the title, and update its description to say so. 3. Add a `reopen_ticket` tool. Decide which annotations it needs, and whether reopening needs the user to confirm. Then add a test for it. 4. Let `close_ticket` ask for a one-line resolution in the same form, and save it as the ticket's last reply. The elicitation schema is a Zod object, so it can hold more than one field. --- # Thinking in MCP > A design process for turning what people need from an AI assistant into tools, resources and prompts, worked through for a help desk. Source: https://frontmcp.dev/learn/thinking-in-mcp [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 When you connect a product to AI, the obvious first move is to take your API and turn each endpoint into a tool. That works, but models often use the result badly, because they don't think in endpoints. They think in the requests people make. This page designs an MCP server the other way round, starting from what people will ask the assistant. The example is a help desk, and you'll end with a working FrontMCP server. **You will learn** - How to start a design from the requests people will make - How to tell whether a job is a tool, a resource or a prompt - How to shape names, inputs and results for a model - How to decide which actions need the user's confirmation - How to group capabilities into apps ## Start with what people will ask Say you make help desk software. It has a web app, a database of tickets, and an API with dozens of endpoints. Support teams want their AI assistant to work with it. Don't start from the API. Start from the requests. Ask a few support agents what they'd want to say to the assistant, or read your team's chat for things people already ask each other. You might collect these: 1. "What's going on with Ada's login problem?" 2. "Are there any open tickets about invoices?" 3. "Tell Linus we've sent him a new sign-in link." 4. "T-1 is fixed. Close it." 5. With a ticket attached to the conversation: "Why did this customer get a refund?" 6. Picked from the client's prompt menu: "Draft a reply to T-3." Then look at the data the requests are about. Here's one ticket: ```json ticket.json { "id": "T-1", "title": "Cannot log in", "customer": "Ada", "status": "open", "message": "I reset my password twice and the login page still says it's wrong.", "replies": [] } ``` These requests are your mockup. Everything that follows is about making each of them easy for a model to carry out. ## Step 1: Break the requests into jobs For each request, write down what the assistant has to do, and what it needs to know to do it: | Request | Jobs | What it needs | | --- | --- | --- | | "What's going on with Ada's login problem?" | find the ticket, read it | a customer's name, then the whole ticket | | "Are there any open tickets about invoices?" | find tickets | a word from the title, a status | | "Tell Linus we've sent him a new sign-in link." | find the ticket, reply | a customer's name, then the id and the reply | | "T-1 is fixed. Close it." | close a ticket | the id | | (ticket attached) "Why did this customer get a refund?" | read a ticket | the whole ticket | | (from the menu) "Draft a reply to T-3." | read a ticket, draft a reply | the whole ticket | Then merge the duplicates. Six requests come down to five jobs: **find tickets**, **read a ticket**, **reply**, **close a ticket** and **draft a reply**. All five work on the same data: tickets. Two things stand out: - **Most requests start by finding a ticket, and people don't know ids.** They say "Ada's login problem" or "Linus". So finding has to work with what people say: words from the title, and the customer's name, with an optional status for request 2. - **The list is short, and it isn't your API.** Your product may have endpoints for tags, assignments, exports and reports. If nobody asks the assistant for them, leave them out for now. Every capability you add is one more thing a model has to read and choose between. ## Step 2: Decide who starts each job MCP has three kinds of capability. What separates them is who decides to use each one: - A **tool** is chosen by the **model**. It reads the tool's name and description, and calls it when the conversation needs it. - A **resource** is read by the **client app**, usually because the user attached it to the conversation, the way you attach a file. It's data with a URI. - A **prompt** is picked by the **user**, usually from a slash menu, to start a workflow with arguments they fill in. So for each job, ask who decides that it should happen: | Job | Who decides | Kind | | --- | --- | --- | | Find tickets | The model, while it answers a question | Tool | | Read a ticket | The model, when it needs the details. The user, when they attach one. | Tool and resource | | Reply | The model, when the user asks for it | Tool | | Close a ticket | The model, when the user asks for it | Tool | | Draft a reply | The user, who wants the same steps every time | Prompt | Reading a ticket is both, which is common for the main thing your product holds: the model needs a way to fetch it, and the user needs a way to hand it over. The two can share the code that loads a ticket. Here's a first sketch with one capability of each kind, all reading the same tickets from a provider. The page read `tickets://T-2`, the ticket attached in request 5. Open the Capabilities tab to see the sketch the way a client does, and use the Call tab's **Request** menu to try the tool and the prompt. Initial call configuration: ```js { resource: "tickets://T-2" } ``` ```ts help-desk.ts active import { type GetPromptResult, Prompt, PromptContext, PublicMcpError, ResourceContext, ResourceNotFoundError, ResourceTemplate, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; // The model decides it needs a ticket: a tool. @Tool({ name: "get_ticket", description: "Get one ticket by id: the customer, the status, their message and every reply so far.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); return ticket; } } // The user attaches a ticket: a resource. @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "A support ticket with the customer's message and every reply", }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new ResourceNotFoundError(uri); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] }; } } // The user picks a workflow: a prompt. @Prompt({ name: "draft_reply", description: "Draft a reply to a ticket, for you to review before it's sent", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class DraftReply extends PromptContext { async execute({ id }: Record): Promise { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); return { messages: [ { role: "user", content: { type: "resource", resource: { uri: `tickets://${id}`, mimeType: "application/json", text: JSON.stringify(ticket) } }, }, { role: "user", content: { type: "text", text: `Draft a reply to ${ticket.customer} about this ticket. Show me the draft and wait for my approval.` }, }, ], }; } } ``` ```ts ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; export type Ticket = { id: string; title: string; customer: string; status: "open" | "closed"; message: string; replies: string[]; }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", customer: "Ada", status: "open", message: "I reset my password twice and the login page still says it's wrong.", replies: [], }, { id: "T-2", title: "Invoice total is wrong", customer: "Grace", status: "closed", message: "My March invoice charges for 12 seats. We only have 10.", replies: ["You're right, sorry! We've refunded the two extra seats."], }, { id: "T-3", title: "Login link expired", customer: "Linus", status: "open", message: "The sign-in link in your email says it has expired, even when I click it right away.", replies: [], }, ]; search(query: string, status?: "open" | "closed") { const q = query.toLowerCase(); return this.tickets .filter((t) => [t.title, t.customer].some((s) => s.toLowerCase().includes(q))) .filter((t) => !status || t.status === status) .map(({ id, title, customer, status }) => ({ id, title, customer, status })); } get(id: string) { return this.tickets.find((t) => t.id === id); } addReply(id: string, text: string) { const ticket = this.get(id); ticket?.replies.push(text); return ticket; } close(id: string) { const ticket = this.get(id); if (ticket) ticket.status = "closed"; return ticket; } } ``` The three capabilities read the same `TicketStore`, so they can never disagree about a ticket. The store is a provider with `ProviderScope.GLOBAL`: FrontMCP creates one for the whole server and hands it to anything that asks with `this.get()`. Every Playground on this page uses this same store file, so from here on it's hidden. It still runs. ## Step 3: Design names, inputs and results for the model With the jobs and their kinds decided, design each tool for its reader: a model that sees only names, descriptions and schemas. It's tempting to skip this and mirror the API with a single tool that takes an `action` and every field any action might need. Here's that design. The page asked it to reply to `T-3`: Initial call configuration: ```js { tool: "tickets", arguments: { action: "reply", id: "T-3" } } ``` ```ts tickets.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; type Input = { action: "search" | "get" | "reply" | "close"; id?: string; query?: string; text?: string }; @Tool({ name: "tickets", description: "Manage tickets.", inputSchema: { action: z.enum(["search", "get", "reply", "close"]), id: z.string().optional(), query: z.string().optional(), text: z.string().optional(), }, }) export class Tickets extends ToolContext { async execute({ action, id, query, text }: Input) { const store = this.get(TicketStore); if (action === "search") return { tickets: store.search(query ?? "") }; if (!id) this.fail(new PublicMcpError(`"${action}" needs an id.`)); if (action === "get") return store.get(id); if (action === "reply") { if (!text) this.fail(new PublicMcpError(`"reply" needs text.`)); return store.addReply(id, text); } return store.close(id); } } ``` **Hidden fixture/test file (runs with the example):** ```ts ticket-store.ts hidden import { Provider, ProviderScope } from "@frontmcp/sdk"; export type Ticket = { id: string; title: string; customer: string; status: "open" | "closed"; message: string; replies: string[]; }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", customer: "Ada", status: "open", message: "I reset my password twice and the login page still says it's wrong.", replies: [], }, { id: "T-2", title: "Invoice total is wrong", customer: "Grace", status: "closed", message: "My March invoice charges for 12 seats. We only have 10.", replies: ["You're right, sorry! We've refunded the two extra seats."], }, { id: "T-3", title: "Login link expired", customer: "Linus", status: "open", message: "The sign-in link in your email says it has expired, even when I click it right away.", replies: [], }, ]; search(query: string, status?: "open" | "closed") { const q = query.toLowerCase(); return this.tickets .filter((t) => [t.title, t.customer].some((s) => s.toLowerCase().includes(q))) .filter((t) => !status || t.status === status) .map(({ id, title, customer, status }) => ({ id, title, customer, status })); } get(id: string) { return this.tickets.find((t) => t.id === id); } addReply(id: string, text: string) { const ticket = this.get(id); ticket?.replies.push(text); return ticket; } close(id: string) { const ticket = this.get(id); if (ticket) ticket.status = "closed"; return ticket; } } ``` The call failed because it had no `text`, and the model had no way to know it needed one. Open the Capabilities tab and read the card as a model would: 1. **Every field but `action` is optional, and nothing says which fields go with which action.** The schema can't express "`reply` needs `id` and `text`", so the model finds out by failing, as this call just did. 2. **One vague name and description cover four jobs.** When a user says "close it", the model has to guess that `tickets` is the tool, then guess the arguments. 3. **It can't be annotated honestly.** The same tool reads and closes tickets, so it can't be marked read-only, and a careful client has to treat every search like a change. Give each job its own tool instead, and design each one for the moment the model will use it: - **Name the tool after its job**: a verb and a noun in `snake_case`, like `search_tickets`. The name is the first thing a model matches against the user's words. - **Ask only for what the model can know at that point**: the user's words, or an id from an earlier result. `search_tickets` takes words and matches them against titles and customer names, because step 1 showed that's how people ask. `get_ticket` takes the id that search returned. Make inputs required unless there's a sensible default, like the optional `status`, and put every rule in the schema. - **Return what the next step needs.** Search returns each match's id, title, customer and status: enough to pick one and to pass its id on. The whole ticket comes from `get_ticket`. - **Make errors say what to do next.** "There's no ticket T-9. Use search_tickets to find the right id." gives the model a way forward. Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "Ada" } } ``` ```ts tools.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; const ticketId = z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"); function notFound(id: string) { return new PublicMcpError(`There's no ticket ${id}. Use search_tickets to find the right id.`, "TICKET_NOT_FOUND"); } @Tool({ name: "search_tickets", description: "Search support tickets by a word from the title or the customer's name. Returns each match's id, title, customer and status.", inputSchema: { query: z.string().min(2).describe('A word from the title, or a customer name, like "invoice" or "Ada"'), status: z.enum(["open", "closed"]).optional().describe("Only tickets with this status. Leave out for both."), }, }) export class SearchTickets extends ToolContext { async execute({ query, status }: { query: string; status?: "open" | "closed" }) { return { tickets: this.get(TicketStore).search(query, status) }; } } @Tool({ name: "get_ticket", description: "Get one ticket by id: the customer, the status, their message and every reply so far.", inputSchema: { id: ticketId }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(notFound(id)); return ticket; } } @Tool({ name: "add_reply", description: "Add a reply to an open ticket. The customer reads it, so write it to them. Returns the updated ticket.", inputSchema: { id: ticketId, text: z.string().min(1).max(2000).describe("The reply, as the customer will read it"), }, }) export class AddReply extends ToolContext { async execute({ id, text }: { id: string; text: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") { this.fail(new PublicMcpError(`${id} is closed. Replies can only go on open tickets.`, "TICKET_CLOSED")); } return store.addReply(id, text); } } @Tool({ name: "close_ticket", description: "Close a ticket whose problem is solved. The customer is told it's resolved.", inputSchema: { id: ticketId }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const store = this.get(TicketStore); if (!store.get(id)) this.fail(notFound(id)); store.close(id); return { id, status: "closed" }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts ticket-store.ts hidden import { Provider, ProviderScope } from "@frontmcp/sdk"; export type Ticket = { id: string; title: string; customer: string; status: "open" | "closed"; message: string; replies: string[]; }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", customer: "Ada", status: "open", message: "I reset my password twice and the login page still says it's wrong.", replies: [], }, { id: "T-2", title: "Invoice total is wrong", customer: "Grace", status: "closed", message: "My March invoice charges for 12 seats. We only have 10.", replies: ["You're right, sorry! We've refunded the two extra seats."], }, { id: "T-3", title: "Login link expired", customer: "Linus", status: "open", message: "The sign-in link in your email says it has expired, even when I click it right away.", replies: [], }, ]; search(query: string, status?: "open" | "closed") { const q = query.toLowerCase(); return this.tickets .filter((t) => [t.title, t.customer].some((s) => s.toLowerCase().includes(q))) .filter((t) => !status || t.status === status) .map(({ id, title, customer, status }) => ({ id, title, customer, status })); } get(id: string) { return this.tickets.find((t) => t.id === id); } addReply(id: string, text: string) { const ticket = this.get(id); ticket?.replies.push(text); return ticket; } close(id: string) { const ticket = this.get(id); if (ticket) ticket.status = "closed"; return ticket; } } ``` The page searched for `Ada`, the way request 1 would, and found `T-1`. Now walk the other requests through the Call tab. Request 2 is `search_tickets` with `invoice` and status `open`: there are none, because the invoice ticket is closed. Request 3 is a search for `Linus`, then `add_reply` on the id it returns. Each card in the Capabilities tab now says what its tool does and asks only for what it needs. For more on each of these, see [Your First Tool](https://frontmcp.dev/learn/your-first-tool), [Schemas Are Contracts](https://frontmcp.dev/learn/schemas-are-contracts), [Shaping Tool Results](https://frontmcp.dev/learn/shaping-tool-results) and [Writing Descriptions Agents Understand](https://frontmcp.dev/learn/writing-descriptions-agents-understand). ## Step 4: Decide what needs the user's confirmation Request 4 now works a little too well. Anything that makes the model call `close_ticket`, whether it misread the user or mixed up two tickets, closes a ticket on the spot. Go through the tools and ask: what happens if the model calls this when the user didn't mean it? | Tool | If it's called by mistake | So | | --- | --- | --- | | `search_tickets`, `get_ticket` | Nothing changes. | `readOnlyHint: true`. No confirmation. | | `add_reply` | A customer gets a reply nobody meant to send. It adds to the ticket and doesn't overwrite anything, and a follow-up can correct it. | `destructiveHint: false`. No confirmation. | | `close_ticket` | A customer who still needs help is told they're done. | `destructiveHint: true`, and ask the user first. | You have two ways to act on the answers, and they do different jobs: - **Annotations** describe a tool's effects to clients, which may use them to decide how carefully to confirm a call. Give every tool annotations. They're hints: FrontMCP sends them but doesn't enforce them. - **`this.elicit()`** asks the user a question from inside your tool, and your code acts on the answer. The check lives in your server, so it holds whatever the client does with hints. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts tools.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; const ticketId = z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"); function notFound(id: string) { return new PublicMcpError(`There's no ticket ${id}. Use search_tickets to find the right id.`, "TICKET_NOT_FOUND"); } @Tool({ name: "search_tickets", description: "Search support tickets by a word from the title or the customer's name. Returns each match's id, title, customer and status.", inputSchema: { query: z.string().min(2).describe('A word from the title, or a customer name, like "invoice" or "Ada"'), status: z.enum(["open", "closed"]).optional().describe("Only tickets with this status. Leave out for both."), }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query, status }: { query: string; status?: "open" | "closed" }) { return { tickets: this.get(TicketStore).search(query, status) }; } } @Tool({ name: "get_ticket", description: "Get one ticket by id: the customer, the status, their message and every reply so far.", inputSchema: { id: ticketId }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(notFound(id)); return ticket; } } @Tool({ name: "add_reply", description: "Add a reply to an open ticket. The customer reads it, so write it to them. Returns the updated ticket.", inputSchema: { id: ticketId, text: z.string().min(1).max(2000).describe("The reply, as the customer will read it"), }, annotations: { destructiveHint: false }, }) export class AddReply extends ToolContext { async execute({ id, text }: { id: string; text: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") { this.fail(new PublicMcpError(`${id} is closed. Replies can only go on open tickets.`, "TICKET_CLOSED")); } return store.addReply(id, text); } } @Tool({ name: "close_ticket", description: "Close a ticket whose problem is solved. The customer is told it's resolved, so the user is asked to confirm first.", inputSchema: { id: ticketId }, annotations: { destructiveHint: true, idempotentHint: true }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") return { id, status: "closed", note: `${id} was already closed.` }; const answer = await this.elicit( `Close ${id} "${ticket.title}"? ${ticket.customer} will be told it's resolved.`, z.object({ confirmed: z.boolean().describe("Yes, close this ticket") }), ); if (answer.status !== "accept" || !answer.content?.confirmed) { return { id, status: "open", note: "The user didn't confirm, so the ticket is still open." }; } store.close(id); return { id, status: "closed" }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts ticket-store.ts hidden import { Provider, ProviderScope } from "@frontmcp/sdk"; export type Ticket = { id: string; title: string; customer: string; status: "open" | "closed"; message: string; replies: string[]; }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", customer: "Ada", status: "open", message: "I reset my password twice and the login page still says it's wrong.", replies: [], }, { id: "T-2", title: "Invoice total is wrong", customer: "Grace", status: "closed", message: "My March invoice charges for 12 seats. We only have 10.", replies: ["You're right, sorry! We've refunded the two extra seats."], }, { id: "T-3", title: "Login link expired", customer: "Linus", status: "open", message: "The sign-in link in your email says it has expired, even when I click it right away.", replies: [], }, ]; search(query: string, status?: "open" | "closed") { const q = query.toLowerCase(); return this.tickets .filter((t) => [t.title, t.customer].some((s) => s.toLowerCase().includes(q))) .filter((t) => !status || t.status === status) .map(({ id, title, customer, status }) => ({ id, title, customer, status })); } get(id: string) { return this.tickets.find((t) => t.id === id); } addReply(id: string, text: string) { const ticket = this.get(id); ticket?.replies.push(text); return ticket; } close(id: string) { const ticket = this.get(id); if (ticket) ticket.status = "closed"; return ticket; } } ``` The page called `close_ticket` again, and this time the user gets a form instead of a closed ticket. Tick **confirmed** and press **Accept** to close it, or **Decline** to keep it open. The tool checks the ticket before it asks, so nobody is asked to close a ticket that doesn't exist or is already closed, and it writes only after the answer comes back, because `execute()` runs again from the top when the answer arrives. The Capabilities tab now shows the annotations as chips: **read-only** on the two reads, **destructive** and **idempotent** on `close_ticket`. Keep confirmations for the actions that need them. A user asked to confirm every reply soon starts pressing Accept without reading, and then the one question that matters gets the same treatment. Replies are covered another way: the `draft_reply` prompt from step 2 tells the model to wait for the user's approval before it sends anything. [Asking the User](https://frontmcp.dev/learn/asking-the-user) covers elicitation in depth. ## Step 5: Group capabilities into apps An `@App` groups capabilities that belong together: they work on the same data, share the same providers, and would share settings such as authentication. Everything so far is about tickets, so it's one app, and the app owns the `TicketStore`. Then a new request shows up: "Is there a help article about sign-in links I can send Linus?" That's a new job with different data, articles, probably looked after by another team. It becomes a second app, and the `@FrontMcp` server lists both. The resource and the prompt from step 2 get files of their own, and `tools.ts` is hidden because it hasn't changed since step 4: ```ts main.ts active import "reflect-metadata"; import { FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; import { KnowledgeBaseApp } from "./knowledge-base.app"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [HelpDeskApp, KnowledgeBaseApp], elicitation: { enabled: true }, }) export default class HelpDeskServer {} ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { DraftReply } from "./draft-reply.prompt"; import { TicketResource } from "./ticket.resource"; import { TicketStore } from "./ticket-store"; import { AddReply, CloseTicket, GetTicket, SearchTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", providers: [TicketStore], tools: [SearchTickets, GetTicket, AddReply, CloseTicket], resources: [TicketResource], prompts: [DraftReply], }) export class HelpDeskApp {} ``` ```ts knowledge-base.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; const articles = [ { id: "KB-1", title: "Resetting your password", url: "https://help.example.com/kb/1" }, { id: "KB-2", title: "Sign-in links and when they expire", url: "https://help.example.com/kb/2" }, { id: "KB-3", title: "Reading your invoice", url: "https://help.example.com/kb/3" }, ]; @Tool({ name: "search_articles", description: "Search help center articles by words in their title. Returns links an agent can send to a customer.", inputSchema: { query: z.string().min(2).describe('Words to look for in article titles, like "password"') }, annotations: { readOnlyHint: true }, }) export class SearchArticles extends ToolContext { async execute({ query }: { query: string }) { const q = query.toLowerCase(); return { articles: articles.filter((a) => a.title.toLowerCase().includes(q)) }; } } @App({ id: "knowledge-base", name: "Knowledge Base", tools: [SearchArticles] }) export class KnowledgeBaseApp {} ``` **Hidden fixture/test file (runs with the example):** ```ts tools.ts hidden import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; const ticketId = z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"); function notFound(id: string) { return new PublicMcpError(`There's no ticket ${id}. Use search_tickets to find the right id.`, "TICKET_NOT_FOUND"); } @Tool({ name: "search_tickets", description: "Search support tickets by a word from the title or the customer's name. Returns each match's id, title, customer and status.", inputSchema: { query: z.string().min(2).describe('A word from the title, or a customer name, like "invoice" or "Ada"'), status: z.enum(["open", "closed"]).optional().describe("Only tickets with this status. Leave out for both."), }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query, status }: { query: string; status?: "open" | "closed" }) { return { tickets: this.get(TicketStore).search(query, status) }; } } @Tool({ name: "get_ticket", description: "Get one ticket by id: the customer, the status, their message and every reply so far.", inputSchema: { id: ticketId }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(notFound(id)); return ticket; } } @Tool({ name: "add_reply", description: "Add a reply to an open ticket. The customer reads it, so write it to them. Returns the updated ticket.", inputSchema: { id: ticketId, text: z.string().min(1).max(2000).describe("The reply, as the customer will read it"), }, annotations: { destructiveHint: false }, }) export class AddReply extends ToolContext { async execute({ id, text }: { id: string; text: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") { this.fail(new PublicMcpError(`${id} is closed. Replies can only go on open tickets.`, "TICKET_CLOSED")); } return store.addReply(id, text); } } @Tool({ name: "close_ticket", description: "Close a ticket whose problem is solved. The customer is told it's resolved, so the user is asked to confirm first.", inputSchema: { id: ticketId }, annotations: { destructiveHint: true, idempotentHint: true }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const store = this.get(TicketStore); const ticket = store.get(id); if (!ticket) this.fail(notFound(id)); if (ticket.status === "closed") return { id, status: "closed", note: `${id} was already closed.` }; const answer = await this.elicit( `Close ${id} "${ticket.title}"? ${ticket.customer} will be told it's resolved.`, z.object({ confirmed: z.boolean().describe("Yes, close this ticket") }), ); if (answer.status !== "accept" || !answer.content?.confirmed) { return { id, status: "open", note: "The user didn't confirm, so the ticket is still open." }; } store.close(id); return { id, status: "closed" }; } } ``` ```ts ticket.resource.ts import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "A support ticket with the customer's message and every reply", }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new ResourceNotFoundError(uri); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] }; } } ``` ```ts draft-reply.prompt.ts import { type GetPromptResult, Prompt, PromptContext, PublicMcpError } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Prompt({ name: "draft_reply", description: "Draft a reply to a ticket, for you to review before it's sent", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class DraftReply extends PromptContext { async execute({ id }: Record): Promise { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); return { messages: [ { role: "user", content: { type: "resource", resource: { uri: `tickets://${id}`, mimeType: "application/json", text: JSON.stringify(ticket) } }, }, { role: "user", content: { type: "text", text: `Draft a reply to ${ticket.customer} about this ticket. Show me the draft and wait. Only send it with add_reply once I approve it.`, }, }, ], }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts ticket-store.ts hidden import { Provider, ProviderScope } from "@frontmcp/sdk"; export type Ticket = { id: string; title: string; customer: string; status: "open" | "closed"; message: string; replies: string[]; }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", customer: "Ada", status: "open", message: "I reset my password twice and the login page still says it's wrong.", replies: [], }, { id: "T-2", title: "Invoice total is wrong", customer: "Grace", status: "closed", message: "My March invoice charges for 12 seats. We only have 10.", replies: ["You're right, sorry! We've refunded the two extra seats."], }, { id: "T-3", title: "Login link expired", customer: "Linus", status: "open", message: "The sign-in link in your email says it has expired, even when I click it right away.", replies: [], }, ]; search(query: string, status?: "open" | "closed") { const q = query.toLowerCase(); return this.tickets .filter((t) => [t.title, t.customer].some((s) => s.toLowerCase().includes(q))) .filter((t) => !status || t.status === status) .map(({ id, title, customer, status }) => ({ id, title, customer, status })); } get(id: string) { return this.tickets.find((t) => t.id === id); } addReply(id: string, text: string) { const ticket = this.get(id); ticket?.replies.push(text); return ticket; } close(id: string) { const ticket = this.get(id); if (ticket) ticket.status = "closed"; return ticket; } } ``` ```ts requests.test.ts import { test, expect } from "@frontmcp/testing"; const ids = (result: { json(): any }) => result.json().tickets.map((t: { id: string }) => t.id); test("What's going on with Ada's login problem?", async ({ mcp }) => { expect(ids(await mcp.tools.call("search_tickets", { query: "Ada" }))).toEqual(["T-1"]); const ticket = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(ticket.json().message).toContain("reset my password"); }); test("Are there any open tickets about invoices?", async ({ mcp }) => { expect(ids(await mcp.tools.call("search_tickets", { query: "invoice", status: "open" }))).toEqual([]); }); test("Tell Linus we've sent him a new sign-in link.", async ({ mcp }) => { const [id] = ids(await mcp.tools.call("search_tickets", { query: "Linus" })); const result = await mcp.tools.call("add_reply", { id, text: "We've sent you a new sign-in link." }); expect(result.json().replies).toEqual(["We've sent you a new sign-in link."]); }); test("T-1 is fixed. Close it.", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "accept", content: { confirmed: true } })); const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.json()).toEqual({ id: "T-1", status: "closed" }); }); test("(T-2 attached) Why did this customer get a refund?", async ({ mcp }) => { const ticket = await mcp.resources.read("tickets://T-2"); expect(ticket.json().replies[0]).toContain("refunded"); }); test("Draft a reply to T-3.", async ({ mcp }) => { const prompt = await mcp.prompts.get("draft_reply", { id: "T-3" }); expect(prompt).toHaveMessages(2); expect(prompt.messages[0].content.resource.uri).toBe("tickets://T-3"); }); test("Is there a help article about sign-in links?", async ({ mcp }) => { const result = await mcp.tools.call("search_articles", { query: "sign-in link" }); expect(result.json().articles.map((a: { id: string }) => a.id)).toEqual(["KB-2"]); }); ``` Open the Capabilities tab. A client sees one list with the tools from both apps. Apps are for you, not the model: they keep each area's code, data and settings together, and the server combines them. Tool names still have to make sense side by side, which is one more reason for specific names like `search_tickets` and `search_articles` over a plain `search`. Now open the Tests tab. Each test is one of the requests you started with, carried out the way a model would carry it out. Your sample requests make a good first test suite: when they pass, the server does what people will ask of it. [Grouping Capabilities into Apps](https://frontmcp.dev/learn/grouping-capabilities-into-apps) covers apps in depth, and [Testing Your Server](https://frontmcp.dev/learn/testing-your-server) covers tests. ## Recap - Start from the requests people will make, not from your API. They're your mockup. - Break the requests into jobs and the data they need, and leave out what nobody asks for. - Decide who starts each job: the model (a tool), the client app or user attaching data (a resource), or the user picking a workflow (a prompt). One job can be both a tool and a resource. - Give each job its own tool, named after the job, asking only for what the model can know, and returning what the next step needs. - Annotate every tool, and ask the user with `this.elicit()` before the changes that are costly to get wrong. - Group capabilities that share data and settings into an app, and let the server combine apps. ## Where to go from here To build this server one step at a time, with each idea explained as it comes up, follow the [Help Desk tutorial](https://frontmcp.dev/learn/tutorial-help-desk). To learn each kind of capability properly, start the [Describing Capabilities](https://frontmcp.dev/learn/describing-capabilities) chapter. --- # Installation > Create a FrontMCP project with Node.js 24+, run it locally and connect an MCP client. Includes existing-project setup and troubleshooting. Source: https://frontmcp.dev/learn/installation [Covers FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) as of October 26, 2026 The Playground is the quickest way to learn FrontMCP. To build a real server, create a project on your machine. It takes one command. **You will learn** - How to create a new FrontMCP project - How to add FrontMCP to a project you already have - How to run your server and test it with the MCP Inspector - How to fix the most common setup problems ## Before you start You need **Node.js 24 or later** and **npm 10 or later** (pnpm and yarn work too). FrontMCP is developed and tested on Node 24. Check your versions: ```bash node --version npm --version ``` ## Creating a new server ```bash npx frontmcp create my-mcp-server cd my-mcp-server npm install npm run dev ``` `create` asks a few questions: - **Deployment target**: Node.js (Docker), Vercel, AWS Lambda or Cloudflare Workers - **Redis**: Docker Compose, an existing Redis, or none (Docker target only) - **GitHub Actions**: whether to add CI/CD workflows Pass `--yes` to accept the defaults, or answer up front with flags such as `--target cloudflare` or `--pm pnpm`. `create` doesn't install anything, so run `npm install` (or your package manager) before `npm run dev`. Your server is now running at `http://localhost:3000/mcp`, and `http://localhost:3000/health` answers `{"status":"ok", …}`. The project looks like this (Docker target): *[Illustration: The new project in a file explorer: my-mcp-server with ci/ (Dockerfile, docker-compose.yml), src/ with main.ts marked @FrontMcp, calc.app.ts marked @App, and tools/add.tool.ts marked @Tool, then package.json and tsconfig.json. Beside it, the same three files as nested boxes: the @FrontMcp server in main.ts contains the @App in calc.app.ts, which contains the @Tool add in add.tool.ts.]* That's the same server you built at the end of the [Quick Start](https://frontmcp.dev/learn#grouping-capabilities-into-an-app-and-a-server). ## Adding FrontMCP to an existing project ```bash npm install @frontmcp/sdk reflect-metadata npm install -D frontmcp @types/node@^24 npx frontmcp init ``` `init` updates `tsconfig.json` with the decorator settings FrontMCP needs: ```json tsconfig.json { "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true } } ``` Then import `reflect-metadata` once, at the very top of your entry file, before anything from FrontMCP: ```ts src/main.ts import "reflect-metadata"; import { FrontMcp } from "@frontmcp/sdk"; ``` ## Running and testing your server Your project comes with these scripts: | Command | What it does | | --- | --- | | `npm run dev` | Starts the server with hot reload and type checking | | `npm run build` | Compiles a production build to `./dist` | | `npm run inspect` | Opens the MCP Inspector | | `npm run doctor` | Checks your Node and npm versions and your config | To have Claude Code call your server while it reloads, connect it through `frontmcp dev --stdio --serve`: see [Build an MCP Server With Claude Code Connected to It](https://frontmcp.dev/blog/claude-code-hot-reload). To test the server the way a client sees it, keep `npm run dev` running and start the Inspector in a second terminal: ```bash npm run inspect ``` It opens at `http://localhost:6274`. Enter `http://localhost:3000/mcp` as the server URL, click **Connect**, pick the `add` tool, and call it with `{ "a": 2, "b": 3 }`. You'll get `5` back, just as in the Playground. ## Connecting a client FrontMCP speaks MCP's Streamable HTTP transport, so any MCP client that supports it can connect to `http://localhost:3000/mcp`. That includes Claude Desktop, Claude Code and Cursor. ## Starting a monorepo For several servers that share code, scaffold an Nx workspace: ```bash npx frontmcp create my-platform --nx --pm pnpm ``` It creates `apps/`, `libs/` and `servers/` folders, plus generators for new apps, tools and servers. To add the plugin to a workspace you already have, run `nx add @frontmcp/nx`: it installs FrontMCP and makes the `build` and `test` targets cacheable. The [Nx plugin](https://frontmcp.dev/reference/nx#installing-it) reference has the details. ## Troubleshooting ### The server won't start Check that you're on Node 24 or later, that nothing else is using port 3000, and that the console shows no TypeScript errors. Then run `npm run doctor`. ### My tools don't appear 1. Make sure the tool is listed in its app's `tools` array, and the app is listed in `@FrontMcp({ apps })`. 2. Make sure `tsconfig.json` has `experimentalDecorators` and `emitDecoratorMetadata` set to `true`. 3. Make sure `import "reflect-metadata"` is the first line of `main.ts`. ### "skill storage needs the optional peer dependency 'vectoriadb'" You're on FrontMCP 1.8, which lists `vectoriadb` as an optional peer dependency but loads it at startup, so an install without dev dependencies, like a production Docker image, can't start. Since 1.9.1, `@frontmcp/sdk` depends on `vectoriadb` (and `vectoriadb` on `tslib`), and a production install starts. Upgrade, or on 1.8 add both to your dependencies: ```bash npm install vectoriadb tslib ``` ### The Inspector can't connect Check that `http://localhost:3000/health` responds, and that you entered `http://`, not `https://`. --- # Docs for Your Agent > Give your coding agent FrontMCP's docs for the release your project uses, through FrontMCP's own MCP server at mcp.frontmcp.dev. Source: https://frontmcp.dev/learn/docs-for-your-agent [Covers FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) as of October 26, 2026 A coding agent knows FrontMCP from whatever release it was trained on, if any. FrontMCP's docs server gives it these docs instead, over MCP, for the release your project uses: it searches them, reads pages and sections, looks up an API by name, returns runnable examples, and says what changed between two releases. **You will learn** - How to connect your agent to the docs server - How to pin it to the FrontMCP release your project uses - What each of its tools does ## Connecting your agent The server is at `https://mcp.frontmcp.dev`, and in the [MCP Registry](https://registry.modelcontextprotocol.io/v0.1/servers?search=dev.frontmcp.mcp%2Ffrontmcp) as `dev.frontmcp.mcp/frontmcp`. It needs no key and no sign-in, and it only reads. With Claude Code: ```bash claude mcp add --transport http frontmcp-docs https://mcp.frontmcp.dev ``` Clients that keep their servers in a JSON file take an entry with the URL: ```json { "mcpServers": { "frontmcp-docs": { "url": "https://mcp.frontmcp.dev" } } } ``` The field that marks an entry as a URL differs between clients (`type`, `transport`, or none), so check your client's documentation for it. The server speaks MCP from 2024-11-05 to 2026-07-28 over Streamable HTTP, as any FrontMCP server does. ## Pinning a release `https://mcp.frontmcp.dev` serves the docs of the latest release. To pin one, add it to the URL: | URL | Docs for | | --- | --- | | `https://mcp.frontmcp.dev` | The latest release | | `https://mcp.frontmcp.dev/1.9.4` | That release | | `https://mcp.frontmcp.dev/1.9` | The newest patch of 1.9 | The server's earlier URL, `https://frontmcp.dev/mcp`, still works, with the same pins after it: `https://frontmcp.dev/mcp/1.9.4`. The server documents each release from 1.9.2 on, and `get_versions` lists them; the [versions page](https://frontmcp.dev/versions) has every release's notes. Without a pin, your agent can find the release itself: the server tells it to call `resolve_version` with your project's `package.json` or lockfile first, and to pass the release it gets to the other tools. ## Choosing a workspace A lockfile can contain several SDK releases. `resolve_version` refuses an ambiguous result instead of selecting the first record. For npm or pnpm, pass `workspace: "."` for the root or a path such as `packages/api`. For Yarn resolutions or a project you can inspect directly, pass `installedVersion` read from that project\'s resolved `@frontmcp/sdk/package.json`. A conflicting manifest/installation is an error to investigate. Search results identify their snapshot, content kind and article dates. Maintained docs are preferred for current instructions; a blog in a release snapshot is still a dated article unless its own scope says otherwise. ## What it offers | Tool | What it does | | --- | --- | | `resolve_version` | Which documented release a project should read, from its lockfile (npm, Yarn or pnpm), its `package.json` or a version range, and the URL that pins it | | `get_versions` | The documented releases, newest first, with their release notes | | `search_docs` | Full-text search over the docs, by section: the page, the heading, the path with its `#anchor`, and the start of its text | | `get_page` | A page as Markdown, or one section of it. It takes a path, a frontmcp.dev URL, or a link to the old docs on docs.agentfront.dev, which it maps to its page here | | `list_pages` | The docs as the sidebar has them: Learn, Reference, Examples and Compare | | `lookup_api` | An API by name: a decorator (`@Tool`), a function (`createFetchHandler`), an option, an error code (`TOOL_NOT_FOUND`) or a CLI flag, with what the Reference says and where | | `get_example` | A page's Playgrounds, or one's files and tests, with how to run them in your project | | `whats_changed` | Between two releases: their release notes, every "Changed in" note in the docs with its page, and the pages that changed | | `get_skill` | FrontMCP's skills catalog: instructions for building with FrontMCP, and their reference files | It also has three prompts. `upgrade_frontmcp` asks your agent to move a project from one release to another, change by change. `migrate_from_fastmcp` and `migrate_from_typescript_sdk` ask it to move a server to FrontMCP from FastMCP for TypeScript or the official SDK, by [Migrating from FastMCP](https://frontmcp.dev/learn/migrating-from-fastmcp#migrate-with-a-coding-agent) and [Migrating from the TypeScript SDK](https://frontmcp.dev/learn/migrating-from-the-typescript-sdk#migrate-with-a-coding-agent). ### How the server is built It's a FrontMCP server on a Cloudflare Worker: [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) behind a Worker entry, as [Cloudflare Workers](https://frontmcp.dev/reference/deployment/cloudflare-workers) describes. Each release's docs come from this site's history: when a release moves the site to the next one, the outgoing release's pages are kept, and the server reads them as Markdown, with a search index and an index of the Reference's APIs, from the Worker's static assets. ## Troubleshooting ### `Too many requests: at most 120 a minute from one address.` The server answers each address 120 requests a minute, then `429` with `Retry-After: 60`. Wait a minute, or have your agent search before it reads pages one by one. ### `FrontMCP 1.8.7 isn't documented here.` The server documents releases from 1.9.2 on. Pin the nearest one, or ask `whats_changed` with `from` set to your release to see what changed since, which also covers releases the server doesn't document. --- # Migrating from the TypeScript SDK > Preserve an MCP server's tools, resources, errors and access rules while moving shared services into FrontMCP. Includes a reproducible contract comparison. Source: https://frontmcp.dev/learn/migrating-from-the-typescript-sdk [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Migrate one client contract at a time. Before you move any code, record what clients rely on: tool names, input schemas, results, resource URIs and refused requests. FrontMCP adds structure around the protocol. It does not make every SDK middleware, transport or schema choice a drop-in match. For a small, stable server, staying with the SDK may be the simpler choice. **You will learn** - How to compare an SDK server and a FrontMCP server with the same contract check - Which FrontMCP API replaces each official SDK construct - How one tool looks before and after the move - What to check for auth and for the endpoint before clients switch ## Compare two implementations [Download the contract fixtures](https://frontmcp.dev/downloads/crm-evaluation.tgz). Their README includes the SDK migration check. The check runs an official TypeScript SDK server and a FrontMCP server against the same synthetic records. Both servers expose two tools, a resource and the same result shape. The check compares the tool schemas, successful calls, a missing record, an unauthorized operation and resource content. Extract it and, with Node.js 24 or later, run in its folder: ```bash npm ci npm run test:migration ``` The output names the package versions that are actually installed. The [dated record](https://frontmcp.dev/evidence/migration.json) keeps one run: official SDK 1.32.1 and FrontMCP 1.10.10, on 2026-10-22. The official SDK server runs over an in-memory transport. FrontMCP runs through its in-process entry. So the check compares the application contract a client sees. HTTP routing, OAuth and deployment need the endpoint checks [below](https://frontmcp.dev/learn/migrating-from-the-typescript-sdk#verify-the-endpoint-before-switching-clients). ## Migrate with a coding agent A coding agent can do this migration in your project. Connect it to [FrontMCP's docs server](https://frontmcp.dev/learn/docs-for-your-agent) first, so it reads this page and the APIs it needs for the release it installs, rather than writing FrontMCP code from what it was trained on. Then paste this prompt into it, in your SDK project: ```text title="Prompt for your coding agent" Migrate this project's MCP server from the official MCP TypeScript SDK (`@modelcontextprotocol/sdk`) to FrontMCP 1.10.11, without changing what its MCP clients see: tool names, input schemas, results, error messages, resource URIs and the requests it refuses. Follow the guide at https://frontmcp.dev/learn/migrating-from-the-typescript-sdk, one section at a time. If FrontMCP's docs MCP server (https://mcp.frontmcp.dev) is connected, read the guide with its get_page tool and look up every FrontMCP API you use with lookup_api and get_example, passing version "1.10.11". Otherwise fetch https://frontmcp.dev/learn/migrating-from-the-typescript-sdk.md and the pages it links to. Don't write FrontMCP code from memory. 1. List what the server has: every registerTool (or tool), registerResource (with or without a ResourceTemplate) and registerPrompt; how it connects (stdio, or Streamable HTTP inside its own HTTP server); its auth; shared services such as API clients and stores; and how its tools fail (isError results or thrown errors). Show me the list before you change code. 2. Record what clients see now: drive the SDK server in-process with an InMemoryTransport linked pair and the SDK's Client, and save tools/list, a successful call and a refused call for each tool, and each resource read. 3. Add FrontMCP next to the SDK server as https://frontmcp.dev/learn/installation#adding-frontmcp-to-an-existing-project shows, with @frontmcp/sdk and frontmcp pinned to 1.10.11. Keep the SDK server working until I switch clients. 4. Move one contract at a time, by the guide's mapping table: McpServer becomes an @FrontMcp server with an @App; each tool a @Tool class with the same Zod input shape; resources, templates and prompts their decorated classes (a @Prompt's arguments are a list, not a Zod shape); a failure this.fail(new PublicMcpError(message, code)). Move shared services into a provider that tools read with this.get(). After each part, write @frontmcp/testing tests that check the recorded answers, run them, and don't start the next part until they pass. 5. Where FrontMCP answers differently, keep its behaviour and list the difference for me instead of hiding it: a failure gets _meta.code and an errorId, and in production only a PublicMcpError keeps its message. 6. A FrontMCP endpoint is public by default. Ask me which auth mode to use before you move anything protected, and keep the negative cases: call restricted tools directly by name, not only through discovery. 7. Ask me before you rename anything a client sees, change the endpoint path, or delete SDK code. When you finish, report what you moved, the test results, every difference a client will see, anything with no FrontMCP counterpart, and the guide's checks before switching clients: the exact endpoint path and protocol revision, missing, expired and wrong-audience tokens, two callers with different access, a build for the real target, and a way to roll back. ``` With the docs server connected, the same prompt is also its `migrate_from_typescript_sdk` MCP prompt, so a client that lists MCP prompts can start it without copying. Review the agent's work as you would anyone's: the recorded answers and the tests that check them are what show the clients see no difference. ## Map SDK constructs to FrontMCP The left column is v1 of the official SDK, `@modelcontextprotocol/sdk`. It's the line the migration check runs, and the one FrontMCP builds on. The last column links the FrontMCP reference for each row. | Official SDK v1 | FrontMCP | Reference | | --- | --- | --- | | `new McpServer({ name, version })` | A class decorated with `@FrontMcp({ info: { name, version }, apps })`. Each app is an `@App` that lists its tools, resources and prompts. | [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp), [`@App`](https://frontmcp.dev/reference/sdk/app) | | `server.registerTool(name, { description, inputSchema }, handler)`, or the deprecated `server.tool()` | A `@Tool({ name, description, inputSchema })` class. `inputSchema` is the same Zod shape, like `{ id: z.string() }`. `execute()` receives the validated input. The `tool()` function is the form without a decorator. | [`@Tool`](https://frontmcp.dev/reference/sdk/tool) | | `server.registerResource(name, uri, metadata, read)` | A `@Resource({ name, uri, mimeType })` class whose `execute()` returns the content | [`@Resource`](https://frontmcp.dev/reference/sdk/resource) | | `server.registerResource(name, new ResourceTemplate(pattern, { list }), metadata, read)` | A `@ResourceTemplate({ name, uriTemplate })` class. `execute(uri, params)` receives the matched parameters. | [`@ResourceTemplate`](https://frontmcp.dev/reference/sdk/resource-template) | | `server.registerPrompt(name, { argsSchema }, handler)` | A `@Prompt({ name, arguments })` class. `arguments` is a list of `{ name, description, required }` entries, not a Zod shape. | [`@Prompt`](https://frontmcp.dev/reference/sdk/prompt) | | `server.connect(new StdioServerTransport())` | `FrontMcpInstance.runStdio(config)` | [`runStdio()`](https://frontmcp.dev/reference/sdk/frontmcp-instance#frontmcpinstancerunstdioconfigorclass) | | `StreamableHTTPServerTransport` inside your own HTTP server | In Node, importing the `@FrontMcp` class starts an HTTP server. `FrontMcpInstance.createHandler()` and `createFetchHandler()` return a handler for a host you choose. | [`FrontMcpInstance`](https://frontmcp.dev/reference/sdk/frontmcp-instance), [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) | | `InMemoryTransport.createLinkedPair()` with a `Client` | `connect()` gives you an MCP client in memory. `create()` calls tools directly, with no client. | [`connect()`](https://frontmcp.dev/reference/sdk/connect), [`create()`](https://frontmcp.dev/reference/sdk/create) | | A handler that returns `{ isError: true, content }`, or throws | `this.fail(new PublicMcpError(message, code))` | [`this.fail`](https://frontmcp.dev/reference/sdk/fail), [Error classes](https://frontmcp.dev/reference/sdk/error-classes) | > **Note: Two methods named registerTool()** > > FrontMCP also has a `server.registerTool()`. It belongs to the server that `create()` or `createDirect()` returns, and adds a tool to a server that is already running. It takes a JSON Schema, not a Zod shape. A migrated tool belongs in a `@Tool` class instead. See [`server.registerTool()`](https://frontmcp.dev/reference/sdk/register-tool). ## Move one tool This is `get_report` from the migration check. It reads one record by id, and it fails when the record is missing. ### Before: the official SDK Here is the SDK version, for `@modelcontextprotocol/sdk` 1.32.1 and `zod` 4. It's the SDK server from `scripts/check-migration.mjs`, cut down to this one tool and served over stdio instead of in memory. The handler closes over a store object at module scope. This file can't run in a Playground, which only loads FrontMCP packages: ```ts server.ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; const reports = [{ id: "R-1", total: 42 }]; class ReportStore { get(id: string) { return reports.find((report) => report.id === id); } } const store = new ReportStore(); const server = new McpServer({ name: "reports", version: "1.0.0" }); server.registerTool( "get_report", { description: "Read one fixture report", inputSchema: { id: z.string() } }, async ({ id }) => { const report = store.get(id); if (!report) return { isError: true, content: [{ type: "text", text: "Report not found" }] }; return { content: [{ type: "text", text: JSON.stringify(report) }], structuredContent: report }; }, ); await server.connect(new StdioServerTransport()); ``` On SDK v2, `@modelcontextprotocol/server`, the [comparison's example](https://frontmcp.dev/compare/official-typescript-sdk#with-the-official-sdk) passes `inputSchema` as `z.object(...)` instead. ### After: FrontMCP The same tool in FrontMCP. The store becomes a provider, and the tool reads it with `this.get()`. The Playground wraps the exported tool and provider in a one-app server and calls `get_report`. The **Tests** tab checks the contract: Initial call configuration: ```js { tool: "get_report", arguments: { id: "R-1" } } ``` ```ts get-report.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { ReportStore } from "./report-store"; @Tool({ name: "get_report", description: "Read one fixture report", inputSchema: { id: z.string() }, }) export class GetReport extends ToolContext { async execute({ id }: { id: string }) { const report = this.get(ReportStore).get(id); if (!report) this.fail(new PublicMcpError("Report not found", "NOT_FOUND")); return report; } } ``` ```ts report-store.ts import { Provider } from "@frontmcp/sdk"; const reports = [{ id: "R-1", total: 42 }]; @Provider({ name: "ReportStore" }) export class ReportStore { get(id: string) { return reports.find((report) => report.id === id); } } ``` ```ts get-report.test.ts import { test, expect } from "@frontmcp/testing"; test("`id` is a required string, as in the SDK version", async ({ mcp }) => { const tools = await mcp.tools.list(); const getReport = tools.find((tool) => tool.name === "get_report"); expect(getReport?.inputSchema.properties).toEqual({ id: { type: "string" } }); expect(getReport?.inputSchema.required).toEqual(["id"]); }); test("the record arrives as structured content with a JSON text copy", async ({ mcp }) => { const result = await mcp.tools.call("get_report", { id: "R-1" }); expect(result).toBeSuccessful(); expect(result.raw.structuredContent).toEqual({ id: "R-1", total: 42 }); expect(result.json()).toEqual({ id: "R-1", total: 42 }); }); test("a missing record keeps the SDK's error text and adds a code", async ({ mcp }) => { const result = await mcp.tools.call("get_report", { id: "missing" }); expect(result).toBeError("NOT_FOUND"); expect(result.text()).toBe("Report not found"); expect(result.raw._meta?.errorId).toMatch(/^err_/); }); ``` What stays the same for clients: - The tool name, `get_report`, and its input: `id`, a required string. - A found record arrives as `structuredContent`, with the same JSON as text. - A missing record is an `isError` result with the text `Report not found`. What changes: - The store is a provider. Tools ask for it with `this.get(ReportStore)`, so they can share one instance. - The failure goes through `this.fail()`. FrontMCP adds `_meta.code` (`NOT_FOUND` here) and an `errorId` to the result. - Only a `PublicMcpError` keeps its message in production. FrontMCP replaces the message of any other error with an error ID ([Error classes](https://frontmcp.dev/reference/sdk/error-classes#in-production)). - Your code doesn't build the server. In a project, list the tool and the provider in an `@App`, and the app in `@FrontMcp`. The check's other refusal, a caller who may not read the report, depends on who is calling. That needs an auth mode, so this example leaves it out. See [Choose auth explicitly](https://frontmcp.dev/learn/migrating-from-the-typescript-sdk#choose-auth-explicitly). ## Move shared services into a provider Keep your API client independent of the transport. Register it as a provider, as `ReportStore` is above. Tools then get it with `this.get()`, instead of reading a module global or wiring it up per request. Move the tools into an app, and register the app on the server. Keep the public names and resource URIs your clients already use. Don't expose new class names to existing clients. [The tool reference](https://frontmcp.dev/reference/sdk/tool), [providers](https://frontmcp.dev/reference/sdk/provider), [resources](https://frontmcp.dev/reference/sdk/resource) and [testing](https://frontmcp.dev/reference/testing) cover each step. The [reporting download](https://frontmcp.dev/downloads/tenant-reporting.tgz) shows a complete local registration, with its tests adapted. ## Choose auth explicitly A FrontMCP endpoint is public by default. Choose [an auth mode](https://frontmcp.dev/reference/auth/modes) before you migrate protected data. Check how the server handles issuer, audience, expiry and scopes. Take tenant claims from a trusted identity, and enforce record access in your API. Static test keys don't replace a real identity-provider flow. Keep your negative cases. A tool missing from discovery is not the same as a server that refuses the call, so also call restricted tools directly by name. For destructive operations, keep authorization separate from [user approval](https://frontmcp.dev/learn/asking-for-approval). ## Verify the endpoint before switching clients 1. Keep the existing endpoint running while the new one runs in preview. 2. Test the exact endpoint path and the protocol revision you chose. Test initialize and session behavior where they apply, the tool schemas and resource reads. 3. Send missing, expired and wrong-audience tokens. Test two callers with different access. 4. Compare typical success and error results. Include any widget or resource metadata the client reads. 5. Build for the real target, and configure secrets and state. Then send the same requests to the preview. 6. Move a small group of clients first, and watch for failures. Keep the previous deployment ready for rollback. The [deployment guide](https://frontmcp.dev/reference/deployment) covers each target's constraints. Passing the in-memory contract check does not show that a hosted deployment works, or that OAuth behaves the same. Keep unsupported middleware or client features on the original path until a replacement has been checked. --- # Migrating from FastMCP for TypeScript > Move a FastMCP for TypeScript server to FrontMCP one client contract at a time: tools, errors, logs, resources, prompts, auth and transports. Source: https://frontmcp.dev/learn/migrating-from-fastmcp [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 This guide moves a server written with FastMCP for TypeScript, punkpeye's `fastmcp` package, to FrontMCP without changing what its clients see: tool names, input schemas, result text, error messages, resource URIs and prompt messages. Most FastMCP calls have a FrontMCP counterpart. A few FrontMCP defaults differ in ways a client notices, and some FastMCP features have no counterpart. Move one contract at a time, and compare it with what FastMCP sent before you move the next. Prefect's `@prefecthq/fastmcp-ts` is a different library; [FrontMCP vs Prefect's FastMCP TypeScript](https://frontmcp.dev/compare/fastmcp-ts) covers it. **You will learn** - Which FrontMCP construct replaces each FastMCP one, and which have no counterpart - How to port a tool and keep the result its clients already read - Where FrontMCP answers differently: invalid input, log messages and session state - An order to migrate in, and what to check before each next step ## Before you start FastMCP 4.23.4's README says it implements the handshake-based MCP revisions, 2025-11-25 and earlier, and doesn't support 2026-07-28 ([README](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md?plain=1#L5-L7)). When this site checked it on 2026-10-20, its server answered 2026-07-28 requests with `400` over HTTP and `-32601` over stdio ([How this was checked](https://frontmcp.dev/compare/fastmcp-typescript#how-this-was-checked)). FrontMCP serves every revision from 2024-11-05 to 2026-07-28 over Streamable HTTP and stdio ([Protocol versions](https://frontmcp.dev/reference/server/protocol-versions#revisions-over-stdio)), so clients that start with `initialize` keep working after the move. Moving isn't the only option. If your clients speak 2025-11-25 or earlier and you don't need what FrontMCP adds, [FrontMCP vs FastMCP for TypeScript](https://frontmcp.dev/compare/fastmcp-typescript#when-to-choose-fastmcp) lists reasons to stay. For 2026-07-28 with FastMCP's API, its README points to ViteMCP, by the same author ([FrontMCP vs ViteMCP](https://frontmcp.dev/compare/vitemcp)). A FrontMCP project needs Node 24 or later and TypeScript's decorator settings; `frontmcp init` adds them to an existing project ([Installation](https://frontmcp.dev/learn/installation#adding-frontmcp-to-an-existing-project)). The FastMCP code on this page ran on 2026-10-25 with `fastmcp` 4.23.4, `zod` 4.6.7, the official SDK 1.32.2 that `fastmcp` installs, and `tsx`. The Playgrounds run only FrontMCP, with a client on MCP 2026-07-28. The sections follow the order to migrate in, and each ends with what to check before the next: 1. [Record what clients see now](https://frontmcp.dev/learn/migrating-from-fastmcp#record-what-clients-see-now) 2. [Port the tools](https://frontmcp.dev/learn/migrating-from-fastmcp#port-the-tools) 3. [Port errors, logs and progress](https://frontmcp.dev/learn/migrating-from-fastmcp#port-errors-logs-and-progress) 4. [Port resources, templates and prompts](https://frontmcp.dev/learn/migrating-from-fastmcp#port-resources-templates-and-prompts) 5. [Move state out of the session](https://frontmcp.dev/learn/migrating-from-fastmcp#move-state-out-of-the-session) 6. [Choose auth](https://frontmcp.dev/learn/migrating-from-fastmcp#choose-auth) 7. [Serve the same endpoint, then switch clients](https://frontmcp.dev/learn/migrating-from-fastmcp#serve-the-same-endpoint-then-switch-clients) ## Migrate with a coding agent A coding agent can do these steps in your project. Connect it to [FrontMCP's docs server](https://frontmcp.dev/learn/docs-for-your-agent) first, so it reads this page and the APIs it needs for the release it installs, rather than writing FrontMCP code from what it was trained on. Then paste this prompt into it, in your FastMCP project: ```text title="Prompt for your coding agent" Migrate this project's FastMCP for TypeScript server (the `fastmcp` npm package) to FrontMCP 1.10.11, without changing what its MCP clients see: tool names, input schemas, result text, error messages, resource URIs and prompt messages. Follow the guide at https://frontmcp.dev/learn/migrating-from-fastmcp, one section at a time. If FrontMCP's docs MCP server (https://mcp.frontmcp.dev) is connected, read the guide with its get_page tool and look up every FrontMCP API you use with lookup_api and get_example, passing version "1.10.11". Otherwise fetch https://frontmcp.dev/learn/migrating-from-fastmcp.md and the pages it links to. Don't write FrontMCP code from memory. 1. Check the library first. If the project uses `@prefecthq/fastmcp-ts` instead of `fastmcp`, stop and tell me: that is a different library, and this guide doesn't cover it. 2. List what the server has: every addTool, addResource, addResourceTemplate and addPrompt; authenticate; the transport, its endpoint and port; what tools use from their context (log, reportProgress, elicit, session, sessionId); UserError; and any state kept per session. Show me the list before you change code. 3. Record what clients see now, as the guide's "Record what clients see now" section does: drive the FastMCP server in-process with the official MCP SDK's in-memory client, and save its answers, including a call with invalid input. 4. Add FrontMCP next to FastMCP as https://frontmcp.dev/learn/installation#adding-frontmcp-to-an-existing-project shows, with @frontmcp/sdk and frontmcp pinned to 1.10.11. Keep the FastMCP server working until I switch clients. 5. Port one part at a time, in the guide's order: tools; errors, logs and progress; resources, templates and prompts; state kept per session; auth; transports. After each part, write @frontmcp/testing tests that check the recorded answers, run them, and don't start the next part until they pass. 6. Where FrontMCP answers differently by design, keep FrontMCP's behaviour and list the difference for me instead of working around it: invalid input is an INVALID_INPUT tool result, not a -32602 error; input schemas name JSON Schema draft 2020-12 without additionalProperties; log messages are opt-in. A tool that returned a string returns { content: [{ type: "text", text }] } to keep its text. State keyed by the session starts over for MCP 2026-07-28 clients: key it by a handle or the signed-in user, as the guide shows. 7. Ask me before you rename anything a client sees, choose an auth mode, or delete FastMCP code. When you finish, report what you ported, the test results, every difference a client will see, anything with no FrontMCP counterpart, and what to change in each client before switching it: the URL path, the stdio command, the health check and the address the server listens on. ``` With the docs server connected, the same prompt is also its `migrate_from_fastmcp` MCP prompt, so a client that lists MCP prompts can start it without copying. Review the agent's work as you would anyone's: the recorded answers and the tests that check them are what show the clients see no difference. ## What maps to what | FastMCP 4.23.4 | FrontMCP | Watch for | | --- | --- | --- | | `new FastMCP({ name, version })` ([Quickstart](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#quickstart)) | `@FrontMcp({ info: { name, version }, apps })`, with an [`@App`](https://frontmcp.dev/reference/sdk/app) that lists the tools, resources and prompts ([`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp)) | | | `server.addTool({ name, description, parameters, execute })` ([Tools](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#tools)) | A [`@Tool`](https://frontmcp.dev/reference/sdk/tool) class. `inputSchema` takes the `z.object()`'s `.shape`; `execute()` gets the arguments | Zod 4 only: ArkType, Valibot and `jsonSchemaAdapter()` schemas have to be rewritten in Zod | | `execute` returns a string ([Returning a string](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#returning-a-string)) | Return `{ content: [{ type: "text", text }] }` | A returned string arrives as `{"value": …}` ([Port the tools](https://frontmcp.dev/learn/migrating-from-fastmcp#port-the-tools)) | | `outputSchema` ([Structured Tool Output](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#structured-tool-output)) | `outputSchema` ([Shaping Tool Results](https://frontmcp.dev/learn/shaping-tool-results#declaring-the-shape-with-outputschema)) | | | `throw new UserError(message)` ([Errors](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#errors)) | `this.fail(new PublicMcpError(message))`, or `throw` it ([`this.fail`](https://frontmcp.dev/reference/sdk/fail)) | A `UserError`'s `extras`, which FastMCP sends as `structuredContent` ([`FastMCP.ts`](https://github.com/punkpeye/fastmcp/blob/v4.23.4/src/FastMCP.ts#L3050-L3056)), have no counterpart on this site | | `log.info(message, data)` and `debug`, `warn`, `error` ([Logging](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#logging)) | `this.notify({ message, context: data }, level)` ([`this.notify`](https://frontmcp.dev/reference/sdk/notify)) | FrontMCP sends nothing until the client asks for log messages | | `reportProgress({ progress, total, message })` ([Progress](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#progress)) | `this.progress(progress, total, message)` ([`this.progress`](https://frontmcp.dev/reference/sdk/progress)) | | | `streamContent()` ([Streaming Output](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#streaming-output)) | None | FastMCP's own extension; it isn't part of MCP | | `timeoutMs` on a tool ([Tools](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#tools)) | `timeout: { executeMs }` on the `@Tool` ([Guard options](https://frontmcp.dev/reference/sdk/guard)) | The result's text is `Execution of "add" timed out after 2000ms`, with `_meta.code` `EXECUTION_TIMEOUT`, where FastMCP's said `Tool 'add' timed out after 2000ms` and suggested raising `timeoutMs` | | `server.embedded(uri)` in a tool's result ([Embedded Resources](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#embedded-resources)) | None on this site. Keep the content in a module that the resource and the tool both import | The tool's `resource` block can stay the same | | `elicit({ message, requestedSchema })` ([Elicitation](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#elicitation)) | `this.elicit(message, zodSchema)`, with `elicitation: { enabled: true }` on the server ([`this.elicit`](https://frontmcp.dev/reference/sdk/elicit)) | The answer's `status` is FastMCP's `action`. Under 2026-07-28 the tool runs again from the top when the answer arrives ([side effects](https://frontmcp.dev/reference/sdk/elicit#keeping-side-effects-after-the-question)) | | `session`: what `authenticate` returned ([Custom Authentication](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#custom-authentication)) | [`this.auth`](https://frontmcp.dev/reference/sdk/auth): `user.sub`, `scopes`, `roles`, `claims` | [Choose auth](https://frontmcp.dev/learn/migrating-from-fastmcp#choose-auth) | | `sessionId` ([Session ID and Request ID Tracking](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#session-id-and-request-id-tracking)) | `this.context.sessionId` ([`this.context`](https://frontmcp.dev/reference/sdk/context)) | A new id on every 2026-07-28 request ([Move state out of the session](https://frontmcp.dev/learn/migrating-from-fastmcp#move-state-out-of-the-session)) | | `requestId`, from the client's request ([Session ID and Request ID Tracking](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#session-id-and-request-id-tracking)) | None. `this.context.requestId` is an id FrontMCP makes, not the JSON-RPC `id` ([`this.context`](https://frontmcp.dev/reference/sdk/context#properties)) | | | `signal` ([Cancelling Long Tool Calls](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#cancelling-long-tool-calls-contextsignal)) | [`this.signal`](https://frontmcp.dev/reference/sdk/contexts#thissignal) | | | `server.addResource({ uri, name, mimeType, load })` ([Resources](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#resources)) | A [`@Resource`](https://frontmcp.dev/reference/sdk/resource) class with `execute()` | `load()`'s `{ text }` or `{ blob }` works unchanged | | `server.addResourceTemplate({ uriTemplate, arguments, load })` ([Resource templates](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#resource-templates)) | A [`@ResourceTemplate`](https://frontmcp.dev/reference/sdk/resource-template) class | Parameters come from the URI's placeholders; there's no `arguments` list | | `complete` on a template argument ([auto-completion](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#resource-template-argument-auto-completion)) | A [completer](https://frontmcp.dev/reference/sdk/resource-template#completing-parameters) | | | `server.addPrompt({ name, arguments, load })` ([Prompts](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#prompts)) | A [`@Prompt`](https://frontmcp.dev/reference/sdk/prompt) class with `execute(args)` | | | `complete` or `enum` on a prompt argument ([auto-completion](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#prompt-argument-auto-completion)) | None on this site | | | `authenticate(request)` checking an API key ([Custom Authentication](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#custom-authentication)) | `auth: { mode: "static" }` ([Static keys](https://frontmcp.dev/reference/auth/tokens#static-keys)) | Your code gets an id per key, not an object you return | | `auth: new GoogleProvider(…)`, `GitHubProvider`, `AzureProvider` ([OAuth with Pre-configured Providers](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#oauth-with-pre-configured-providers)) | `auth: { mode: "remote" }` with an OpenID Connect provider you configure ([Remote and proxied auth](https://frontmcp.dev/reference/auth/remote)) | GitHub [can't be used](https://frontmcp.dev/reference/auth/remote#pointing-frontmcp-at-your-providers-endpoints) | | `canAccess`, `requireScopes()`, `requireRole()` ([Tool Authorization](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#tool-authorization)) | `authorities` on the tool ([Authorities](https://frontmcp.dev/reference/auth/authorities#authorities-on-an-entry)), or `this.auth.hasScope()` in `execute()` | | | `server.start({ transportType: "httpStream", httpStream: { port } })` ([HTTP Streaming](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#http-streaming)) | `http: { port, entryPath: "/mcp" }` on `@FrontMcp` ([`http`](https://frontmcp.dev/reference/sdk/frontmcp#http)) | FrontMCP serves the root path unless you set `entryPath` | | `httpStream: { stateless: true }` ([Stateless Mode](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#stateless-mode)) | `transport: { protocol: "stateless-api" }` ([`transport`](https://frontmcp.dev/reference/sdk/frontmcp#transport)) | Only clients before 2026-07-28 have sessions to turn off | | `server.start({ transportType: "stdio" })` ([Quickstart](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#quickstart)) | `FRONTMCP_STDIO=1` ([Starting the server](https://frontmcp.dev/reference/sdk/frontmcp#starting-the-server)) | | | `server.connect()` with `InMemoryTransport`, in tests ([Unit testing](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#unit-testing-with-an-in-memory-transport)) | `@frontmcp/testing` ([Testing](https://frontmcp.dev/reference/testing)) | | | `fastmcp dev`, `fastmcp inspect` ([Running Your Server](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#running-your-server)) | `frontmcp dev`, `frontmcp inspector` ([CLI](https://frontmcp.dev/reference/cli)) | | | `EdgeFastMCP` ([Edge Runtime Support](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#edge-runtime-support)) | [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), or a [Cloudflare Workers build](https://frontmcp.dev/reference/deployment/cloudflare-workers) | | ## Record what clients see now Save what the FastMCP server answers before you change any code: the FrontMCP tests below check these answers. FastMCP's `server.connect()` attaches the server to a transport you create, so a script can drive it in-process with the official SDK's client ([Unit testing with an in-memory transport](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#unit-testing-with-an-in-memory-transport)). Export the `FastMCP` instance from a module that doesn't call `start()`, then run this with `npx tsx record-contract.ts`: ```ts record-contract.ts import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { server } from "./server.js"; const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "contract-recorder", version: "1.0.0" }); await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); const contract = { tools: await client.listTools(), add: await client.callTool({ name: "add", arguments: { a: 2, b: 3 } }), addWithAString: await client.callTool({ name: "add", arguments: { a: "2", b: 3 } }).catch((error) => ({ code: error.code, message: error.message })), }; console.log(JSON.stringify(contract, null, 2)); await client.close(); ``` For FastMCP's Quickstart server, with its one tool, `add`, it printed this (the error message is shortened): ```json { "tools": { "tools": [ { "name": "add", "description": "Add two numbers", "inputSchema": { "type": "object", "properties": { "a": { "type": "number" }, "b": { "type": "number" } }, "required": ["a", "b"], "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false } } ] }, "add": { "content": [{ "type": "text", "text": "5" }] }, "addWithAString": { "code": -32602, "message": "MCP error -32602: … Tool 'add' parameter validation failed: a: Invalid input: expected number, received string. …" } } ``` Add a call for every tool, resource and prompt your clients use, with `listResources()`, `listResourceTemplates()`, `readResource()`, `listPrompts()` and `getPrompt()`, and a failing call for each tool. **Before you move on:** the recording has every name and URI your clients use, and a successful and a failing answer for each tool. ## Port the tools FastMCP's [Quickstart](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#quickstart), which answered over stdio as recorded above (the recording drove the same server, exported instead of started): ```ts server.ts import { FastMCP } from "fastmcp"; import { z } from "zod"; const server = new FastMCP({ name: "My Server", version: "1.0.0", }); server.addTool({ name: "add", description: "Add two numbers", parameters: z.object({ a: z.number(), b: z.number(), }), execute: async (args) => { return String(args.a + args.b); }, }); server.start({ transportType: "stdio", }); ``` In FrontMCP the same server is three declarations: the tool, an app that lists it, and the server that lists the app. FastMCP sends a returned string as one text block ([Returning a string](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#returning-a-string)), so `execute()` returns that block, and the client gets the same answer. The **Tests** tab checks it against the recording. Initial call configuration: ```js { tool: "add", arguments: { a: 2, b: 3 } } ``` ```ts main.ts active import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "add", description: "Add two numbers", inputSchema: { a: z.number(), b: z.number() }, }) export class Add extends ToolContext { async execute({ a, b }: { a: number; b: number }) { return { content: [{ type: "text", text: String(a + b) }] }; } } @App({ id: "calc", name: "Calculator", tools: [Add] }) export class CalcApp {} @FrontMcp({ info: { name: "My Server", version: "1.0.0" }, apps: [CalcApp] }) export default class Server {} ``` ```ts add.test.ts import { test, expect } from "@frontmcp/testing"; test("2 + 3 is the text 5, with no structuredContent, as in the recording", async ({ mcp }) => { const result = await mcp.tools.call("add", { a: 2, b: 3 }); expect(result.raw.content).toEqual([{ type: "text", text: "5" }]); expect(result.raw.structuredContent).toBeUndefined(); }); test("tools/list has the same name, description and inputs", async ({ mcp }) => { const add = (await mcp.tools.list()).find((tool) => tool.name === "add"); expect(add?.description).toBe("Add two numbers"); expect(add?.inputSchema).toEqual({ type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"], $schema: "https://json-schema.org/draft/2020-12/schema", }); }); test("a string where a number belongs is an INVALID_INPUT result, not a -32602 error", async ({ mcp }) => { const result = await mcp.tools.call("add", { a: "2", b: 3 }); expect(result.error).toBeUndefined(); expect(result).toBeError("INVALID_INPUT"); }); test("the server still says it is My Server 1.0.0", async ({ mcp }) => { const result = await mcp.tools.call("add", { a: 2, b: 3 }); expect(result.raw._meta?.["io.modelcontextprotocol/serverInfo"]).toEqual({ name: "My Server", version: "1.0.0" }); }); ``` A 2026-07-28 result also carries `resultType` and the server's name in `_meta`, which the last test reads. Two answers differ from the recording, and the tests show both: - **The input schema's keywords.** FrontMCP names JSON Schema draft 2020-12 and leaves out `additionalProperties`. FastMCP named draft-07 and added `additionalProperties: false`, whatever the schema said ([Tools](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md?plain=1#L634-L640)). The properties and `required` are the same. - **Invalid input.** FrontMCP answers with a tool result, `isError: true` and `_meta.code` `INVALID_INPUT`, which the model reads and can correct ([Your First Tool](https://frontmcp.dev/learn/your-first-tool#what-happens-when-a-tool-is-called)). FastMCP answered with a JSON-RPC error, `-32602` ([`FastMCP.ts`](https://github.com/punkpeye/fastmcp/blob/v4.23.4/src/FastMCP.ts#L2810-L2817)). Check any client code or test that looks for `-32602`. - **An unknown tool.** FrontMCP answers with a tool result too, `isError: true` and `_meta.code` `TOOL_NOT_FOUND`, with the text `Tool "multiply" not found`. FastMCP answered with the JSON-RPC error `-32602` and `Unknown tool: multiply`. - **The order of `tools/list`.** FrontMCP lists tools by name; FastMCP listed them in the order they were added. A client that relies on the order needs changing. FastMCP's `parameters` was a `z.object()`; FrontMCP's `inputSchema` takes its fields. Keep the object and pass its `.shape`, as the next section does. Schemas in ArkType, Valibot or plain JSON Schema have to be rewritten in Zod 4, which is the only schema library FrontMCP takes ([Schemas Are Contracts](https://frontmcp.dev/learn/schemas-are-contracts)). > **Pitfall: A returned string isn't sent as it is** > > `return String(a + b)` runs in FrontMCP too, but the client gets `{"value":"5"}` as the text, and the same object as `structuredContent` ([Shaping Tool Results](https://frontmcp.dev/learn/shaping-tool-results#what-comes-back-from-toolscall)). Return the content block to keep the text. When the clients can take a change, return an object, like `{ sum: 5 }`, which arrives as `structuredContent` with a JSON copy as text ([FrontMCP vs FastMCP](https://frontmcp.dev/compare/fastmcp-typescript#in-frontmcp)); make that change as a step of its own, after the move. **Before you move on:** for each tool, the `tools/list` entry and the recorded calls give the same answers, apart from the differences above. ## Port errors, logs and progress FastMCP's README shows a `download` tool three times: to log, to fail with a `UserError`, and to report progress. In one tool, as it ran with `fastmcp` 4.23.4: ```ts download.ts import { UserError } from "fastmcp"; import { z } from "zod"; import { server } from "./server.js"; server.addTool({ name: "download", description: "Download a file", parameters: z.object({ url: z.string(), }), execute: async (args, { log, reportProgress }) => { log.info("Downloading file...", { url: args.url }); await reportProgress({ progress: 0, total: 100 }); if (args.url.startsWith("https://example.com")) { throw new UserError("This URL is not allowed"); } await reportProgress({ progress: 100, total: 100 }); return "done"; }, }); ``` The helpers FastMCP passes in the second argument are methods of `this` in FrontMCP. The Call tab streams the log message and the progress above the result. Initial call configuration: ```js { tool: "download", arguments: { url: "https://files.example.org/report.pdf" } } ``` ```ts download.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const downloadParameters = z.object({ url: z.string() }); @Tool({ name: "download", description: "Download a file", inputSchema: downloadParameters.shape }) export class Download extends ToolContext { async execute({ url }: z.infer) { await this.notify({ message: "Downloading file...", context: { url } }); await this.progress(0, 100); if (url.startsWith("https://example.com")) { this.fail(new PublicMcpError("This URL is not allowed")); } await this.progress(100, 100); return { content: [{ type: "text", text: "done" }] }; } } ``` ```ts download.test.ts import { test, expect } from "@frontmcp/testing"; test("a blocked URL fails with the UserError's message, word for word", async ({ mcp }) => { const result = await mcp.tools.call("download", { url: "https://example.com/report.pdf" }); expect(result).toBeError("PUBLIC_ERROR"); expect(result.raw.content).toEqual([{ type: "text", text: "This URL is not allowed" }]); }); test("the log message carries FastMCP's data", async ({ mcp }) => { await mcp.tools.call("download", { url: "https://files.example.org/report.pdf" }); const logs = mcp.notifications.collect().received.filter((n) => n.method === "notifications/message"); expect(logs.map((n) => n.params)).toEqual([ { level: "info", logger: "download", data: { message: "Downloading file...", context: { url: "https://files.example.org/report.pdf" } } }, ]); }); test("progress goes from 0 to 100", async ({ mcp }) => { await mcp.tools.call("download", { url: "https://files.example.org/report.pdf" }); const updates = mcp.notifications.collectProgress().all; expect(updates.map((update) => [update.progress, update.total])).toEqual([[0, 100], [100, 100]]); }); test("an allowed URL answers done", async ({ mcp }) => { const result = await mcp.tools.call("download", { url: "https://files.example.org/report.pdf" }); expect(result.text()).toBe("done"); }); ``` - **`UserError` becomes `PublicMcpError`.** Its message is the whole text of an `isError` result in both, and FrontMCP adds a code in `_meta.code`. An error that isn't public is different: FastMCP sent `Tool 'download' execution failed:` and the message ([`FastMCP.ts`](https://github.com/punkpeye/fastmcp/blob/v4.23.4/src/FastMCP.ts#L3058-L3067)); FrontMCP sends `Tool "download" execution failed:`, the message and the stack in development, and hides all of it in production ([Failing vs throwing](https://frontmcp.dev/reference/sdk/fail#failing-vs-throwing)). - **`log` becomes `this.notify()`.** FastMCP sent `{ message, context }` as the log message's `data` ([`FastMCP.ts`](https://github.com/punkpeye/fastmcp/blob/v4.23.4/src/FastMCP.ts#L2035-L2058)); `this.notify()` sends the object you pass, so the same object keeps the shape, and FrontMCP adds the tool's name as `logger`. FastMCP's `log.warn` sent the level `"warning"` ([`FastMCP.ts`](https://github.com/punkpeye/fastmcp/blob/v4.23.4/src/FastMCP.ts#L1929-L1931)), which is the name `this.notify()` takes. - **FrontMCP's log messages are opt-in.** FastMCP sent every level until the client set one with `logging/setLevel` ([Logging](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md?plain=1#L1283-L1286)). FrontMCP sends nothing until the client asks: with `logging/setLevel` before 2026-07-28, and with a level in each request's `_meta` under 2026-07-28 ([Asking for log messages](https://frontmcp.dev/reference/sdk/notify#asking-for-log-messages)). The Playground's test client asks for `debug` on every call, which is why the test sees the message. Anything the model needs belongs in the result. - **Progress is the same.** Both send it only for a request with a `progressToken` ([FastMCP](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md?plain=1#L1351), [`this.progress`](https://frontmcp.dev/reference/sdk/progress)). **Before you move on:** the recorded failures have the same text, and the clients that show logs or progress still get them. ## Port resources, templates and prompts FastMCP's README examples for a resource, a resource template and a prompt, as they ran with `fastmcp` 4.23.4 (the log's text is ours): ```ts logs.ts import { server } from "./server.js"; server.addResource({ uri: "file:///logs/app.log", name: "Application Logs", mimeType: "text/plain", async load() { return { text: "Started on port 8080" }; }, }); server.addResourceTemplate({ uriTemplate: "file:///logs/{name}.log", name: "Application Logs", mimeType: "text/plain", arguments: [{ name: "name", description: "Name of the log", required: true }], async load({ name }) { return { text: `Example log content for ${name}` }; }, }); server.addPrompt({ name: "git-commit", description: "Generate a Git commit message", arguments: [{ name: "changes", description: "Git diff or description of changes", required: true }], load: async (args) => { return `Generate a concise but descriptive commit message for these changes:\n\n${args.changes}`; }, }); ``` In FrontMCP each is a class, and `load()`'s body moves into `execute()` as it is. The tests compare the lists and the reads with what FastMCP answered. Initial call configuration: ```js { resource: "file:///logs/server.log" } ``` ```ts logs.resource.ts active import { Resource, ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; @Resource({ uri: "file:///logs/app.log", name: "Application Logs", mimeType: "text/plain" }) export class AppLog extends ResourceContext { async execute() { return { text: "Started on port 8080" }; } } @ResourceTemplate({ uriTemplate: "file:///logs/{name}.log", name: "Application Logs", mimeType: "text/plain" }) export class NamedLog extends ResourceContext<{ name: string }> { async execute(uri: string, { name }: { name: string }) { return { text: `Example log content for ${name}` }; } } ``` ```ts git-commit.prompt.ts import { Prompt, PromptContext } from "@frontmcp/sdk"; @Prompt({ name: "git-commit", description: "Generate a Git commit message", arguments: [{ name: "changes", description: "Git diff or description of changes", required: true }], }) export class GitCommit extends PromptContext { async execute(args: Record) { return `Generate a concise but descriptive commit message for these changes:\n\n${args.changes}`; } } ``` ```ts logs.test.ts import { test, expect } from "@frontmcp/testing"; test("resources/list and resources/templates/list match the recording", async ({ mcp }) => { expect(await mcp.resources.list()).toEqual([{ name: "Application Logs", uri: "file:///logs/app.log", mimeType: "text/plain" }]); expect(await mcp.resources.listTemplates()).toEqual([{ name: "Application Logs", uriTemplate: "file:///logs/{name}.log", mimeType: "text/plain" }]); }); test("both reads give the recorded contents", async ({ mcp }) => { expect((await mcp.resources.read("file:///logs/app.log")).raw.contents).toEqual([ { uri: "file:///logs/app.log", mimeType: "text/plain", text: "Started on port 8080" }, ]); expect((await mcp.resources.read("file:///logs/server.log")).raw.contents).toEqual([ { uri: "file:///logs/server.log", mimeType: "text/plain", text: "Example log content for server" }, ]); }); test("the prompt lists the same argument and gives the same message", async ({ mcp }) => { const gitCommit = (await mcp.prompts.list()).find((prompt) => prompt.name === "git-commit"); expect(gitCommit?.arguments).toEqual([{ name: "changes", description: "Git diff or description of changes", required: true }]); const result = await mcp.prompts.get("git-commit", { changes: "Fix a typo in the README" }); expect(result.messages).toEqual([ { role: "user", content: { type: "text", text: "Generate a concise but descriptive commit message for these changes:\n\nFix a typo in the README" } }, ]); }); ``` A template has no `arguments` list in FrontMCP: its parameters are the placeholders in `uriTemplate`, and `execute()` gets them as its second argument. FastMCP didn't send the list in `resources/templates/list` either. In both, `{name}` matches within one path segment, so `file:///logs/2026/10/app.log` doesn't match this template ([FastMCP](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md?plain=1#L1656), [FrontMCP](https://frontmcp.dev/reference/sdk/resource-template#matching-paths-with-slashes)). The last challenge ports a template that matches across segments. **Before you move on:** the resource and prompt lists, and a read of every URI and template your clients use, match the recording. ## Move state out of the session FastMCP's README keeps per-session state in a `Map` keyed by `context.sessionId`, which comes from the `Mcp-Session-Id` header ([Session ID and Request ID Tracking](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#session-id-and-request-id-tracking)). Ported as it is, it works for clients on 2025-11-25 and earlier over HTTP, which still get a session from FrontMCP. A client on 2026-07-28 has none: `this.context.sessionId` is a new `anon:` id on every request ([`this.context`](https://frontmcp.dev/reference/sdk/context#thiscontextsessionid-is-different-on-every-call)), so the count starts again on every call: Initial call configuration: ```js { tool: "increment_counter", arguments: {} } ``` ```ts counter.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; const sessionCounters = new Map(); // 🚩 Keyed by a session that 2026-07-28 clients don't have @Tool({ name: "increment_counter", description: "Increment a per-session counter", inputSchema: {} }) export class IncrementCounter extends ToolContext { async execute() { const sessionId = this.context.sessionId; const counter = (sessionCounters.get(sessionId) ?? 0) + 1; sessionCounters.set(sessionId, counter); return { content: [{ type: "text", text: `Counter for session ${sessionId}: ${counter}` }] }; } } ``` ```ts counter.test.ts import { test, expect } from "@frontmcp/testing"; test("each call is a new session, so the count never passes 1", async ({ mcp }) => { const first = await mcp.tools.call("increment_counter", {}); const second = await mcp.tools.call("increment_counter", {}); expect(first.text()).toMatch(/^Counter for session anon:.+: 1$/); expect(second.text()).toMatch(/: 1$/); expect(second.text()).not.toBe(first.text()); }); ``` Give the state a handle of its own that the model passes back, as the 2026-07-28 changelog suggests ([MCP Went Stateless](https://frontmcp.dev/blog/mcp-went-stateless#what-it-means-for-a-server)). One tool starts a counter and returns its id; the other takes the id: Initial call configuration: ```js { tool: "start_counter", arguments: {} } ``` ```ts counter.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const counters = new Map(); // ✅ Keyed by an id the model passes to the next call @Tool({ name: "start_counter", description: "Start a counter. Pass its counterId to increment_counter.", inputSchema: {} }) export class StartCounter extends ToolContext { async execute() { const counterId = crypto.randomUUID(); counters.set(counterId, 0); return { counterId }; } } @Tool({ name: "increment_counter", description: "Increment a counter that start_counter returned", inputSchema: { counterId: z.string().describe("The counterId from start_counter") }, }) export class IncrementCounter extends ToolContext { async execute({ counterId }: { counterId: string }) { const counter = counters.get(counterId); if (counter === undefined) this.fail(new PublicMcpError(`There's no counter ${counterId}. Start one with start_counter.`)); counters.set(counterId, counter + 1); return { counterId, counter: counter + 1 }; } } ``` ```ts counter.test.ts import { test, expect } from "@frontmcp/testing"; test("the count goes on across calls, without a session", async ({ mcp }) => { const { counterId } = (await mcp.tools.call("start_counter", {})).json() as { counterId: string }; await mcp.tools.call("increment_counter", { counterId }); const second = await mcp.tools.call("increment_counter", { counterId }); expect(second.json()).toEqual({ counterId, counter: 2 }); }); test("an unknown counterId is refused with a message the model can act on", async ({ mcp }) => { const result = await mcp.tools.call("increment_counter", { counterId: "c-404" }); expect(result).toBeError("PUBLIC_ERROR"); expect(result.text()).toBe("There's no counter c-404. Start one with start_counter."); }); ``` The `Map` lives in one process. With several instances, keep the state where every instance can read it ([Running Several Instances](https://frontmcp.dev/learn/running-several-instances)). State that belongs to a signed-in user can be keyed by `this.auth.user.sub` instead, once an [auth mode](https://frontmcp.dev/learn/migrating-from-fastmcp#choose-auth) identifies users. **Before you move on:** call each tool that keeps state twice, as a 2026-07-28 client, and check that the second call sees the first. ## Choose auth A FrontMCP server is `public` by default: every request gets in. Choose a mode before you move a protected server ([Auth modes](https://frontmcp.dev/reference/auth/modes)). - **An API key.** FastMCP's README checks an `x-api-key` header in `authenticate()` ([Custom Authentication](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#custom-authentication)). FrontMCP's `static` mode checks the same header, without code, with `header: "x-api-key"` and `scheme: ""` ([Static keys in a header of your own](https://frontmcp.dev/reference/auth/tokens#static-keys-in-a-header-of-your-own)). What tools see changes: FastMCP passed whatever `authenticate()` returned as `session`; in `static` mode, `this.auth.user.sub` is `static:` and 12 hex characters for each key, and every key gets the mode's `scopes` ([What each auth mode fills in](https://frontmcp.dev/reference/sdk/auth#what-each-auth-mode-fills-in)). - **Roles and scopes per user.** Accept JWTs from your identity provider in `transparent` mode ([Tokens](https://frontmcp.dev/reference/auth/tokens#verifying-jwts-from-your-identity-provider)), and replace `canAccess` with `authorities` on the tool ([Authorities](https://frontmcp.dev/reference/auth/authorities#authorities-on-an-entry)). - **Sign-in with a provider.** FastMCP's OAuth proxy has presets for Google, GitHub and Azure. FrontMCP's `remote` mode takes an OpenID Connect provider you configure; GitHub, whose users have no `sub`, [can't be used](https://frontmcp.dev/reference/auth/remote#pointing-frontmcp-at-your-providers-endpoints). `local` mode makes FrontMCP the sign-in page, and you check the user in `authenticate` ([Local auth](https://frontmcp.dev/reference/auth/local#checking-users-yourself)). The Playground's client can't send credentials, so none of this runs on this page; each linked reference page runs its mode. **Before you move on:** against a running server, a request without a key and one with a wrong key are refused, a request with a key gets through, and a tool a caller may not use is refused when called by name, not only left out of `tools/list` ([Hiding versus refusing](https://frontmcp.dev/reference/auth/authorities#hiding-versus-refusing)). ## Serve the same endpoint, then switch clients Clients reach the server at a URL, or start it with a command. Keep the URL, and update the command: - **The HTTP path.** FastMCP serves `/mcp` unless you set `httpStream.endpoint` ([HTTP Streaming](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md?plain=1#L141-L143)). FrontMCP serves the root path unless you set `http.entryPath`, so set `entryPath: "/mcp"` and the same `port` ([`http`](https://frontmcp.dev/reference/sdk/frontmcp#http)). - **SSE.** FastMCP also starts an SSE endpoint at `/sse` ([HTTP Streaming](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md?plain=1#L147)). FrontMCP's Node server serves the older HTTP+SSE transport by default ([`transport`](https://frontmcp.dev/reference/sdk/frontmcp#transport)) at `/sse`. Point an SSE client there and post to the message URL the stream names, including its `sessionId`. The message endpoint accepts any `Accept` value, or none. Changed in 1.10.11: before, `initialize` posts with `*/*` or `application/*` got `404`; see [client troubleshooting](https://frontmcp.dev/reference/deployment/mcp-clients#cannot-post-mcpmessage). Try the client before you switch it. - **stdio.** A client that starts the server as a command needs the new command. FrontMCP connects over stdio when `FRONTMCP_STDIO=1` is set ([Starting the server](https://frontmcp.dev/reference/sdk/frontmcp#starting-the-server), [Over stdio](https://frontmcp.dev/reference/deployment/mcp-clients#over-stdio)). - **Health checks.** FastMCP 4.23.4 answers `/health` with the text `✓ Ok`, although its [Health-check Endpoint](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#health-check-endpoint) section says `ok`. FrontMCP answers `/health` and `/healthz` with JSON ([Health and metrics](https://frontmcp.dev/reference/deployment/health-and-metrics)), so a probe that reads the body needs changing. - **The address it listens on.** FrontMCP listens on `127.0.0.1` by default, which a published container port can't reach; set `FRONTMCP_BIND_ADDRESS=all` there ([Node](https://frontmcp.dev/reference/deployment/node)). - **Readiness.** FastMCP answers `/ready`. FrontMCP answers `/readyz`, and `/ready` is `404` ([Health and metrics](https://frontmcp.dev/reference/deployment/health-and-metrics)). - **Browser clients.** FastMCP answers a browser's preflight to `/mcp` from any origin. FrontMCP sends no CORS headers until you set `http.cors` or `FRONTMCP_CORS_ORIGINS` ([`http`](https://frontmcp.dev/reference/sdk/frontmcp#http), [config files](https://frontmcp.dev/reference/server/config-files)). - **Production mode.** Run FrontMCP with `NODE_ENV=production`. Without it, error results such as `INVALID_INPUT` and `TOOL_NOT_FOUND` carry the stack, with the server's file paths, in `_meta.stack`. Then switch clients the way [Migrating from the TypeScript SDK](https://frontmcp.dev/learn/migrating-from-the-typescript-sdk#verify-the-endpoint-before-switching-clients) describes: keep FastMCP serving while FrontMCP runs in preview, call the exact endpoint as a client that starts with `initialize` and as one on 2026-07-28, move a small group of clients first, and keep the FastMCP deployment until you no longer need to roll back. **Before you move on:** every client reaches the server at its old URL or with its new command, and the recorded calls give the same answers there as in the tests. ## Recap - Record what FastMCP answers first, with `server.connect()` and the SDK's in-memory client, and turn each answer into a `@frontmcp/testing` test. - `addTool`, `addResource`, `addResourceTemplate` and `addPrompt` become `@Tool`, `@Resource`, `@ResourceTemplate` and `@Prompt` classes, listed in an `@App` on a `@FrontMcp` server. - A tool that returned a string returns `{ content: [{ type: "text", text }] }` to keep its text. - `UserError` becomes `PublicMcpError`; `log`, `reportProgress` and `elicit` become `this.notify()`, `this.progress()` and `this.elicit()`. Log messages are opt-in in FrontMCP. - Invalid input is an `INVALID_INPUT` tool result in FrontMCP, not a `-32602` error. - State keyed by the session starts over for 2026-07-28 clients: key it by a handle or the signed-in user. - Choose an auth mode, set `entryPath: "/mcp"`, and keep FastMCP serving until clients have moved. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Keep a tool's text This FastMCP tool returned `` `Hello, ${name}!` `` as a string, and was ported as it was. Clients read the text, so make `greet` send `Hello, Ada!` as the whole text, with no `structuredContent`, as FastMCP did. Initial call configuration: ```js { tool: "greet", arguments: { name: "Ada" } } ``` ```ts greet.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "greet", description: "Greet someone by name", inputSchema: { name: z.string() } }) export class Greet extends ToolContext { async execute({ name }: { name: string }) { return `Hello, ${name}!`; } } ``` **Solution file (alternative to the starter):** ```ts greet.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "greet", description: "Greet someone by name", inputSchema: { name: z.string() } }) export class Greet extends ToolContext { async execute({ name }: { name: string }) { return { content: [{ type: "text", text: `Hello, ${name}!` }] }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts greet.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("the text is `Hello, Ada!` and nothing else", async ({ mcp }) => { const result = await mcp.tools.call("greet", { name: "Ada" }); expect(result.raw.content).toEqual([{ type: "text", text: "Hello, Ada!" }]); }); test("there's no `structuredContent`", async ({ mcp }) => { const result = await mcp.tools.call("greet", { name: "Ada" }); expect(result.raw.structuredContent).toBeUndefined(); }); ``` **Hint:** Return the content block FastMCP built from the string. **Solution:** A string is wrapped as `{ "value": … }`; a result with a `content` array is sent as it is, so the text is the greeting and there's no `structuredContent`. ### Challenge: Port a UserError FastMCP had `UserError`, and this port throws a plain `Error` in its place. Make a blocked URL fail with `This URL is not allowed` as the whole text, the way `UserError` did, in development and in production. Initial call configuration: ```js { tool: "download", arguments: { url: "https://example.com/report.pdf" } } ``` ```ts download.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "download", description: "Download a file", inputSchema: { url: z.string() } }) export class Download extends ToolContext { async execute({ url }: { url: string }) { if (url.startsWith("https://example.com")) { throw new Error("This URL is not allowed"); } return { content: [{ type: "text", text: "done" }] }; } } ``` **Solution file (alternative to the starter):** ```ts download.tool.ts solution import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "download", description: "Download a file", inputSchema: { url: z.string() } }) export class Download extends ToolContext { async execute({ url }: { url: string }) { if (url.startsWith("https://example.com")) { this.fail(new PublicMcpError("This URL is not allowed")); } return { content: [{ type: "text", text: "done" }] }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts download.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("a blocked URL fails with `This URL is not allowed` as the whole text", async ({ mcp }) => { const result = await mcp.tools.call("download", { url: "https://example.com/report.pdf" }); expect(result).toBeError(); expect(result.raw.content).toEqual([{ type: "text", text: "This URL is not allowed" }]); }); test("the error is public, so production keeps its message", async ({ mcp }) => { const result = await mcp.tools.call("download", { url: "https://example.com/report.pdf" }); expect(result).toBeError("PUBLIC_ERROR"); }); test("another URL still answers `done`", async ({ mcp }) => { const result = await mcp.tools.call("download", { url: "https://files.example.org/report.pdf" }); expect(result.text()).toBe("done"); }); ``` **Hint:** FrontMCP's public error class is exported from `@frontmcp/sdk`. **Solution:** A plain `Error` is a server failure: in development its text starts with `Tool "download" execution failed:` and includes the stack, and production hides it. `PublicMcpError` is meant for the model, so its message is the whole text everywhere, with `PUBLIC_ERROR` in `_meta.code`. ### Challenge: Read logs in subfolders The logs moved into dated folders, like `file:///logs/2026/10/app.log`, and the template should read them with `name` as `2026/10/app`. FastMCP's README uses `{+name}` for a value that spans segments; do the same here, without breaking `file:///logs/server.log`. Initial call configuration: ```js { resource: "file:///logs/server.log" } ``` ```ts logs.resource.ts import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; @ResourceTemplate({ uriTemplate: "file:///logs/{name}.log", name: "Application Logs", mimeType: "text/plain" }) export class NamedLog extends ResourceContext<{ name: string }> { async execute(uri: string, { name }: { name: string }) { return { text: `Example log content for ${name}` }; } } ``` **Solution file (alternative to the starter):** ```ts logs.resource.ts solution import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; @ResourceTemplate({ uriTemplate: "file:///logs/{+name}.log", name: "Application Logs", mimeType: "text/plain" }) export class NamedLog extends ResourceContext<{ name: string }> { async execute(uri: string, { name }: { name: string }) { return { text: `Example log content for ${name}` }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts logs.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`file:///logs/2026/10/app.log` reads the log named `2026/10/app`", async ({ mcp }) => { const result = await mcp.resources.read("file:///logs/2026/10/app.log"); expect(result.text()).toBe("Example log content for 2026/10/app"); }); test("`file:///logs/server.log` still reads the log named `server`", async ({ mcp }) => { const result = await mcp.resources.read("file:///logs/server.log"); expect(result.text()).toBe("Example log content for server"); }); ``` **Hint:** Only the placeholder in `uriTemplate` changes. **Solution:** `{name}` stops at the first `/`, in FrontMCP as in FastMCP. `{+name}` takes the rest of the path up to `.log`, slashes included. --- # Evaluating CodeCall > Compare the same CRM task through direct tool calls and CodeCall. Measure bytes and calls, separate discovery overhead, and record model evaluation independently. Source: https://frontmcp.dev/learn/evaluating-codecall [Covers FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) as of October 26, 2026 Measure the workload you need to support before you add FrontMCP's CodeCall plugin to a server. This evaluation uses the published 47-tool CRM and its synthetic data. It runs the same scripted, read-only task two ways: through direct tool calls, and through one server-side script. It checks that both paths return the same answer. It does not measure model tokens, hosted latency, cost, or whether a model can write the script. **You will learn** - How to run the fixture that compares direct calls with CodeCall - What each output field measures, and what it can't tell you - What the recorded run on 2026-10-22 found - What a model evaluation needs on top of this fixture ## Run the fixture [Download the evaluation project](https://frontmcp.dev/downloads/crm-evaluation.tgz), extract it, and follow its README. It pins the dependencies and the fixture. With Node.js 24 or later, in the extracted folder, run: ```bash npm ci npm run evaluate -- --runs=5 ``` For the direct baseline, the harness removes only the plugin's registration. Both servers use the same tool implementations. A scripted client stands in for the model: it makes the same tool choices a model would have to make. A small-catalog control makes a single direct lookup, because not every task gains from running a script. ## Read the output | Field | What it measures | What it cannot establish | | --- | --- | --- | | catalog bytes | UTF-8 bytes of JSON tool definitions | Model tokenizer counts or billing | | direct response bytes | Sum of result envelopes from the underlying calls | Which content a particular client includes in its prompt | | discovery response bytes | Search and schema response envelopes | Free or zero-overhead discovery | | CodeCall response bytes | The execution result envelope | Total context use without discovery and request sizes | | direct calls / execution calls | Requests made by the scripted fixture | Number of model reasoning steps | | directMs / scriptMs | In-process time after server startup | Cold deployment or hosted-model latency | Runs happen one after another, after the servers start. The first call and later calls can differ because of cache state. So the output keeps every run, rather than one number that hides the spread. Compare the runs before you combine them. A failed assertion makes the harness exit with an error. Don't leave wrong results out of an evaluation. ## Recorded results The [JSON record](https://frontmcp.dev/evidence/codecall.json) holds one capture of five runs. The tables below report it as written. Times are rounded to 0.01 ms; the JSON keeps full precision. | Recorded | Value | | --- | --- | | Observed on | 2026-10-22 | | `@frontmcp/sdk` | 1.10.10 | | `@frontmcp/plugin-codecall` | 1.10.10 | | Node.js | v24.18.1 | | Platform | `darwin`, `arm64` | | Workload | 47-tool CRM; identical scripted read-only task; catalog and parsed result JSON UTF-8 bytes | | Runs | 5 | | Failures | None recorded. The harness writes no record if any assertion fails. | The record's `sourceHash` is the SHA-256 of `scripts/evaluate-codecall.mjs` when it was captured. Compare it with the script you run to see whether the harness has changed since. | Catalog from `tools/list` | Tools | Bytes | | --- | --- | --- | | Direct, without the plugin | 47 | 43,251 | | With CodeCall | 6 | 15,999 | Every run made the same calls and returned the same number of bytes: | Step, in each of the 5 runs | Calls | Response bytes | | --- | --- | --- | | Direct tool calls | 8 | 4,194 | | CodeCall discovery: `codecall:search` and `codecall:describe` | 2 | 16,467 | | CodeCall execution: `codecall:execute` | 1 | 522 | | Small-catalog control: one direct `get_agent` call | 1 | Not recorded | Times, in milliseconds. `directMs` times the scripted task through its eight direct calls. `scriptMs` times only the `codecall:execute` call, without discovery. | Run | `directMs` | `scriptMs` | | --- | --- | --- | | 1 | 9.20 | 21.22 | | 2 | 3.75 | 6.69 | | 3 | 3.11 | 6.24 | | 4 | 2.67 | 5.50 | | 5 | 3.02 | 5.43 | What the record shows: - Both paths returned the same answer in every run. - The CodeCall catalog was 15,999 bytes for 6 tools. Without the plugin, it was 43,251 bytes for 47 tools. - CodeCall's two discovery calls returned 16,467 bytes. That is more than the 4,194 bytes of all eight direct responses. The execution result was 522 bytes. - `scriptMs` was higher than `directMs` in every run. Run 1 was the slowest on both paths. > **Note: Limits of this record** > > The runs were sequential and in-process, after server startup. A first execution may include lazy initialization. The record measures no model, tokens, cost, hosted network or answer quality. It counts discovery separately from execution. The same script stands in for a model making direct calls. Bytes are UTF-8 bytes of JSON, not tokens. ## Add a model evaluation deliberately Record the exact prompt, the model and its version, the settings, the catalog, how the client behaves, the execution limits and a fixed set of tasks. Include simple lookups, joins, ambiguous requests and refused operations. Measure real input and output tokens, tool requests, whether the answers are correct, and wall time. Repeat the runs cold and warm. Keep any API price math dated, and separate from those measurements. Publish failures and limits next to the results. Don't turn a fixture's byte reduction into a percentage token saving, a security certification or a universal speedup. Tool authorization still applies on both paths. Read [CodeCall's limits](https://frontmcp.dev/reference/plugins/codecall) before you turn on scripts. ## Next step Look through [the complete CRM](https://frontmcp.dev/examples/crm-with-codecall). Then replace its synthetic data with a read-only workload of your own. If direct calls already give a small answer, keep that simpler path. --- # Upgrading a server > Identify an installed FrontMCP version, read matching changes, compare a pinned fixture before and after upgrade, and keep a rollback path. Source: https://frontmcp.dev/learn/upgrading-a-server [Covers FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) as of October 26, 2026 Upgrade the FrontMCP packages together, then compare the client contract you depend on. The SDK release, the MCP protocol revision and the client version are three separate values. Documentation hashes show which pages changed. ## Identify the installed release Read the resolved SDK package in your project, or use the docs server's `resolve_version`. In a monorepo with several installations, pass the npm/pnpm workspace path or an exact `installedVersion`. When a lockfile is ambiguous, the resolver returns an error. It doesn't pick the first package record. A manifest range lists the versions allowed, not the one installed. ```json { "workspace": "packages/api", "packageJson": "", "lockfile": "" } ``` Keep lockfiles local, unless you choose to share the parts that matter. The resolver needs no credentials, and not your whole repository. ## Read the changes and establish a baseline Pin a docs endpoint to the installed version. Call `whats_changed` with the old and the target release. Then read the reference pages for the APIs that changed. Check companion plugins and adapters too. Before you change dependencies, run your existing contract, access and deployment checks. Save their output with secrets and private data removed, along with the versions and the date. How often releases ship doesn't tell you whether an API changed. So for each change that affects your server, record four things: the old behavior, the new behavior, whether application or client code must change, and the migration check that covers it. Keep additions, fixes and intended breaking changes apart. The [production evaluation](https://frontmcp.dev/compare/production-readiness#assess-maintenance-and-adoption) explains what release and adoption signals can and can't tell you. ## Reproduce a bounded upgrade The repository's upgrade check uses the reporting download as its fixture. It installs FrontMCP 1.9.2, runs the fixture's tests and captures its tool contracts. Then it installs the documented release and runs the same checks again. It keeps the output from before and after, and reports any differences. ```bash node scripts/check-upgrade.mjs ``` The check installs dependencies in a temporary project. It needs network access to npm, and Node.js 24 or later. Its README and script are in the [evaluation download](https://frontmcp.dev/downloads/crm-evaluation.tgz). ## Inspect the recorded contract The [before/after record](https://frontmcp.dev/evidence/upgrade.json) names both releases. It lists the actual schemas, a successful result and an expected refusal. It also records whether the selected contract changed. The same fixture code is installed separately with each set of dependencies. > **Note: What these checks don't cover** > > - Documentation hashes don't test your server's behavior. > - The upgrade check covers one fixture, and its record is one local observation. It reports differences. It doesn't show that every application is safe to upgrade, and it isn't a support guarantee. > - It doesn't deploy or change your application, and it doesn't test a production database migration. > - A passing documentation test doesn't replace your application's own checks. ## Upgrade and roll back deliberately Update the SDK, CLI, testing and companion packages to releases that work together. Regenerate the lockfile, run the contract and auth checks, and build for your target. In preview, try real client connections and widget behavior. Keep the previous build artifact, configuration and lockfile at hand. Database migrations need their own plan to roll back or recover forward. If you change a contract on purpose, record the result before and after, and update the affected clients yourself. See [Versions](https://frontmcp.dev/versions) and [release notes](https://frontmcp.dev/releases). --- # Describing Capabilities > How to describe what your MCP server can do, so AI models use it well. Tools, schemas, results, resources, prompts and apps. Source: https://frontmcp.dev/learn/describing-capabilities [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 An MCP server is only as useful as a model's understanding of it. Models can't read your code; they read the names, descriptions and schemas you give them. This chapter is about describing a FrontMCP server's capabilities so that models call the right one, with the right arguments, and understand the answer. **In this chapter** - [How to write a tool a model can use](https://frontmcp.dev/learn/your-first-tool) - [How schemas act as the contract between the model and your code](https://frontmcp.dev/learn/schemas-are-contracts) - [How to shape tool results so clients can use them](https://frontmcp.dev/learn/shaping-tool-results) - [How to expose data with resources](https://frontmcp.dev/learn/exposing-data-with-resources) - [How to write prompts users can reuse](https://frontmcp.dev/learn/reusable-prompts) - [How to group capabilities into apps](https://frontmcp.dev/learn/grouping-capabilities-into-apps) - [How to write descriptions agents understand](https://frontmcp.dev/learn/writing-descriptions-agents-understand) ## Your first tool A tool is a function the model can choose to call. Its name, description and input schema are all the model knows about it, so a tool with a vague name and no description is nearly invisible, however good the code is. ### [Your First Tool](https://frontmcp.dev/learn/your-first-tool) Learn the four parts of a `@Tool`, what FrontMCP checks before your code runs, and how to return errors the model can act on. ## Schemas are contracts Every argument you declare with Zod becomes part of the JSON Schema the model reads, and every call is checked against it before `execute()` runs. Constraints belong in the schema, not in `if` statements, because the model can see a schema and can't see your code. Initial call configuration: ```js { tool: "schedule_callback", arguments: { phone: "+971 4 555 0100", when: "tomorrow_morning" } } ``` ```ts schedule.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "schedule_callback", description: "Schedule a phone call back to a customer", inputSchema: { phone: z.string().min(7).describe("Phone number with country code"), when: z.enum(["today", "tomorrow_morning", "tomorrow_afternoon"]), note: z.string().max(200).optional(), }, }) export class ScheduleCallback extends ToolContext { async execute(input: { phone: string; when: "today" | "tomorrow_morning" | "tomorrow_afternoon"; note?: string }) { return { scheduled: true, ...input }; } } ``` ### [Schemas Are Contracts](https://frontmcp.dev/learn/schemas-are-contracts) Learn what the model reads from a Zod schema, why rules belong in the schema instead of `if` statements, and how optional fields and defaults appear in `tools/list`. ## Shaping tool results A tool can return plain values or objects. Declaring `outputSchema` tells clients the shape of the result before they call, and the result arrives as `structuredContent` that programs can use directly. The [Quick Start](https://frontmcp.dev/learn#returning-structured-results) shows an example. ### [Shaping Tool Results](https://frontmcp.dev/learn/shaping-tool-results) Learn what `content` and `structuredContent` carry, when to declare `outputSchema`, how to return only what the model needs, and how to fail in a way the model can act on. ## Exposing data with resources Not everything should be a tool. Data that an application attaches to a conversation, like a document, a record or a policy, is a **resource**: addressed by a URI and read with `resources/read`. `@ResourceTemplate` covers a family of URIs such as `ticket://{id}`. Initial call configuration: ```js { resource: "docs://refund-policy" } ``` ```ts policy.resource.ts import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "refund-policy", uri: "docs://refund-policy", mimeType: "text/markdown", description: "When customers can get a refund", }) export class RefundPolicy extends ResourceContext { async execute(uri: string) { return { contents: [{ uri, mimeType: "text/markdown", text: "# Refunds\n\nFull refund within 30 days of purchase." }], }; } } ``` ### [Exposing Data with Resources](https://frontmcp.dev/learn/exposing-data-with-resources) Learn when data should be a resource instead of a tool, how fixed resources and URI templates work, and what clients see in `resources/list` and `resources/read`. ## Reusable prompts A **prompt** is a message template that a user picks, often from a slash menu in their client, with arguments they fill in. Prompts are how you package the instructions that work best with your tools. ### [Reusable Prompts](https://frontmcp.dev/learn/reusable-prompts) Learn how to declare a prompt and its arguments, return several messages, and bring ticket data into the conversation. ## Grouping capabilities into apps An `@App` bundles related tools, resources, prompts and providers, and a `@FrontMcp` server can host several apps on one endpoint. The [Quick Start](https://frontmcp.dev/learn#grouping-capabilities-into-an-app-and-a-server) shows the three files involved. ### [Grouping Capabilities into Apps](https://frontmcp.dev/learn/grouping-capabilities-into-apps) Learn how apps group capabilities, how a server composes several apps, what happens when two apps use the same name, and how apps share a provider. ## Writing descriptions agents understand Descriptions are prompts. They decide whether a model picks your tool, which of two similar tools it picks, and how it fills in arguments. ### [Writing Descriptions Agents Understand](https://frontmcp.dev/learn/writing-descriptions-agents-understand) Learn what to put in a tool's description and its arguments' descriptions, how to say when *not* to use a tool, and how to test descriptions in `tools/list`. ## What's next? Start the chapter with [Your First Tool](https://frontmcp.dev/learn/your-first-tool). After it, [Talking to Clients](https://frontmcp.dev/learn/talking-to-clients) covers what happens during a call: asking the user for input and reporting progress. --- # Your First Tool > Build a typed MCP tool with FrontMCP, validate input with Zod and return results and errors a client can use. Source: https://frontmcp.dev/learn/your-first-tool [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 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. In FrontMCP they're three fields of `@Tool`, and most of writing a good tool is writing them 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: ```ts lookup.tool.ts 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"; } } ``` 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: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-2" } } ``` ```ts get-ticket.tool.ts 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"; } } ``` 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](https://frontmcp.dev/learn#sharing-a-service-with-a-provider). > **Note** > > `@Tool` also checks your types when TypeScript compiles. Type `execute()`'s parameter as `{ id: number }` against `z.string()`, or leave out `extends ToolContext`, and the decorator line gets an error that starts `Unable to resolve signature of class decorator`. The rest of the message names the problem, like `Parameter type does not match the input schema.` The Playground runs your code without type-checking, so you'll see these errors in your editor, not here. > **Note** > > For small tools that don't need providers or other context, FrontMCP also has a function form: `tool({ name, description, inputSchema })(async (input) => ...)`. This course uses classes, because most real tools grow into needing context. ## What happens when a tool is called When the model decides to use your tool, FrontMCP stands between the call and your code: *[Illustration: Seven steps across three lanes. The client sends tools/call with the tool's name and the model's arguments. FrontMCP finds the tool by name, then checks the arguments against inputSchema and drops unknown fields; if they don't match, the client gets an isError result, INVALID_INPUT, listing each problem, and execute never runs. Your code's execute runs with the validated arguments. FrontMCP checks the result against outputSchema if the tool has one; a mismatch returns INVALID_OUTPUT. FrontMCP wraps the result as structuredContent plus a text block, and the client hands it to the model.]* 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: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "ticket two" } } ``` ```ts get-ticket.tool.ts 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"; } } ``` 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. ### What does the model actually receive? 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: ```json { "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: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-9" } } ``` ```ts get-ticket.tool.ts 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; } } ``` `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. > **Pitfall: Raw errors are hidden in production** > > If `execute()` throws a plain `Error`, what the client sees depends on the environment. In development, FrontMCP includes the message and stack trace to help you debug, which is what the Playground shows. In production (`NODE_ENV=production`) it replaces them with "Internal FrontMCP error. Please contact support with error ID: …", so database errors and secrets don't leak to the model. Use `PublicMcpError` for anything the model should read. ## 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: 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. ```ts search.tool.ts 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())) }; } } ``` **Solution file (alternative to the starter):** ```ts search.tool.ts solution 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: "search_tickets", description: "Search support tickets by words in their title. Returns matching tickets with their status.", inputSchema: { query: z.string().describe("Words to look for in ticket titles") }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts search.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("the tool is called search_tickets", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("search_tickets"); }); test("it has a description", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "search_tickets"); expect(tool?.description?.length ?? 0).toBeGreaterThan(20); }); test("its input is a described `query`", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "search_tickets"); expect(tool?.inputSchema.required).toEqual(["query"]); expect(tool?.inputSchema.properties?.query).toHaveProperty("description"); }); test("searching for \"log\" finds both login tickets", async ({ mcp }) => { const result = await mcp.tools.call("search_tickets", { query: "log" }); expect(result).toBeSuccessful(); expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-1", "T-3"]); }); ``` **Hint:** Remember to rename the field in both places: `inputSchema` and the `execute()` parameter. **Solution:** The name says what it does, the description says what comes back, and `query` explains itself in the schema the model reads. The Playground above now shows the solution; press **Check** to see it pass. ### Challenge: Add an optional filter Let the model narrow results to open or closed tickets. Add a `status` argument that can be `"open"` or `"closed"`, and leave it optional so the model can omit it. Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts search.tool.ts 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: "closed" }, ]; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title.", inputSchema: { query: z.string() }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) }; } } ``` **Solution file (alternative to the starter):** ```ts search.tool.ts solution 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: "closed" }, ]; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Optionally only open or only closed tickets.", inputSchema: { query: z.string(), status: z.enum(["open", "closed"]).optional().describe("Leave out to include both"), }, }) export class SearchTickets extends ToolContext { async execute({ query, status }: { query: string; status?: "open" | "closed" }) { const q = query.toLowerCase(); return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(q) && (!status || t.status === status)) }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts search.test.ts hidden import { test, expect } from "@frontmcp/testing"; const ids = (result: { json(): any }) => result.json().tickets.map((t: { id: string }) => t.id); test("`status` is in the schema, as \"open\" or \"closed\"", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "search_tickets"); expect(tool?.inputSchema.properties?.status?.enum).toEqual(["open", "closed"]); }); test("`status` is optional", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "search_tickets"); expect(tool?.inputSchema.required ?? []).not.toContain("status"); expect(ids(await mcp.tools.call("search_tickets", { query: "log" }))).toEqual(["T-1", "T-3"]); }); test("status \"open\" leaves out closed tickets", async ({ mcp }) => { expect(ids(await mcp.tools.call("search_tickets", { query: "log", status: "open" }))).toEqual(["T-1"]); }); test("status \"closed\" leaves out open tickets", async ({ mcp }) => { expect(ids(await mcp.tools.call("search_tickets", { query: "log", status: "closed" }))).toEqual(["T-3"]); }); ``` **Hint:** `z.enum(["open", "closed"]).optional()` makes a field the model may leave out. Its type in `execute()` is `"open" | "closed" | undefined`. **Solution:** `z.enum()` puts the allowed values in the JSON Schema, so the model knows exactly what it may send, and `.optional()` keeps `status` out of `required`. In `execute()`, a missing `status` means "don't filter". ### Challenge: Reject queries that are too short A one-letter query matches almost everything. Make FrontMCP reject any `query` shorter than 3 characters, without adding a check inside `execute()`. Then call the tool with `"a"` to see the error. Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "a" } } ``` ```ts search.tool.ts 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: "search_tickets", description: "Search support tickets by words in their title.", inputSchema: { query: z.string() }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) }; } } ``` **Solution file (alternative to the starter):** ```ts search.tool.ts solution 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: "search_tickets", description: "Search support tickets by words in their title.", inputSchema: { query: z.string().min(3).describe("At least 3 characters") }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts search.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("a 1-letter query is rejected", async ({ mcp }) => { const result = await mcp.tools.call("search_tickets", { query: "a" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("INVALID_INPUT"); }); test("the rule is in the schema the model reads", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "search_tickets"); expect(tool?.inputSchema.properties?.query?.minLength).toBe(3); }); test("a 3-letter query still works", async ({ mcp }) => { const result = await mcp.tools.call("search_tickets", { query: "log" }); expect(result).toBeSuccessful(); expect(result.json().tickets).toHaveLength(1); }); ``` **Solution:** Put the rule in the schema with `.min(3)`. FrontMCP rejects the call with `INVALID_INPUT` before `execute()` runs, and because the rule is in `tools/list` as `minLength`, a model can follow it before it ever calls the tool. A check inside `execute()` would only tell it afterwards. ### Challenge: Tell clients the tool is read-only Searching never changes anything. Add the annotation that tells clients so, and find where it shows up in the Capabilities tab. ```ts search.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title.", inputSchema: { query: z.string().min(3) }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [] as { id: string; title: string }[], query }; } } ``` **Solution file (alternative to the starter):** ```ts search.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title.", inputSchema: { query: z.string().min(3) }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [] as { id: string; title: string }[], query }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts search.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("tools/list marks search_tickets read-only", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "search_tickets"); expect(tool?.annotations).toMatchObject({ readOnlyHint: true }); }); test("it doesn't claim to be destructive", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "search_tickets"); expect(tool?.annotations?.destructiveHint).not.toBe(true); }); ``` **Hint:** Annotations go in `@Tool({ annotations: { ... } })`. MCP defines `readOnlyHint`, `destructiveHint`, `idempotentHint` and `openWorldHint`. **Solution:** `annotations: { readOnlyHint: true }` goes out with the tool in `tools/list`, and the card now shows a **read-only** chip. Hints are hints: clients may use them to decide how carefully to confirm a call, but they don't enforce anything. --- # Schemas Are Contracts > How a tool's Zod input schema becomes the JSON Schema a model reads, what FrontMCP checks before execute() runs, and how to write schemas models fill in correctly. Source: https://frontmcp.dev/learn/schemas-are-contracts [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A tool's `inputSchema` is a contract. The model promises to send arguments that match it, FrontMCP checks that promise before your code runs, and `execute()` gets exactly the fields you declared. The model can only keep promises it can see, so every rule and every hint about what a field means belongs in the schema, where the model reads it, not in your code, where it can't. **You will learn** - How a Zod schema becomes the JSON Schema a model reads - Why limits belong in the schema instead of in `if` statements - What FrontMCP checks, drops and passes on before `execute()` runs - What the model sees for optional fields and defaults - How to declare nested objects and lists ## What the model reads This tool opens a support ticket, and it works. Open the **Capabilities** tab to see what a model gets to work with: ```ts create-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "create_ticket", description: "Open a new support ticket for a customer.", inputSchema: { title: z.string(), customer: z.string(), priority: z.string(), }, }) export class CreateTicket extends ToolContext { async execute(input: { title: string; customer: string; priority: string }) { return { id: "T-4", status: "open", ...input }; } } ``` A user writes: "Dana from Acme can't log in, and it's urgent." The model has to fill in three strings. Is `customer` a name (`"Dana"`), a company (`"Acme"`), or an email address? Is `priority` `"urgent"`, `"high"` or `"P1"`? Nothing says, so the model guesses, and your code stores whatever it guessed. `.describe()` attaches a note to a field. Say what the value is, in what format, with an example: Initial call configuration: ```js { tool: "create_ticket", arguments: { title: "Cannot log in", customer: "dana@acme.com", priority: "high" } } ``` ```ts create-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "create_ticket", description: "Open a new support ticket for a customer.", inputSchema: { title: z.string().describe("One-line summary of the problem"), customer: z.string().describe("The customer's email address, like dana@acme.com"), priority: z.string().describe("low, normal or high. Use high only when the customer can't work at all."), }, }) export class CreateTicket extends ToolContext { async execute(input: { title: string; customer: string; priority: string }) { return { id: "T-4", status: "open", ...input }; } } ``` FrontMCP converts `inputSchema` to [JSON Schema](https://json-schema.org) and sends it in `tools/list`. Open the **Wire** tab and expand `tools/list` to see it. This is the part the model reads: ```json { "name": "create_ticket", "description": "Open a new support ticket for a customer.", "inputSchema": { "type": "object", "properties": { "title": { "type": "string", "description": "One-line summary of the problem" }, "customer": { "type": "string", "description": "The customer's email address, like dana@acme.com" }, "priority": { "type": "string", "description": "low, normal or high. Use high only when ..." } }, "required": ["title", "customer", "priority"] } } ``` That JSON is the whole contract. The model never sees your Zod code, your types or your `execute()`. Every field is in `required` because none of them is marked optional. A good `.describe()` note says what the name can't: the format (`an email address`), an example (`like dana@acme.com`), the unit, or where the value comes from. Don't repeat the name back: `title: "The title"` tells the model nothing it didn't already know. ### Why import z from @frontmcp/sdk and not from zod? The `z` in `@frontmcp/sdk` is Zod 4 with one difference you rarely notice: `z.object()`, `z.union()` and the other factories for compound schemas put off building the schema until it's first used. A server with many tools starts without building their input schemas; FrontMCP builds them when a client first lists the tools or calls one. Everything else works as in Zod: `.describe()`, `.optional()`, parsing, `instanceof`, `z.infer`. A project made with `frontmcp create` has `zod` 4 installed too, and schemas from `import { z } from "zod"` work in `inputSchema` the same way. Zod 3 schemas don't: the tool is listed with no properties, and every call fails with `INVALID_INPUT`. The one place the difference shows is Zod's own `z.toJSONSchema()`, which throws for a `z` schema with an optional object in it, like `customer: z.object({ email: z.string() }).optional()`. Use `toJSONSchema` from `@frontmcp/sdk` instead, or build that schema with `eagerZ`, which is Zod's own `z`. See [`z`, `eagerZ` and `lazyZ`](https://frontmcp.dev/reference/sdk#z-eagerz-and-lazyz). ## Put rules in the schema, not in `if` statements The `priority` note says "low, normal or high", but nothing enforces it. The obvious fix is a check in `execute()`: Initial call configuration: ```js { tool: "create_ticket", arguments: { title: "Help", customer: "dana", priority: "urgent" } } ``` ```ts create-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "create_ticket", description: "Open a new support ticket for a customer.", inputSchema: { title: z.string().describe("One-line summary of the problem"), customer: z.string().describe("The customer's email address, like dana@acme.com"), priority: z.string().describe("low, normal or high. Use high only when the customer can't work at all."), }, }) export class CreateTicket extends ToolContext { async execute(input: { title: string; customer: string; priority: string }) { if (!["low", "normal", "high"].includes(input.priority)) { this.fail(new PublicMcpError(`priority must be low, normal or high, not "${input.priority}"`)); } if (input.title.length < 5) this.fail(new PublicMcpError("title is too short")); if (!input.customer.includes("@")) this.fail(new PublicMcpError("customer must be an email address")); return { id: "T-4", status: "open", ...input }; } } ``` This call has three problems, and the model hears about one of them. It will fix `priority`, call again, find out about `title`, call a third time, and find out about `customer`. The Capabilities tab still says `priority: string`, so the next conversation starts from the same guess. Here are the same rules as Zod constraints: Initial call configuration: ```js { tool: "create_ticket", arguments: { title: "Help", customer: "dana", priority: "urgent" } } ``` ```ts create-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "create_ticket", description: "Open a new support ticket for a customer.", inputSchema: { title: z.string().min(5).max(120).describe("One-line summary of the problem"), customer: z.string().email().describe("The customer's email address, like dana@acme.com"), priority: z.enum(["low", "normal", "high"]).describe("Use high only when the customer can't work at all."), }, }) export class CreateTicket extends ToolContext { async execute(input: { title: string; customer: string; priority: "low" | "normal" | "high" }) { return { id: "T-4", status: "open", ...input }; } } ``` Three things changed: 1. **The model sees the rules before it calls.** The Capabilities card now shows `"low" | "normal" | "high"` instead of `string`, and `tools/list` carries `minLength`, `maxLength`, `format` and `enum`, so a model can get the arguments right the first time. 2. **All the problems come back at once.** FrontMCP checks every field and returns one `INVALID_INPUT` error that lists each problem with its `path`, so a model can fix everything in one retry. 3. **`execute()` got simpler.** The checks are gone, and `priority` has the narrow type `"low" | "normal" | "high"`. The `priority` note changed too: `z.enum()` already lists the values, so the note only says when to pick `high`. Here is what each common Zod constraint becomes in the JSON Schema the model reads: | Zod | JSON Schema in `tools/list` | | --- | --- | | `z.string().min(5).max(120)` | `"minLength": 5, "maxLength": 120` | | `z.string().regex(/^T-\d+$/)` | `"pattern": "^T-\\d+$"` | | `z.string().email()` | `"format": "email"`, plus a `pattern` | | `z.enum(["low", "normal", "high"])` | `"enum": ["low", "normal", "high"]` | | `z.number().int().min(1).max(50)` | `"type": "integer", "minimum": 1, "maximum": 50` | | `z.array(z.string()).min(1).max(10)` | `"type": "array", "minItems": 1, "maxItems": 10` | | `.describe("...")` | `"description": "..."` | | `.optional()` | the field is left out of `required` | `z.enum()` is worth reaching for whenever a field has a fixed set of values. A model picks from a list far more reliably than it guesses a spelling. ## What reaches `execute()` FrontMCP runs every `tools/call` through your schema before `execute()` runs. If the arguments don't match, `execute()` never runs and the model gets the list of problems you saw above. If they do match, `execute()` gets the parsed result, which is not always exactly what the client sent. This call sends an `assignee` field that isn't in the schema: Initial call configuration: ```js { tool: "create_ticket", arguments: { title: "Cannot log in", customer: "dana@acme.com", priority: "high", assignee: "Nour" } } ``` ```ts create-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "create_ticket", description: "Open a new support ticket for a customer.", inputSchema: { title: z.string().min(5).max(120).describe("One-line summary of the problem"), customer: z.string().email().describe("The customer's email address, like dana@acme.com"), priority: z.enum(["low", "normal", "high"]).describe("Use high only when the customer can't work at all."), }, }) export class CreateTicket extends ToolContext { async execute(input: { title: string; customer: string; priority: "low" | "normal" | "high" }) { // Echo back exactly what execute() received. return { id: "T-4", status: "open", ...input }; } } ``` ```ts create-ticket.test.ts import { test, expect } from "@frontmcp/testing"; const ticket = { title: "Cannot log in", customer: "dana@acme.com", priority: "high" }; test("fields that aren't in the schema are dropped", async ({ mcp }) => { const result = await mcp.tools.call("create_ticket", { ...ticket, assignee: "Nour" }); expect(result).toBeSuccessful(); expect(result.json()).not.toHaveProperty("assignee"); }); test("every problem is reported in one error", async ({ mcp }) => { const result = await mcp.tools.call("create_ticket", { title: "Help", customer: "dana", priority: "urgent" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("INVALID_INPUT"); expect(result.text()).toContain('"title"'); expect(result.text()).toContain('"customer"'); expect(result.text()).toContain('"priority"'); }); test("values aren't converted to the right type", async ({ mcp }) => { const result = await mcp.tools.call("create_ticket", { ...ticket, priority: ["high"] }); expect(result.raw._meta?.code).toBe("INVALID_INPUT"); }); ``` The result has no `assignee`: FrontMCP dropped it before `execute()` ran, without an error. The **Tests** tab checks three rules: - **Unknown fields are dropped, not rejected.** Your code only ever sees the fields you declared, so a client can't slip extra data past your schema. - **Every problem is reported at once.** The error text is `Invalid tool input`, then one entry per problem with the field's `path` and a `message`. - **Nothing is converted.** A value of the wrong type is an error, not something FrontMCP tries to fix: `["high"]` is not `"high"`, and `"5"` is not a number. > **Pitfall: A renamed field disappears without an error** > > Because unknown fields are dropped, renaming a field doesn't produce an "unknown field" error for callers that still use the old name. If you rename `customer` to `customer_email`, a client that sends `customer` gets `customer_email` reported as missing, which is a confusing message. If the new field is optional, the value just vanishes. Treat field names like the tool's name: once models and prompts use them, change them carefully. > **Pitfall: Refinements are invisible to the model** > > `.refine()` runs your own function during validation, but JSON Schema has no way to describe a function, so the model never sees the rule: > > Initial call configuration: > > ```js > { tool: "reply_to_ticket", arguments: { id: "T-1", body: "Can you send me your password so I can check?" } } > ``` > > ```ts reply.tool.ts > import { Tool, ToolContext, z } from "@frontmcp/sdk"; > > @Tool({ > name: "reply_to_ticket", > description: "Send a reply to the customer on a ticket.", > inputSchema: { > id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"), > body: z > .string() > .min(1) > .refine((text) => !/password/i.test(text), "Never ask a customer for their password") > .describe("The reply, in plain text"), > }, > }) > export class ReplyToTicket extends ToolContext { > async execute({ id, body }: { id: string; body: string }) { > return { id, sent: true, length: body.length }; > } > } > ``` > > The call fails with your message, but the Capabilities card only says `body: string`. Keep the refinement, and also say the rule in `.describe()` (`"The reply, in plain text. Never ask for a password."`), so the model can follow it before it calls. Your schema can also clean up values on the way in. `.trim()`, `.toLowerCase()` and `.toUpperCase()` change a string while it's parsed, and `.transform()` runs any function you give it. `tools/list` describes what a client should send, and `execute()` gets the value after the clean-up: Initial call configuration: ```js { tool: "create_ticket", arguments: { title: "Cannot log in", customer: "Dana@Acme.com" } } ``` ```ts create-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "create_ticket", description: "Open a new support ticket for a customer.", inputSchema: { title: z.string().min(5).max(120).describe("One-line summary of the problem"), customer: z .string() .email() .transform((email) => email.toLowerCase()) .describe("The customer's email address, like dana@acme.com"), }, }) export class CreateTicket extends ToolContext { async execute(input: { title: string; customer: string }) { return { id: "T-4", status: "open", ...input }; } } ``` ```ts create-ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("the model sees `customer` as an email address", async ({ mcp }) => { const [tool] = await mcp.tools.list(); expect(tool.inputSchema.properties?.customer).toMatchObject({ type: "string", format: "email" }); expect(tool.inputSchema.required).toEqual(["title", "customer"]); }); test("execute() gets it in lowercase", async ({ mcp }) => { const result = await mcp.tools.call("create_ticket", { title: "Cannot log in", customer: "Dana@Acme.com" }); expect(result.json().customer).toBe("dana@acme.com"); }); ``` The model doesn't need to know about the lowercasing, and the schema doesn't tell it: JSON Schema can't describe a function, so a `.transform()` is as invisible as a `.refine()`. Here `.toLowerCase()` would do the same job in fewer characters; keep `.transform()` for clean-ups the built-in methods don't cover. ## Optional fields and defaults Most tools have arguments the model can leave out. Zod gives you two ways to say so. Look at `status` and `limit` in the Capabilities tab and the **Tests** tab: Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts search.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title.", inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles"), status: z.enum(["open", "closed"]).optional().describe("Leave out to search both"), limit: z.number().int().min(1).max(50).default(10).describe("The most tickets to return"), }, }) export class SearchTickets extends ToolContext { async execute({ query, status, limit }: { query: string; status?: "open" | "closed"; limit: number }) { const q = query.toLowerCase(); const matches = tickets.filter((t) => t.title.toLowerCase().includes(q) && (!status || t.status === status)); return { tickets: matches.slice(0, limit), total: matches.length }; } } ``` ```ts tickets.ts export const tickets = Array.from({ length: 30 }, (_, i) => ({ id: `T-${i + 1}`, title: i % 2 ? "Login link expired" : "Cannot log in", status: i % 3 ? "open" : "closed", })); ``` ```ts search.test.ts import { test, expect } from "@frontmcp/testing"; async function schema(mcp: any) { const tool = (await mcp.tools.list()).find((t: any) => t.name === "search_tickets"); return tool.inputSchema; } test("`status` is optional", async ({ mcp }) => { expect((await schema(mcp)).required).not.toContain("status"); }); test("`limit` is optional, and the schema shows its default", async ({ mcp }) => { const s = await schema(mcp); expect(s.required).not.toContain("limit"); expect(s.properties.limit.default).toBe(10); }); test("leaving `limit` out: execute() gets 10", async ({ mcp }) => { const result = await mcp.tools.call("search_tickets", { query: "log" }); expect(result).toBeSuccessful(); expect(result.json().tickets).toHaveLength(10); }); test("`limit` must be a number, not a string", async ({ mcp }) => { const result = await mcp.tools.call("search_tickets", { query: "log", limit: "5" }); expect(result.raw._meta?.code).toBe("INVALID_INPUT"); }); ``` - **`.optional()`** leaves the field out of `required`. When the model leaves it out, `execute()` gets `undefined`, so your code decides what "no value" means. Here it means "search both". - **`.default(10)`** also leaves the field out of `required`, and adds `"default": 10` to it, so the model knows what happens if it says nothing. When the field is missing, FrontMCP fills in 10 before `execute()` runs. The Capabilities card shows **optional** and **default 10**. Use `.default()` when one value is right most of the time, and `.optional()` when leaving a field out means something of its own, like "search both". With a default, `execute()` always gets a value, so type the parameter as `limit: number`, not `limit?: number`. ### Why is limit optional in the schema but not in execute()? A schema can describe a value before it's parsed (what a client may send) or after (what your code receives). `tools/list` uses the "before" view: a client may leave `limit` out, so it isn't required. `execute()` gets the "after" view: by then `limit` always has a value. `@Tool` checks `execute()`'s parameter against the "after" view when TypeScript compiles. `limit?: number` fails that check, because it allows `undefined`, which `execute()` never gets. ## Objects and lists When several values belong together, like a customer's email and name, group them in a `z.object()`. When the model should send several values of one kind, use `z.array()` with rules for the items. An object shows up in the JSON Schema with its own `properties` and `required` list, and a list with an `items` schema, so the model sees the rules at every level: Initial call configuration: ```js { tool: "create_ticket", arguments: { title: "Invoice total is wrong", customer: { email: "dana@acme", name: "Dana Levi" }, tags: ["billing", "urgent"] } } ``` ```ts create-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "create_ticket", description: "Open a new support ticket for a customer.", inputSchema: { title: z.string().min(5).max(120).describe("One-line summary of the problem"), customer: z .object({ email: z.string().email().describe("Like dana@acme.com"), name: z.string().optional().describe("Full name, if the customer gave it"), }) .describe("Who reported the problem"), tags: z .array(z.enum(["billing", "login", "bug", "feature"])) .max(3) .optional() .describe("Up to 3 labels"), }, }) export class CreateTicket extends ToolContext { async execute(input: { title: string; customer: { email: string; name?: string }; tags?: ("billing" | "login" | "bug" | "feature")[]; }) { return { id: "T-4", status: "open", ...input }; } } ``` ```ts create-ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("each problem's path points into the structure", async ({ mcp }) => { const result = await mcp.tools.call("create_ticket", { title: "Invoice total is wrong", customer: { email: "dana@acme" }, tags: ["billing", "urgent"], }); const problems = JSON.parse(result.text().split("Details: ")[1]); expect(problems.map((p: { path: unknown[] }) => p.path)).toEqual([["customer", "email"], ["tags", 1]]); }); test("unknown fields inside `customer` are dropped", async ({ mcp }) => { const result = await mcp.tools.call("create_ticket", { title: "Invoice total is wrong", customer: { email: "dana@acme.com", vip: true }, }); expect(result.json().customer).toEqual({ email: "dana@acme.com" }); }); ``` The error lists two problems, and each `path` points into the structure: `["customer", "email"]` for the email, and `["tags", 1]` for the second tag, `"urgent"`, which isn't one of the four labels. Fix both in the form and call again. The rules you've seen apply at every level. Nested fields can have `.describe()` notes, nested objects get their own `required` list, and unknown fields inside `customer` are dropped just like unknown fields at the top. Prefer a list over a string the model has to format. `tags: z.array(...)` says exactly what to send. A `tags: z.string().describe("Comma-separated labels")` leaves the model to guess about spaces, quotes and brackets, and leaves your code to parse whatever it chose. ## Recap - FrontMCP converts `inputSchema` to JSON Schema in `tools/list`. That JSON is all the model knows about a tool's arguments. - Use `.describe()` on any field whose name doesn't say its format, unit, or where its value comes from. - Put rules in the schema with constraints like `.min()`, `.regex()`, `.email()` and `z.enum()`. The model sees them before it calls, and FrontMCP enforces them before `execute()` runs, reporting every problem at once. - Unknown fields are dropped, and values of the wrong type are rejected, not converted. - `.refine()` rules aren't visible to the model; repeat them in `.describe()`. `.trim()`, `.toLowerCase()` and `.transform()` clean up values before `execute()` gets them. - `.optional()` and `.default()` both leave a field out of `required`. With `.default()`, `tools/list` shows the value, and FrontMCP fills it in before `execute()` runs. - Use `z.object()` and `z.array()` for structured values instead of strings the model has to format. ## Try some challenges ### Challenge: Move the checks into the schema This tool checks `priority` and `title` with `if` statements, so the model can't see the rules until it breaks them. Move both rules into `inputSchema` and delete the checks from `execute()`. Initial call configuration: ```js { tool: "create_ticket", arguments: { title: "Cannot log in", priority: "urgent" } } ``` ```ts create-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "create_ticket", description: "Open a new support ticket.", inputSchema: { title: z.string().describe("One-line summary of the problem"), priority: z.string().describe("low, normal or high"), }, }) export class CreateTicket extends ToolContext { async execute({ title, priority }: { title: string; priority: string }) { if (title.length < 5 || title.length > 120) { this.fail(new PublicMcpError("title must be 5 to 120 characters")); } if (!["low", "normal", "high"].includes(priority)) { this.fail(new PublicMcpError("priority must be low, normal or high")); } return { id: "T-4", title, priority }; } } ``` **Solution file (alternative to the starter):** ```ts create-ticket.tool.ts solution 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"]), }, }) export class CreateTicket extends ToolContext { async execute({ title, priority }: { title: string; priority: "low" | "normal" | "high" }) { return { id: "T-4", title, priority }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts create-ticket.test.ts hidden import { test, expect } from "@frontmcp/testing"; async function schema(mcp: any) { const tool = (await mcp.tools.list()).find((t: any) => t.name === "create_ticket"); return tool.inputSchema; } test("`priority` lists its allowed values in the schema", async ({ mcp }) => { expect((await schema(mcp)).properties.priority.enum).toEqual(["low", "normal", "high"]); }); test("`title` is 5 to 120 characters, in the schema", async ({ mcp }) => { const title = (await schema(mcp)).properties.title; expect(title.minLength).toBe(5); expect(title.maxLength).toBe(120); }); test("a bad priority is rejected before `execute()` runs", async ({ mcp }) => { const result = await mcp.tools.call("create_ticket", { title: "Cannot log in", priority: "urgent" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("INVALID_INPUT"); }); test("a valid ticket is still created", async ({ mcp }) => { const result = await mcp.tools.call("create_ticket", { title: "Cannot log in", priority: "high" }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ id: "T-4", title: "Cannot log in", priority: "high" }); }); ``` **Hint:** `z.enum([...])` replaces the list check, and `.min()` and `.max()` on the string replace the length check. Change the type of `priority` in `execute()` to match. **Solution:** The rules now appear in `tools/list` as `enum`, `minLength` and `maxLength`, so a model can follow them before it calls. A call that breaks them fails with `INVALID_INPUT` instead of your own `PUBLIC_ERROR`, and it lists every problem at once. The `.describe()` note on `priority` became redundant, because the enum lists the values. ### Challenge: Show the model the default priority Most tickets are `normal`, and `create_ticket` already treats a missing `priority` as `normal`. But that rule is a `??` inside `execute()`, where the model can't see it. Move the default into the schema, so `tools/list` shows it, and keep `priority` optional. Initial call configuration: ```js { tool: "create_ticket", arguments: { title: "Cannot log in" } } ``` ```ts create-ticket.tool.ts 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"]) .optional() .describe("Use high only when the customer can't work at all"), }, }) export class CreateTicket extends ToolContext { async execute({ title, priority }: { title: string; priority?: "low" | "normal" | "high" }) { return { id: "T-4", title, priority: priority ?? "normal" }; } } ``` **Solution file (alternative to the starter):** ```ts create-ticket.tool.ts solution 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") .describe("Use high only when the customer can't work at all"), }, }) export class CreateTicket extends ToolContext { async execute({ title, priority }: { title: string; priority: "low" | "normal" | "high" }) { return { id: "T-4", title, priority }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts create-ticket.test.ts hidden import { test, expect } from "@frontmcp/testing"; async function schema(mcp: any) { const tool = (await mcp.tools.list()).find((t: any) => t.name === "create_ticket"); return tool.inputSchema; } test("`priority` isn't required", async ({ mcp }) => { expect((await schema(mcp)).required ?? []).not.toContain("priority"); }); test("the schema shows the default, \"normal\"", async ({ mcp }) => { expect((await schema(mcp)).properties.priority.default).toBe("normal"); }); test("a ticket without a priority is \"normal\"", async ({ mcp }) => { const result = await mcp.tools.call("create_ticket", { title: "Cannot log in" }); expect(result).toBeSuccessful(); expect(result.json().priority).toBe("normal"); }); test("a ticket can still be \"high\"", async ({ mcp }) => { const result = await mcp.tools.call("create_ticket", { title: "Cannot log in", priority: "high" }); expect(result.json().priority).toBe("high"); }); ``` **Hint:** `.default()` takes the place of `.optional()`, and of the `??`. Change the type of `priority` in `execute()` to match. **Solution:** `.default("normal")` keeps `priority` out of `required`, puts `"default": "normal"` in `tools/list` so the model knows what happens if it says nothing, and fills in `"normal"` before `execute()` runs. The `??` is gone, and `priority` is never `undefined` in `execute()`, so its type loses the `?`. ### Challenge: Take a list, not a comma-separated string `close_tickets` asks the model for a comma-separated string of ids, and then parses it. Change `ids` to a list of ticket ids, where each id looks like `T-123`, and the list has 1 to 10 items. ```ts close-tickets.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", status: "open" }, { id: "T-2", status: "closed" }, { id: "T-3", status: "open" }, ]; @Tool({ name: "close_tickets", description: "Close one or more support tickets.", inputSchema: { ids: z.string().describe("Comma-separated ticket ids, like T-1, T-3"), }, }) export class CloseTickets extends ToolContext { async execute({ ids }: { ids: string }) { const list = ids.split(",").map((id) => id.trim()); for (const t of tickets) if (list.includes(t.id)) t.status = "closed"; return { closed: list }; } } ``` **Solution file (alternative to the starter):** ```ts close-tickets.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", status: "open" }, { id: "T-2", status: "closed" }, { id: "T-3", status: "open" }, ]; @Tool({ name: "close_tickets", description: "Close one or more support tickets.", inputSchema: { ids: z .array(z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1")) .min(1) .max(10) .describe("The tickets to close"), }, }) export class CloseTickets extends ToolContext { async execute({ ids }: { ids: string[] }) { for (const t of tickets) if (ids.includes(t.id)) t.status = "closed"; return { closed: ids }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts close-tickets.test.ts hidden import { test, expect } from "@frontmcp/testing"; async function ids(mcp: any) { const tool = (await mcp.tools.list()).find((t: any) => t.name === "close_tickets"); return tool.inputSchema.properties.ids; } test("`ids` is a list in the schema", async ({ mcp }) => { expect((await ids(mcp)).type).toBe("array"); }); test("the list holds 1 to 10 ids", async ({ mcp }) => { const s = await ids(mcp); expect(s.minItems).toBe(1); expect(s.maxItems).toBe(10); }); test("an id that doesn't look like T-123 is rejected", async ({ mcp }) => { const result = await mcp.tools.call("close_tickets", { ids: ["T-1", "ticket 3"] }); expect(result.raw._meta?.code).toBe("INVALID_INPUT"); }); test("closing [\"T-1\", \"T-3\"] closes both", async ({ mcp }) => { const result = await mcp.tools.call("close_tickets", { ids: ["T-1", "T-3"] }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ closed: ["T-1", "T-3"] }); }); ``` **Hint:** `z.array(item)` takes the schema for one item. Put the id's `.regex()` on the item, and `.min()` and `.max()` on the array. **Solution:** `z.array(z.string().regex(/^T-\d+$/)).min(1).max(10)` becomes `"type": "array"` with `minItems`, `maxItems` and an `items` schema that has the `pattern`. The model now knows exactly what to send, a bad id is rejected with its position in the list, and `execute()` no longer parses anything. --- # Shaping Tool Results > What a tool call sends back, how FrontMCP turns your return value into content and structuredContent, and how to return results and errors a model can act on. Source: https://frontmcp.dev/learn/shaping-tool-results [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 When a tool finishes, FrontMCP turns what it returned into the call's result, and the result goes into the model's context. The model reads it to decide what to tell the user and what to call next. So a result is as much a part of a tool's interface as its input schema. It should hold the data the model needs, in a shape programs can rely on, with ids that lead to the next step. And when a call fails, the result should say what to do about it. **You will learn** - What a `tools/call` result contains, and what FrontMCP does with your return value - How to declare a result's shape with `outputSchema`, and what FrontMCP checks against it - How to return what the model needs, with ids it can pass to the next tool - How to fail in a way the model can recover from - How to return images ## What comes back from `tools/call` Here is a tool that returns an object. Open the **Wire** tab and expand the `tools/call` exchange: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts get-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open", assignee: undefined, updated: new Date("2026-09-20T09:30:00Z") }; } } ``` The result has two parts that carry the same data: ```json { "content": [ { "type": "text", "text": "{\"id\":\"T-1\",\"title\":\"Cannot log in\",\"status\":\"open\",\"updated\":\"2026-09-20T09:30:00.000Z\"}" } ], "structuredContent": { "id": "T-1", "title": "Cannot log in", "status": "open", "updated": "2026-09-20T09:30:00.000Z" } } ``` - **`content`** is a list of blocks: text, images and other media. Most clients put these in front of the model. FrontMCP writes your object into one text block, as JSON. - **`structuredContent`** is the same data as a JSON object, for clients and programs that read structured results directly instead of parsing text. FrontMCP makes the value JSON-safe on the way: the `Date` arrived as an ISO string, and `assignee`, which was `undefined`, was left out. That works well for objects. Plain values are less clear. Switch between these tabs and look at each result: ### What FrontMCP does with a plain value ### Example: A number Initial call configuration: ```js { tool: "count_open_tickets", arguments: {} } ``` ```ts count.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "count_open_tickets", description: "Count open support tickets.", inputSchema: {} }) export class CountOpenTickets extends ToolContext { async execute() { return 2; } } ``` ### Example: A string Initial call configuration: ```js { tool: "ticket_status", arguments: { id: "T-1" } } ``` ```ts status.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "ticket_status", description: "Get the status of a support ticket.", inputSchema: { id: z.string().regex(/^T-\d+$/) }, }) export class TicketStatus extends ToolContext { async execute({ id }: { id: string }) { return `Ticket ${id} is open`; } } ``` ### Example: A list Initial call configuration: ```js { tool: "list_open_tickets", arguments: {} } ``` ```ts list.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "list_open_tickets", description: "List open support tickets.", inputSchema: {} }) export class ListOpenTickets extends ToolContext { async execute() { return [ { id: "T-1", title: "Cannot log in" }, { id: "T-3", title: "Login link expired" }, ]; } } ``` `structuredContent` has to be an object, so FrontMCP wraps anything else in one: `2` becomes `{ "value": 2 }`, the sentence becomes `{ "value": "Ticket T-1 is open" }`, and the list becomes `{ "value": [...] }`. The text block holds the same wrapper, so even the text isn't the plain sentence you returned. `{ "value": 2 }` is correct, but it doesn't say what the 2 counts. Return objects with named fields instead: `{ open: 2 }`, `{ id, status }`, `{ tickets: [...] }`. The field names tell the model what each value means, and the object leaves room to add a field later without changing the result's shape. | `execute()` returns | `structuredContent` | Text block in `content` | | --- | --- | --- | | An object | the object | the object as JSON | | A string, number or boolean | `{ "value": ... }` | `{"value": ...}` as JSON | | An array | `{ "value": [...] }` | `{"value": [...]}` as JSON | | An object with a `content` array of blocks | none | your blocks, as they are ([see images](https://frontmcp.dev/learn/shaping-tool-results#returning-images)) | > **Pitfall: NaN fails the whole call** > > JSON has no `NaN` or `Infinity`, so FrontMCP refuses to send them. Averages over an empty list are the usual way to get one: > > Initial call configuration: > > ```js > { tool: "ticket_stats", arguments: {} } > ``` > > ```ts stats.tool.ts > import { Tool, ToolContext } from "@frontmcp/sdk"; > > const firstReplyMinutes: number[] = []; // no tickets answered yet today > > @Tool({ name: "ticket_stats", description: "Today's support numbers.", inputSchema: {} }) > export class TicketStats extends ToolContext { > async execute() { > const total = firstReplyMinutes.reduce((a, b) => a + b, 0); > return { answered: firstReplyMinutes.length, avgFirstReplyMinutes: total / firstReplyMinutes.length }; > } > } > ``` > > `0 / 0` is `NaN`, so the call fails with `INVALID_OUTPUT`, and in production the message is only "Output validation failed. Please contact support." Return `null` for "no value yet", which JSON can carry, or leave the field out. ## Declaring the shape with `outputSchema` `outputSchema` tells clients the shape of a result before they call the tool. Write it with Zod, like `inputSchema`, but as one `z.object()`: Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts search.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in", status: "open" as const }, { id: "T-2", title: "Invoice total is wrong", status: "closed" as const }, { id: "T-3", title: "Login link expired", status: "open" as const }, ]; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title.", inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles") }, outputSchema: z.object({ tickets: z.array(z.object({ id: z.string(), title: z.string(), status: z.enum(["open", "closed"]) })), total: z.number().int().describe("How many tickets matched"), }), annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { const matches = tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())); return { tickets: matches, total: matches.length }; } } ``` The Capabilities card now ends with **Returns `{ tickets, total }`**, and `tools/list` in the Wire tab carries the full JSON Schema of the result next to `inputSchema`. Clients and programs that use your server know which fields to expect before they call, and can rely on `total` being a number. FrontMCP also checks every result against `outputSchema`. When a result matches, FrontMCP sends the parsed result, so fields that aren't in the schema are dropped, whether or not the tool has a widget. When it doesn't match, the call fails. (Changed in 1.8.7: a tool with a widget used to keep its extra fields.) The **Tests** tab checks both cases: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-2" } } ``` ```ts get-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { rows } from "./db"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, outputSchema: z.object({ id: z.string(), title: z.string(), status: z.enum(["open", "closed"]) }), }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { // Returns the whole row and relies on outputSchema to trim it. return rows.find((r) => r.id === id)!; } } ``` ```ts db.ts export const rows = [ { id: "T-1", title: "Cannot log in", status: "open", internal_notes: "Legacy plan. Don't offer a refund." }, { id: "T-2", title: "Invoice total is wrong", status: "pending", internal_notes: "Finance is checking. Don't promise a date." }, ]; ``` ```ts get-ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("T-1 matches outputSchema, so `internal_notes` is dropped", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result.raw.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" }); }); test("T-2's status isn't in outputSchema, so the call fails with INVALID_OUTPUT", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-2" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("INVALID_OUTPUT"); expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at status)"); }); test("nothing from T-2's row is sent", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-2" }); expect(result.raw).not.toHaveProperty("structuredContent"); expect(result.text()).not.toContain("Finance"); }); ``` Someone added a `pending` status to the database, and nobody updated `outputSchema`. T-1 still comes back trimmed, but T-2 doesn't match, so the call fails with `INVALID_OUTPUT`, and nothing from the row is sent: not the status the schema says can't happen, and not the internal note. The message names the first field that didn't match. In production it says only "Output validation failed. Please contact support." > **Pitfall: Letting outputSchema trim your results** > > Returning a whole row and letting `outputSchema` drop the extra fields works until one value doesn't match. Then the model gets an error instead of the ticket, for every ticket with that value, and in production it can't tell why. Build the result yourself, field by field, as in the next section, and test that results match. `@Tool` also checks `execute()`'s return type against `outputSchema` when TypeScript compiles, and would reject this example, because `db.ts` types `status` as any string. But types describe what you expect: a database can still hand back a status nobody added to them. ## Return what the model needs The easiest thing to return is whatever the database gave you. Here is a typical row: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts get-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { db } from "./db"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return db.tickets.find((row) => row.ticket_no === id); } } ``` ```ts db.ts export const db = { tickets: [ { id: 5501, ticket_no: "T-1", subject: "Cannot log in", status_code: 1, cust_id: 881, assignee_id: 12, body_html: "

I get Error 403 when I sign in from the office.

", internal_notes: "Legacy plan. Don't offer a refund.", created_ms: 1789808400000, updated_ms: 1789896600000, _version: 7, }, ], customers: [{ id: 881, name: "Dana Levi", email: "dana@acme.com", password_hash: "$2b$10$Qe..." }], statuses: { 1: "open", 2: "waiting on customer", 3: "closed" } as Record, }; ``` The model has to work to use this, and some of it is dangerous: - **Codes instead of meanings.** `status_code: 1` means nothing without the `statuses` table, which the model can't see. - **Two ids.** `id: 5501` and `ticket_no: "T-1"` both look like the ticket's id. Only one of them works with `get_ticket`. - **Formats a model reads badly.** `created_ms` is a number of milliseconds; `body_html` spends tokens on markup. - **Things the model shouldn't see.** `internal_notes` is for agents, and a model may quote it to the customer. Map the row to the result you want the model to read: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts get-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { db } from "./db"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, outputSchema: z.object({ id: z.string(), title: z.string(), status: z.enum(["open", "waiting on customer", "closed"]), customer: z.string(), description: z.string(), created: z.string(), updated: z.string(), }), }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const row = db.tickets.find((r) => r.ticket_no === id)!; const customer = db.customers.find((c) => c.id === row.cust_id)!; return { id: row.ticket_no, title: row.subject, status: db.statuses[row.status_code] as "open" | "waiting on customer" | "closed", customer: customer.name, description: row.body_html.replace(/<[^>]+>/g, ""), created: new Date(row.created_ms).toISOString(), updated: new Date(row.updated_ms).toISOString(), }; } } ``` ```ts db.ts export const db = { tickets: [ { id: 5501, ticket_no: "T-1", subject: "Cannot log in", status_code: 1, cust_id: 881, assignee_id: 12, body_html: "

I get Error 403 when I sign in from the office.

", internal_notes: "Legacy plan. Don't offer a refund.", created_ms: 1789808400000, updated_ms: 1789896600000, _version: 7, }, ], customers: [{ id: 881, name: "Dana Levi", email: "dana@acme.com", password_hash: "$2b$10$Qe..." }], statuses: { 1: "open", 2: "waiting on customer", 3: "closed" } as Record, }; ``` The result is shorter and says more. Some rules that got it there: - **Use the words the user and the model use.** `status: "open"`, not `status_code: 1`, and `customer: "Dana Levi"`, not `cust_id: 881`. - **Give each thing one id, in the format your other tools accept.** Here that's `T-1`, which is also what `get_ticket` takes. - **Prefer readable formats.** ISO dates, plain text instead of HTML, and units in field names (`firstReplyMinutes`) when a number has one. - **Pick fields on purpose.** Listing each field means a new database column, like a password hash, can't end up in a result without someone deciding it should. ## Ids the model can pass to the next tool Models often chain tools: search for something, then act on what they found. That only works if a result carries an id that the next tool accepts, in exactly the format it accepts. Here `search_tickets` returns short summaries, and says which tool gives the rest: Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts tickets.tools.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Returns up to 5 short matches; use get_ticket with an id for the full ticket.", inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles") }, outputSchema: z.object({ tickets: z.array(z.object({ id: z.string(), title: z.string(), status: z.string() })), total: z.number().int().describe("How many tickets matched, including any not returned"), }), annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { const q = query.toLowerCase(); const matches = tickets.filter((t) => t.title.toLowerCase().includes(q)); return { tickets: matches.slice(0, 5).map(({ id, title, status }) => ({ id, title, status })), total: matches.length, }; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id, with its full description.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, annotations: { readOnlyHint: true }, }) 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}. Find tickets with search_tickets.`)); return ticket; } } ``` ```ts tickets.ts export const tickets = [ { id: "T-1", title: "Cannot log in", status: "open", description: "Error 403 when signing in from the office." }, { id: "T-2", title: "Invoice total is wrong", status: "closed", description: "Charged twice for September." }, { id: "T-3", title: "Login link expired", status: "open", description: "The emailed link says it has expired." }, ]; ``` ```ts chain.test.ts import { test, expect } from "@frontmcp/testing"; test("an id from search_tickets works with get_ticket", async ({ mcp }) => { const found = await mcp.tools.call("search_tickets", { query: "log" }); const first = found.json().tickets[0]; const ticket = await mcp.tools.call("get_ticket", { id: first.id }); expect(ticket).toBeSuccessful(); expect(ticket.json().title).toBe(first.title); }); test("search results are summaries, without descriptions", async ({ mcp }) => { const found = await mcp.tools.call("search_tickets", { query: "log" }); expect(found.json().tickets[0]).not.toHaveProperty("description"); expect(found.json().total).toBe(2); }); ``` The **Tests** tab does what a model would: search, take the first id, and get the ticket. A test like this catches the mistakes that make models stumble, like search returning a database number while `get_ticket` wants `T-1`. Two more details help the model find its way: - **Search returns summaries; get returns details.** A search that returns every field of every match fills the model's context with text it didn't ask for. - **Say when a list is cut short.** `total` tells the model there were more matches than it got, so it can ask for a narrower search instead of assuming it saw everything. ## Errors the model can act on A call can fail in three ways, and each needs different handling: 1. **The arguments are wrong.** FrontMCP catches this before `execute()` runs and returns `INVALID_INPUT` with each problem. See [Schemas Are Contracts](https://frontmcp.dev/learn/schemas-are-contracts). 2. **The arguments are fine, but the request can't be done.** The ticket doesn't exist, or it's already closed. Fail with `this.fail(new PublicMcpError(message, code))`. 3. **Something broke.** The database is down. Let the error throw; FrontMCP returns it as `TOOL_EXECUTION_ERROR` and hides the details in production. For the second kind, write the message for the model. Say what went wrong, with the value it sent, and what to do next: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-2" } } ``` ```ts close-ticket.tool.ts active 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: "close_ticket", description: "Close an open support ticket.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, }) export class CloseTicket 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}. Find the right id with search_tickets.`, "TICKET_NOT_FOUND")); } if (ticket.status === "closed") { this.fail(new PublicMcpError(`Ticket ${id} is already closed. Nothing to do.`, "ALREADY_CLOSED")); } ticket.status = "closed"; return { id, status: ticket.status }; } } ``` ```ts close-ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("a missing ticket fails with TICKET_NOT_FOUND", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-9" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("TICKET_NOT_FOUND"); expect(result.text()).toBe("There's no ticket T-9. Find the right id with search_tickets."); }); test("closing a closed ticket fails with ALREADY_CLOSED", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-2" }); expect(result.raw._meta?.code).toBe("ALREADY_CLOSED"); }); test("closing an open ticket works", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ id: "T-1", status: "closed" }); }); ``` `this.fail()` ends the call with an `isError: true` result. `PublicMcpError` marks the message as safe to show, so the model reads it word for word, in development and in production. The second argument is a code: FrontMCP puts it in the result's `_meta.code`, where programs and tests can check it without parsing the message. Leave it out and the code is `PUBLIC_ERROR`. Not every "nothing" is an error. A search with no matches should return `{ tickets: [], total: 0 }`: that's a real answer, and the model can say so or try other words. Fail when the model asked for something specific that can't be done, like closing a ticket that doesn't exist. > **Pitfall: A plain Error hides the message** > > Throwing a `PublicMcpError` instead of passing it to `this.fail()` gives the same result ([see `this.fail`](https://frontmcp.dev/reference/sdk/fail#failing-vs-throwing)), which helps in code that can't call `this.fail()`, like a provider. What matters is the class. FrontMCP treats any other error as something that broke: > > Initial call configuration: > > ```js > { tool: "close_ticket", arguments: { id: "T-9" } } > ``` > > ```ts close-ticket.tool.ts > import { Tool, ToolContext, z } from "@frontmcp/sdk"; > > @Tool({ > name: "close_ticket", > description: "Close an open support ticket.", > inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, > }) > export class CloseTicket extends ToolContext { > async execute({ id }: { id: string }) { > if (id !== "T-1") throw new Error(`There's no ticket ${id}.`); > return { id, status: "closed" }; > } > } > ``` > > The code is `TOOL_EXECUTION_ERROR`, and the message starts with `Tool "close_ticket" execution failed:` and ends with a stack trace. The Playground runs in development mode, so you still see your text. In production the model gets "Internal FrontMCP error. Please contact support with error ID: …" instead. Use `PublicMcpError` for every error the model should read. ## Returning images Results aren't limited to text. A block in `content` can be an image, as base64 data with a MIME type. Help desk customers often attach screenshots, and a model that can see images can read one: ### Returning an image ### Example: Image only Set `outputSchema: "image"` and return one image block. Initial call configuration: ```js { tool: "get_screenshot", arguments: { id: "T-1" } } ``` ```ts screenshot.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { screenshots } from "./attachments"; @Tool({ name: "get_screenshot", description: "Get the screenshot a customer attached to a ticket.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, outputSchema: "image", }) export class GetScreenshot extends ToolContext { async execute({ id }: { id: string }) { const file = screenshots[id]; if (!file) this.fail(new PublicMcpError(`Ticket ${id} has no screenshot.`, "NO_SCREENSHOT")); return { type: "image" as const, data: file.data, mimeType: file.mimeType }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts attachments.ts hidden export const screenshots: Record = { "T-1": { name: "login-error.png", mimeType: "image/png", data: "iVBORw0KGgoAAAANSUhEUgAAAHgAAABICAMAAAAKwMFNAAAAElBMVEX////s7vFaYGnWRUE0btzIzNIk3o6XAAAAgUlEQVR42u3Z0QqAMAiF4aXt/V+5i26DRolO/Q90/WGTA2NDgjJawqoic95fD5jl8oGPoAAD+8LDOMC7wmoQ4GV49RTrwCyXC0xl1oenQb7B/0fNB79cBgpOHARHbTXNRXOVb64eE9NcNFcx+DQIcE74aX341cBUJnBbmOcf4NTwBVNjKhVy30tUAAAAAElFTkSuQmCC", }, }; ``` ### Example: Text and image Return a `content` array yourself, and FrontMCP sends it as it is. Initial call configuration: ```js { tool: "get_screenshot", arguments: { id: "T-1" } } ``` ```ts screenshot.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { screenshots } from "./attachments"; @Tool({ name: "get_screenshot", description: "Get the screenshot a customer attached to a ticket, with its file name.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, }) export class GetScreenshot extends ToolContext { async execute({ id }: { id: string }) { const file = screenshots[id]; if (!file) this.fail(new PublicMcpError(`Ticket ${id} has no screenshot.`, "NO_SCREENSHOT")); return { content: [ { type: "text" as const, text: `${file.name}, attached to ${id} by the customer.` }, { type: "image" as const, data: file.data, mimeType: file.mimeType }, ], }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts attachments.ts hidden export const screenshots: Record = { "T-1": { name: "login-error.png", mimeType: "image/png", data: "iVBORw0KGgoAAAANSUhEUgAAAHgAAABICAMAAAAKwMFNAAAAElBMVEX////s7vFaYGnWRUE0btzIzNIk3o6XAAAAgUlEQVR42u3Z0QqAMAiF4aXt/V+5i26DRolO/Q90/WGTA2NDgjJawqoic95fD5jl8oGPoAAD+8LDOMC7wmoQ4GV49RTrwCyXC0xl1oenQb7B/0fNB79cBgpOHARHbTXNRXOVb64eE9NcNFcx+DQIcE74aX341cBUJnBbmOcf4NTwBVNjKhVy30tUAAAAAElFTkSuQmCC", }, }; ``` ```ts screenshot.test.ts import { test, expect } from "@frontmcp/testing"; test("the result has a caption and an image", async ({ mcp }) => { const result = await mcp.tools.call("get_screenshot", { id: "T-1" }); expect(result).toHaveTextContent("login-error.png"); expect(result).toHaveImageContent(); expect(result.raw).not.toHaveProperty("structuredContent"); }); ``` The Call tab shows the image. Either way there's no `structuredContent`: an image isn't JSON data, so FrontMCP only fills in `content`. When you return a `content` array yourself, FrontMCP doesn't add anything to it, so it's also the way to control exactly what the model reads. ## Recap - A `tools/call` result has `content` (blocks the model reads) and `structuredContent` (the same data as a JSON object). - Objects arrive as they are. Strings, numbers and arrays are wrapped as `{ "value": ... }`, so return objects with named fields. - `outputSchema` advertises the shape of a result in `tools/list`, and FrontMCP checks every result against it: extra fields are dropped, and a result that doesn't match fails with `INVALID_OUTPUT`. - Map database rows to results: meanings instead of codes, one id per thing, readable formats, and only fields you chose. - Return ids in exactly the format your other tools accept, and test the chain. - Fail with `this.fail(new PublicMcpError(message, code))`, with a message that says what to do next. A plain `Error` is hidden in production. - Return images as `{ type: "image", data, mimeType }` with `outputSchema: "image"`, or build the `content` array yourself. ## Try some challenges ### Challenge: Trim a ticket for the model `get_ticket` returns the whole database row. Return only `id` (like `T-1`), `title`, `status` (as a word) and `customer` (the name), and declare that shape with `outputSchema`. Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts get-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { db } from "./db"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return db.tickets.find((row) => row.ticket_no === id)!; } } ``` ```ts db.ts export const db = { tickets: [ { id: 5501, ticket_no: "T-1", subject: "Cannot log in", status_code: 1, cust_id: 881, internal_notes: "Legacy plan. Don't offer a refund." }, ], customers: [{ id: 881, name: "Dana Levi", password_hash: "$2b$10$Qe..." }], statuses: { 1: "open", 2: "closed" } as Record, }; ``` **Solution file (alternative to the starter):** ```ts get-ticket.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { db } from "./db"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, outputSchema: z.object({ id: z.string(), title: z.string(), status: z.enum(["open", "closed"]), customer: z.string(), }), }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const row = db.tickets.find((r) => r.ticket_no === id)!; const customer = db.customers.find((c) => c.id === row.cust_id)!; return { id: row.ticket_no, title: row.subject, status: db.statuses[row.status_code], customer: customer.name }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts get-ticket.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("the result is id, title, status and customer", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result).toBeSuccessful(); expect(result.raw.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open", customer: "Dana Levi" }); }); test("internal notes never reach the model", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result.text()).not.toContain("refund"); }); test("`outputSchema` declares the four fields", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "get_ticket"); expect(Object.keys(tool?.outputSchema?.properties ?? {}).sort()).toEqual(["customer", "id", "status", "title"]); }); ``` **Hint:** Look up the customer by `cust_id` and the status word in `db.statuses`, then return a new object with just the four fields. **Solution:** The result is built field by field, so nothing reaches the model unless you listed it, and `outputSchema` tells clients the shape. Relying on `outputSchema` alone would also have trimmed this row, but only while every field matched: the day a status the schema doesn't know shows up, the call fails instead. ### Challenge: Return ids the next tool accepts `search_tickets` returns the database's numeric ids, but `get_ticket` wants ids like `T-1`, so a model can't get from one to the other. Make each search result carry an `id` that `get_ticket` accepts. Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts tickets.tools.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { rows } from "./db"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Use get_ticket with an id for the full ticket.", inputSchema: { query: z.string().min(3) }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { const matches = rows.filter((r) => r.subject.toLowerCase().includes(query.toLowerCase())); return { tickets: matches.map((r) => ({ id: r.id, title: r.subject })) }; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const row = rows.find((r) => r.ticket_no === id); if (!row) this.fail(new PublicMcpError(`There's no ticket ${id}.`, "TICKET_NOT_FOUND")); return { id: row.ticket_no, title: row.subject }; } } ``` ```ts db.ts export const rows = [ { id: 5501, ticket_no: "T-1", subject: "Cannot log in" }, { id: 5502, ticket_no: "T-2", subject: "Invoice total is wrong" }, { id: 5503, ticket_no: "T-3", subject: "Login link expired" }, ]; ``` **Solution file (alternative to the starter):** ```ts tickets.tools.ts solution import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { rows } from "./db"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Use get_ticket with an id for the full ticket.", inputSchema: { query: z.string().min(3) }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { const matches = rows.filter((r) => r.subject.toLowerCase().includes(query.toLowerCase())); return { tickets: matches.map((r) => ({ id: r.ticket_no, title: r.subject })) }; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const row = rows.find((r) => r.ticket_no === id); if (!row) this.fail(new PublicMcpError(`There's no ticket ${id}.`, "TICKET_NOT_FOUND")); return { id: row.ticket_no, title: row.subject }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts chain.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("every search result has an id like T-123", async ({ mcp }) => { const found = await mcp.tools.call("search_tickets", { query: "log" }); for (const t of found.json().tickets) expect(String(t.id)).toMatch(/^T-\d+$/); }); test("each id from search_tickets works with get_ticket", async ({ mcp }) => { const found = await mcp.tools.call("search_tickets", { query: "log" }); expect(found.json().tickets).toHaveLength(2); for (const t of found.json().tickets) { const ticket = await mcp.tools.call("get_ticket", { id: t.id }); expect(ticket).toBeSuccessful(); expect(ticket.json().title).toBe(t.title); } }); ``` **Hint:** The row already has the id `get_ticket` wants. It's just not the one called `id`. **Solution:** Search now returns `ticket_no` as `id`, so its results feed straight into `get_ticket`. The hidden check does exactly what a model would, search and then get, which is the easiest way to catch a broken chain before a model does. ### Challenge: Fail so the model can recover This tool throws a plain `Error` when a ticket doesn't exist, which reaches the model as an internal error. Make a missing ticket fail with the code `TICKET_NOT_FOUND` and a message that starts with `There's no ticket T-9` (for id `T-9`) and points the model to `search_tickets`. Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-9" } } ``` ```ts get-ticket.tool.ts 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.", 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) throw new Error("Not found"); return ticket; } } ``` **Solution file (alternative to the starter):** ```ts get-ticket.tool.ts solution 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.", 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}. Find tickets by title with search_tickets.`, "TICKET_NOT_FOUND")); } return ticket; } } ``` **Hidden fixture/test file (runs with the example):** ```ts get-ticket.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("a missing ticket fails with the code TICKET_NOT_FOUND", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-9" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("TICKET_NOT_FOUND"); }); test("the message starts with \"There's no ticket T-9\"", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-9" }); expect(result.text()).toMatch(/^There's no ticket T-9/); }); test("the message points to search_tickets", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-9" }); expect(result.text()).toContain("search_tickets"); }); test("an existing ticket still comes back", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result).toBeSuccessful(); expect(result.json().title).toBe("Cannot log in"); }); ``` **Hint:** Only a `PublicMcpError`'s message is meant for the model. It takes the code as its second argument. **Solution:** `this.fail()` sends the `PublicMcpError` as it is: your message word for word, and your code in `_meta.code`. The plain `Error` was wrapped as a `TOOL_EXECUTION_ERROR`, which production replaces with "Internal FrontMCP error". The new message also names the tool to use next, so the model can recover instead of apologizing. --- # Exposing Data with Resources > When data should be a resource instead of a tool, how to declare fixed resources and URI templates, and what clients get back when they read one. Source: https://frontmcp.dev/learn/exposing-data-with-resources [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A **resource** is data your FrontMCP server makes available at a URI, like `docs://refund-policy` or `tickets://T-2`. Tools are for things the model decides to do. Resources are for things the application reads and puts in front of the model, usually because the user picked them. A lot of what people first write as tools, like "get the policy" or "get this record", works better as a resource. **You will learn** - When data should be a resource instead of a tool, and who decides to read it - How to declare a resource with a fixed URI using `@Resource` - How to choose a MIME type, and what FrontMCP picks when you don't - How to cover a whole family of URIs, like `tickets://{id}`, with `@ResourceTemplate` - What `resources/list`, `resources/templates/list` and `resources/read` return, and what happens when a resource doesn't exist ## Data that isn't an action Support agents keep asking the model about the refund policy, so someone wrote a tool for it: Initial call configuration: ```js { tool: "get_refund_policy", arguments: {} } ``` ```ts policy.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "get_refund_policy", description: "Get the refund policy", inputSchema: {}, }) export class GetRefundPolicy extends ToolContext { async execute() { return "# Refunds\n\nFull refund within 30 days of purchase. After that, store credit only."; } } ``` It works, but a tool is the wrong shape for this: 1. **The model decides when to call it.** A support agent replying to an angry customer can't make sure the policy is in the conversation. They can only hope the model thinks to look it up. 2. **It has no address.** Nothing can point at "the refund policy": not the user, not the client's UI, not another part of your server. 3. **It takes up room among the actions.** Every tool in `tools/list` is something the model has to weigh on every turn. A document isn't an action. MCP has a separate kind of capability for data like this. The difference is **who decides** to use it: | | Tool | Resource | | --- | --- | --- | | Who decides to use it | The model, mid-conversation | The application, usually because the user picked it | | Identified by | A name, like `get_ticket` | A URI, like `tickets://T-2` | | Discovered with | `tools/list` | `resources/list` and `resources/templates/list` | | Used with | `tools/call`, with arguments | `resources/read`, with a URI | | Side effects | Allowed | None. Reading a resource shouldn't change anything. | Clients usually show resources in an attach or @-mention menu. The user picks the refund policy, the client reads it, and its text goes into the conversation before the model says a word. ## Declaring a resource Here is the refund policy as a resource: Initial call configuration: ```js { resource: "docs://refund-policy" } ``` ```ts policy.resource.ts import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "refund-policy", title: "Refund policy", uri: "docs://refund-policy", mimeType: "text/markdown", description: "When customers can get their money back. Attach it when answering refund questions.", }) export class RefundPolicy extends ResourceContext { async execute(uri: string) { return "# Refunds\n\nFull refund within 30 days of purchase. After that, store credit only."; } } ``` A `@Resource` has: 1. **`uri`**, the address clients read. The scheme (`docs://`) is yours to choose. Pick one that says what kind of thing lives there. 2. **`name`**, an identifier, and optionally a **`title`** for clients to show people. 3. **`description`**, what the data is and when it's worth attaching. Clients show it in their pickers, so write it for the person choosing what to attach. 4. **`mimeType`**, what kind of text it is. More on that [below](https://frontmcp.dev/learn/exposing-data-with-resources#picking-a-mime-type). 5. **The class**, which extends `ResourceContext` and implements `execute(uri)`. It runs every time a client reads the resource. The [`@Resource` reference](https://frontmcp.dev/reference/sdk/resource) lists every option. `execute()` can return plain text or an object, and FrontMCP builds the MCP result around it. Open the **Call** tab: the page already sent `resources/read`, and the text came back inside `contents`, labelled with the URI and the MIME type. ### What does the client actually receive? A client discovers resources with `resources/list`. Open the **Wire** tab and expand it. For this server it returns: ```json { "resources": [ { "name": "refund-policy", "title": "Refund policy", "uri": "docs://refund-policy", "description": "When customers can get their money back. Attach it when answering refund questions.", "mimeType": "text/markdown" } ] } ``` That's metadata only. The content arrives when the client sends `resources/read` with `{ "uri": "docs://refund-policy" }`: ```json { "contents": [ { "uri": "docs://refund-policy", "mimeType": "text/markdown", "text": "# Refunds\n\nFull refund within 30 days of purchase. After that, store credit only." } ] } ``` `contents` is a list, because one read can return several pieces. (The real responses carry a few more fields, like `_meta`; the Wire tab shows all of them.) ## Picking a MIME type `mimeType` tells the client what the text is, so it can decide how to show it and how to hand it to the model. Two cover most help desk data: - **`text/markdown`** for prose a person or model reads: policies, articles, canned replies. - **`application/json`** for records a model or program picks fields out of: a ticket, a customer, some counts. When you leave `mimeType` out, FrontMCP picks one from what `execute()` returns: an object is sent as JSON and labelled `application/json`, and a string is labelled `text/plain`. Open the **Tests** tab to see all three: Initial call configuration: ```js { resource: "stats://tickets" } ``` ```ts resources.ts active import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "refund-policy", uri: "docs://refund-policy", mimeType: "text/markdown", description: "When customers can get their money back", }) export class RefundPolicy extends ResourceContext { async execute(uri: string) { return "# Refunds\n\nFull refund within 30 days of purchase."; } } @Resource({ name: "ticket-stats", uri: "stats://tickets", description: "How many tickets are open and closed right now", }) export class TicketStats extends ResourceContext { async execute(uri: string) { return { open: 2, closed: 1 }; } } @Resource({ name: "support-hours", uri: "docs://support-hours", description: "When the support team is online", }) export class SupportHours extends ResourceContext { async execute(uri: string) { return "Monday to Friday, 8:00 to 18:00 UTC."; } } ``` ```ts resources.test.ts import { test, expect } from "@frontmcp/testing"; test("the policy is markdown, because we said so", async ({ mcp }) => { expect(await mcp.resources.read("docs://refund-policy")).toHaveMimeType("text/markdown"); }); test("an object is sent as JSON", async ({ mcp }) => { const stats = await mcp.resources.read("stats://tickets"); expect(stats).toHaveMimeType("application/json"); expect(stats.json()).toEqual({ open: 2, closed: 1 }); }); test("a string with no mimeType is plain text", async ({ mcp }) => { expect(await mcp.resources.read("docs://support-hours")).toHaveMimeType("text/plain"); }); ``` The fallback is fine for JSON, but set `mimeType` anyway: it also goes out in `resources/list`, so clients know what they'll get before they read. Notice that `ticket-stats` doesn't declare one, so the Capabilities card has no type to show. ## A family of resources with a template The refund policy has one URI. Tickets don't: there's `T-1`, `T-2`, and a new one every few minutes. You can't declare a `@Resource` for each. A **resource template** covers all of them with one URI pattern: Initial call configuration: ```js { resource: "tickets://T-2" } ``` ```ts ticket.resource.ts import { ResourceContext, ResourceTemplate } 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" }, ]; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "One support ticket, with its title and status", }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { return tickets.find((t) => t.id === id); } } ``` `{id}` in `uriTemplate` is a parameter. When a client reads `tickets://T-2`, FrontMCP matches the URI against the template and passes `{ id: "T-2" }` as the second argument of `execute()`. The type parameter on `ResourceContext<{ id: string }>` describes those parameters. Templates aren't in `resources/list`, because there's no single URI to list. Clients find them with `resources/templates/list`, which returns the `uriTemplate` instead of a `uri`. Look for it in the Wire tab. (Every option is in the [`@ResourceTemplate` reference](https://frontmcp.dev/reference/sdk/resource-template).) Now look at the result in the Call tab. `execute()` returned a plain object, and FrontMCP built the content around it the same way as for a fixed resource: the ticket as JSON, the template's MIME type, and the `uri` that was read, `tickets://T-2`. A client that attaches `T-1` and `T-2` gets two pieces of content, each labelled with its own ticket. Change the id in the Call tab to read other tickets. To set every field of the result yourself, return `{ contents: [{ uri, mimeType, text }] }` instead, and FrontMCP sends it as it is. The [`@Resource` reference](https://frontmcp.dev/reference/sdk/resource#executeuri-params) lists every shape `execute()` can return. > **Note** > > A template can have several parameters, like `customers://{customer}/tickets/{id}`. Each one matches a single path segment, so `tickets://{id}` matches `tickets://T-2` but not `tickets://T-2/comments`. The [Research Assistant](https://frontmcp.dev/examples/research-assistant) example uses two templates, `kb://{id}` and `tickets://{id}`, so a client can open every source an answer cites. ## When the ticket doesn't exist Read `T-9` in the example above. `tickets.find()` returns `undefined`, so `execute()` returns nothing, and the read fails with `Resource "tickets://T-9" read failed: Resource output not found`. That's how a server that broke looks, and nothing in it says the ticket doesn't exist. When a template's parameters don't point at anything, throw `ResourceNotFoundError`: Initial call configuration: ```js { resource: "tickets://T-9" } ``` ```ts ticket.resource.ts active import { ResourceContext, ResourceNotFoundError, ResourceTemplate } 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" }, ]; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "One support ticket, with its title and status", }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = tickets.find((t) => t.id === id); if (!ticket) throw new ResourceNotFoundError(uri); return ticket; } } ``` ```ts ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("T-2 reads as JSON", async ({ mcp }) => { const result = await mcp.resources.read("tickets://T-2"); expect(result).toHaveMimeType("application/json"); expect(result.json()).toMatchObject({ id: "T-2", status: "closed" }); }); test("T-9 is a not-found error that names the URI", async ({ mcp }) => { const result = await mcp.resources.read("tickets://T-9"); expect(result).toBeError(-32602); expect(result.error.message).toBe("Resource not found: tickets://T-9"); }); test("a URI that no resource matches gets the same error", async ({ mcp }) => { const result = await mcp.resources.read("tickets://T-2/comments"); expect(result).toBeError(-32602); expect(result.error.message).toBe("Resource not found: tickets://T-2/comments"); }); ``` Unlike a tool error, a resource error isn't a result with `isError`. It's a JSON-RPC error, so there are no `contents` and nothing gets attached. The Call tab shows it: code `-32602`, which MCP 2026-07-28 uses for a resource that doesn't exist, and the message `Resource not found: tickets://T-9`. The **Tests** tab shows that a URI no resource or template matches fails the same way, before any of your code runs. For a client, a ticket that doesn't exist and a URI that points nowhere are the same thing: there's nothing to read. > **Pitfall: Throw ResourceNotFoundError, not a plain Error** > > A `ResourceNotFoundError` tells the client the ticket doesn't exist. A plain `Error` tells it the server failed: > > Initial call configuration: > > ```js > { resource: "tickets://T-9" } > ``` > > ```ts ticket.resource.ts > import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; > > const tickets = [{ id: "T-1", title: "Cannot log in", status: "open" }]; > > @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json" }) > export class TicketResource extends ResourceContext<{ id: string }> { > async execute(uri: string, { id }: { id: string }) { > const ticket = tickets.find((t) => t.id === id); > // 🚩 Says the read failed, not that T-9 doesn't exist > if (!ticket) throw new Error(`There's no ticket ${id}.`); > return ticket; > } > } > ``` > > The client gets code `-32603`, an internal error, and `Resource "tickets://T-9" read failed: There's no ticket T-9.` That's in development, which is how the Playground runs. With `NODE_ENV=production`, FrontMCP hides the message of an error like this, and the client only gets `Internal FrontMCP error. Please contact support with error ID: …`. For an error the client should read, throw `ResourceNotFoundError`, or a `PublicMcpError` with your own message. ## Recap - Tools are for actions the model decides to take. Resources are data the application reads, usually because the user picked it, and they're addressed by URI. - `@Resource` declares one fixed URI. Its class extends `ResourceContext` and returns the content from `execute(uri)`. - Set `mimeType`: `text/markdown` for prose, `application/json` for records. Without it, objects are sent as JSON and strings as `text/plain`. - `@ResourceTemplate` covers a family of URIs like `tickets://{id}` and passes the parameters to `execute(uri, params)`. FrontMCP labels what it returns with the URI that was read. - Clients discover resources with `resources/list` and templates with `resources/templates/list`, and read both with `resources/read`. - Throw `ResourceNotFoundError` when a URI doesn't point at anything. The client gets a `-32602` JSON-RPC error instead of content. A plain `Error` reads as a server failure, and production hides its message. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Turn a tool into a resource The escalation policy is a document, but it was written as a tool. Make it a resource at `docs://escalation-policy`, in markdown, and remove the tool. ```ts escalation.ts import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "get_escalation_policy", description: "Get the escalation policy", inputSchema: {}, }) export class GetEscalationPolicy extends ToolContext { async execute() { return "# Escalation\n\nEscalate to the on-call lead when a customer is blocked for more than 4 hours."; } } ``` **Solution file (alternative to the starter):** ```ts escalation.ts solution import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "escalation-policy", uri: "docs://escalation-policy", mimeType: "text/markdown", description: "When to hand a ticket to the on-call lead", }) export class EscalationPolicy extends ResourceContext { async execute(uri: string) { return "# Escalation\n\nEscalate to the on-call lead when a customer is blocked for more than 4 hours."; } } ``` **Hidden fixture/test file (runs with the example):** ```ts escalation.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`get_escalation_policy` is no longer a tool", async ({ mcp }) => { expect(await mcp.tools.list()).not.toContainTool("get_escalation_policy"); }); test("`docs://escalation-policy` is listed as a resource", async ({ mcp }) => { expect(await mcp.resources.list()).toContainResource("docs://escalation-policy"); }); test("it's markdown, and it still says when to escalate", async ({ mcp }) => { const result = await mcp.resources.read("docs://escalation-policy"); expect(result).toHaveMimeType("text/markdown"); expect(result.text()).toContain("Escalate to the on-call lead"); }); ``` **Hint:** Swap `@Tool` for `@Resource` and `ToolContext` for `ResourceContext`. A resource needs a `uri` instead of an `inputSchema`. **Solution:** The policy now has an address, so a user can attach it and the client can read it before the model starts. Because it's markdown, the client knows it's prose, and because it's no longer in `tools/list`, the model has one fewer action to weigh. ### Challenge: Add a template for customers Support agents want to attach a customer's details the same way they attach a ticket. Add a template at `customers://{id}` that returns the customer as JSON. Initial call configuration: ```js { resource: "tickets://T-1" } ``` ```ts resources.ts import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in", status: "open", customer: "C-1" }, { id: "T-2", title: "Invoice total is wrong", status: "closed", customer: "C-2" }, ]; const customers = [ { id: "C-1", name: "Acme Corp", plan: "enterprise" }, { id: "C-2", name: "Globex", plan: "starter" }, ]; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "One support ticket, with its title, status and customer", }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = tickets.find((t) => t.id === id); if (!ticket) throw new ResourceNotFoundError(uri); return ticket; } } // Add a `customers://{id}` template here. ``` **Solution file (alternative to the starter):** ```ts resources.ts solution import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in", status: "open", customer: "C-1" }, { id: "T-2", title: "Invoice total is wrong", status: "closed", customer: "C-2" }, ]; const customers = [ { id: "C-1", name: "Acme Corp", plan: "enterprise" }, { id: "C-2", name: "Globex", plan: "starter" }, ]; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "One support ticket, with its title, status and customer", }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = tickets.find((t) => t.id === id); if (!ticket) throw new ResourceNotFoundError(uri); return ticket; } } @ResourceTemplate({ name: "customer", uriTemplate: "customers://{id}", mimeType: "application/json", description: "One customer, with their name and plan", }) export class CustomerResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const customer = customers.find((c) => c.id === id); if (!customer) throw new ResourceNotFoundError(uri); return customer; } } ``` **Hidden fixture/test file (runs with the example):** ```ts customers.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`customers://{id}` is listed as a template", async ({ mcp }) => { expect(await mcp.resources.listTemplates()).toContainResourceTemplate("customers://{id}"); }); test("reading `customers://C-2` returns Globex as JSON", async ({ mcp }) => { const result = await mcp.resources.read("customers://C-2"); expect(result).toHaveMimeType("application/json"); expect(result.json()).toMatchObject({ id: "C-2", name: "Globex" }); }); test("the content is labelled `customers://C-2`", async ({ mcp }) => { const result = await mcp.resources.read("customers://C-2"); expect(result.raw.contents[0].uri).toBe("customers://C-2"); }); ``` **Hint:** Copy the shape of the ticket template: `@ResourceTemplate` with a `uriTemplate` and a `mimeType`, and a class that extends `ResourceContext<{ id: string }>` whose `execute()` returns the customer. **Solution:** The template covers every customer, including ones added later, and it's listed in `resources/templates/list`. FrontMCP labels each read with the URI it was for, so the content says which customer it's about. The solution also throws `ResourceNotFoundError` for unknown ids, which the checks don't require but a real server should. ### Challenge: Don't attach a ticket that doesn't exist Reading `tickets://T-9` "succeeds" with `{"error":"not found"}`, which a client would attach as if it were a ticket. Make the read fail instead, with an error that names the URI. Initial call configuration: ```js { resource: "tickets://T-9" } ``` ```ts ticket.resource.ts import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in", status: "open" }, { id: "T-2", title: "Invoice total is wrong", status: "closed" }, ]; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "One support ticket, with its title and status", }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { return tickets.find((t) => t.id === id) ?? { error: "not found" }; } } ``` **Solution file (alternative to the starter):** ```ts ticket.resource.ts solution import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in", status: "open" }, { id: "T-2", title: "Invoice total is wrong", status: "closed" }, ]; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "One support ticket, with its title and status", }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = tickets.find((t) => t.id === id); if (!ticket) throw new ResourceNotFoundError(uri); return ticket; } } ``` **Hidden fixture/test file (runs with the example):** ```ts ticket.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("reading `tickets://T-9` is an error", async ({ mcp }) => { expect(await mcp.resources.read("tickets://T-9")).toBeError(); }); test("the error names `tickets://T-9`", async ({ mcp }) => { const result = await mcp.resources.read("tickets://T-9"); expect(result.error?.message ?? "").toContain("tickets://T-9"); }); test("`tickets://T-1` still reads", async ({ mcp }) => { const result = await mcp.resources.read("tickets://T-1"); expect(result).not.toBeError(); expect(result.json()).toMatchObject({ id: "T-1", title: "Cannot log in" }); }); ``` **Hint:** `ResourceNotFoundError` is exported from `@frontmcp/sdk` and takes the URI. Throw it when there's no ticket. **Solution:** Throwing `ResourceNotFoundError(uri)` turns the read into a `-32602` JSON-RPC error with the message `Resource not found: tickets://T-9`. The client gets no `contents`, so there's nothing to attach by mistake. --- # Reusable Prompts > What a prompt is and who picks it, how to declare required and optional arguments, return several messages, and put ticket data into a prompt. Source: https://frontmcp.dev/learn/reusable-prompts [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A **prompt** is a message template that the user picks, usually from a slash menu in their client. It's the third kind of MCP capability: the model decides when to call a tool, the application decides which resources to read, and the user decides when to run a prompt. Prompts are how a FrontMCP server ships the instructions that work best with it, so nobody has to write them from memory. **You will learn** - What a prompt is, and how it differs from tools and resources - How to declare a `@Prompt` with required and optional arguments - How to put a ticket's data into a prompt as an embedded resource - How to return several messages, including an example exchange - How clients show prompts to the people using them ## A workflow the user starts Every support agent triages tickets a little differently. One asks the model for a priority, another for a team, a third forgets to ask for a reply at all. When there's a way that works, put it on the server as a prompt: Initial call configuration: ```js { prompt: "triage", arguments: {} } ``` ```ts triage.prompt.ts import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "triage", title: "Triage a ticket", description: "Suggest a priority, an owning team and a first reply for a support ticket", }) export class Triage extends PromptContext { async execute(): Promise { return { messages: [ { role: "user", content: { type: "text", text: "Triage the support ticket I paste next. Suggest a priority (low, normal, high or urgent), the team that should own it, and a two-sentence first reply to the customer.", }, }, ], }; } } ``` Open the **Call** tab. The page sent `prompts/get`, and what came back is a list of **messages**, not an answer. Getting a prompt doesn't run the model. The client puts these messages into the conversation as if the user had typed them, and the model replies from there, calling your tools if it needs to. A `@Prompt` has: 1. **`name`**, what clients use to get it. Like tool names, use `snake_case`. 2. **`title`** and **`description`**, what the user sees in the client's menu. Unlike a tool's description, these are written mostly for people. 3. **`arguments`**, the values the user fills in. This one has none yet, so it leaves the option out. 4. **The class**, which extends `PromptContext` and returns `{ messages }` from `execute()`. Each message has a `role` (`"user"` or `"assistant"`) and one piece of `content`. The [`@Prompt` reference](https://frontmcp.dev/reference/sdk/prompt) lists every option. So the three kinds of capability differ in who decides to use them: | | Tool | Resource | Prompt | | --- | --- | --- | --- | | Who decides | The model | The application, often because the user attached it | The user | | Identified by | A name | A URI | A name | | Used with | `tools/call` | `resources/read` | `prompts/get` | | Gives back | A result for the model | Content for the conversation | Messages to start the conversation with | > **Note** > > A prompt that's one user message can return just its text. FrontMCP wraps a string in a `user` message, so this sends the same `messages` as the example above: > > Initial call configuration: > > ```js > { prompt: "triage", arguments: {} } > ``` > > ```ts triage.prompt.ts > import { Prompt, PromptContext } from "@frontmcp/sdk"; > > @Prompt({ > name: "triage", > title: "Triage a ticket", > description: "Suggest a priority, an owning team and a first reply for a support ticket", > }) > export class Triage extends PromptContext { > async execute() { > return "Triage the support ticket I paste next. Suggest a priority (low, normal, high or urgent), the team that should own it, and a two-sentence first reply to the customer."; > } > } > ``` > > The examples in this lesson return the full shape, and declare `execute()` as returning `Promise`. That's optional, but it makes TypeScript check each message's `role` and `content` as you write them. ## Arguments The prompt above makes the user paste the ticket in a second message. It's easier to ask for the ticket id up front: Initial call configuration: ```js { prompt: "triage_ticket", arguments: { id: "T-1", language: "French" } } ``` ```ts triage.prompt.ts import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "triage_ticket", title: "Triage a ticket", description: "Suggest a priority, an owning team and a first reply for one support ticket", arguments: [ { name: "id", description: "Ticket id, like T-1", required: true }, { name: "language", description: "Language for the reply to the customer. Defaults to English." }, ], }) export class TriageTicket extends PromptContext { async execute({ id, language }: { id: string; language?: string }): Promise { return { messages: [ { role: "user", content: { type: "text", text: `Look up ticket ${id} with get_ticket, then triage it. Suggest a priority (low, normal, high or urgent), the team that should own it, and a two-sentence first reply in ${language ?? "English"}.`, }, }, ], }; } } ``` Each argument has a `name`, a `description` for the person filling it in, and `required`: - **Required arguments** must be sent. Clients ask for them before they send `prompts/get`, and FrontMCP rejects a request without them. - **Optional arguments** can be left out. A missing one is `undefined` in `execute()`, so give it a default, like `language ?? "English"`. Here is the same prompt, requested without an `id`: Initial call configuration: ```js { prompt: "triage_ticket", arguments: {} } ``` ```ts triage.prompt.ts import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "triage_ticket", title: "Triage a ticket", description: "Suggest a priority, an owning team and a first reply for one support ticket", arguments: [ { name: "id", description: "Ticket id, like T-1", required: true }, { name: "language", description: "Language for the reply to the customer. Defaults to English." }, ], }) export class TriageTicket extends PromptContext { async execute({ id, language }: { id: string; language?: string }): Promise { return { messages: [ { role: "user", content: { type: "text", text: `Look up ticket ${id} with get_ticket, then triage it. Suggest a priority (low, normal, high or urgent), the team that should own it, and a two-sentence first reply in ${language ?? "English"}.`, }, }, ], }; } } ``` The request fails with a JSON-RPC error, code `-32602` (invalid params) and the message `Missing required argument: id`, and `execute()` never runs. That's the only check FrontMCP makes. Prompt arguments are plain strings in MCP, with no schema like a tool's `inputSchema`, so an id like `banana` gets through to your code. If a value matters, check it yourself. ## Putting the ticket in the prompt "Look up ticket T-1 with get_ticket" leaves a step to the model: it has to call the tool before it can start. Since the prompt runs on your server, it can look the ticket up itself and send the data along. The clearest way to send data is as an **embedded resource**: a message whose content is a resource, with a URI, instead of text: Initial call configuration: ```js { prompt: "triage_ticket", arguments: { id: "T-2" } } ``` ```ts triage.prompt.ts active import { Prompt, PromptContext, PublicMcpError, type GetPromptResult } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Prompt({ name: "triage_ticket", title: "Triage a ticket", description: "Suggest a priority, an owning team and a first reply for one support ticket", arguments: [ { name: "id", description: "Ticket id, like T-1", required: true }, { name: "language", description: "Language for the reply to the customer. Defaults to English." }, ], }) export class TriageTicket extends PromptContext { async execute({ id, language }: { id: string; language?: string }): Promise { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); return { messages: [ { role: "user", content: { type: "resource", resource: { uri: `tickets://${ticket.id}`, mimeType: "application/json", text: JSON.stringify(ticket) }, }, }, { role: "user", content: { type: "text", text: `Triage the ticket above. Suggest a priority (low, normal, high or urgent), the team that should own it, and a two-sentence first reply in ${language ?? "English"}.`, }, }, ], }; } } ``` ```ts ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets = [ { id: "T-1", title: "Cannot log in", status: "open", customer: "Acme Corp" }, { id: "T-2", title: "Invoice total is wrong", status: "closed", customer: "Globex" }, { id: "T-3", title: "Login link expired", status: "open", customer: "Acme Corp" }, ]; get(id: string) { return this.tickets.find((t) => t.id === id); } } ``` ```ts ticket.resource.ts import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "One support ticket", }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new ResourceNotFoundError(uri); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] }; } } ``` ```ts triage.test.ts import { test, expect } from "@frontmcp/testing"; test("the ticket arrives as an embedded resource", async ({ mcp }) => { const prompt = await mcp.prompts.get("triage_ticket", { id: "T-2" }); expect(prompt).toHaveMessages(2); expect(prompt.messages[0].content).toMatchObject({ type: "resource", resource: { uri: "tickets://T-2", mimeType: "application/json" }, }); }); test("it's the same data as reading tickets://T-2", async ({ mcp }) => { const prompt = await mcp.prompts.get("triage_ticket", { id: "T-2" }); const read = await mcp.resources.read("tickets://T-2"); expect(JSON.parse(prompt.messages[0].content.resource.text)).toEqual(read.json()); }); test("an unknown ticket is an error the user can read", async ({ mcp }) => { const prompt = await mcp.prompts.get("triage_ticket", { id: "T-9" }); expect(prompt).toBeError(-32602); expect(prompt.error.message).toBe("There's no ticket T-9."); }); ``` A few things changed: 1. **The prompt gets the ticket from a provider.** `PromptContext` has `this.get()`, like tools and resources, so the prompt and the `tickets://{id}` template read the same `TicketStore`. 2. **The first message is the ticket itself**, as `{ type: "resource", resource: { uri, mimeType, text } }`. The URI says where the data came from, and it's the same URI the [ticket template](https://frontmcp.dev/learn/exposing-data-with-resources#a-family-of-resources-with-a-template) serves. Clients can show it as an attached ticket instead of a wall of JSON the user seems to have typed, and the model can tell data apart from instructions. 3. **The instructions come second**, as text, and refer to "the ticket above". 4. **An unknown id fails.** `throw new PublicMcpError(...)` turns into a JSON-RPC error, code `-32602`, whose message is yours, so the client can tell the user that `T-9` doesn't exist. Open the **Tests** tab to see all three. > **Pitfall: Throw PublicMcpError, not a plain Error** > > `PublicMcpError` is for messages the user should read. A plain `Error` means the server failed: > > Initial call configuration: > > ```js > { prompt: "triage_ticket", arguments: { id: "T-9" } } > ``` > > ```ts triage.prompt.ts > import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; > > const tickets = [{ id: "T-1", title: "Cannot log in" }]; > > @Prompt({ name: "triage_ticket", arguments: [{ name: "id", required: true }] }) > export class TriageTicket extends PromptContext { > async execute({ id }: { id: string }): Promise { > const ticket = tickets.find((t) => t.id === id); > // 🚩 A server failure, as far as the client can tell > if (!ticket) throw new Error(`There's no ticket ${id}.`); > return { messages: [{ role: "user", content: { type: "text", text: `Triage ticket ${ticket.id}: ${ticket.title}` } }] }; > } > } > ``` > > The client gets code `-32603`, an internal error, and `Prompt execution failed: There's no ticket T-9.` That's in development, which is how the Playground runs. With `NODE_ENV=production`, FrontMCP hides the message and sends `Internal FrontMCP error. Please contact support with error ID: …` instead, so the user never learns what was wrong. ## Several messages A prompt can return more than one message, and they don't all have to be from the user. A message with `role: "assistant"` is a turn the model appears to have already taken. The most useful thing to do with that is show the model an example of the answer you want: Initial call configuration: ```js { prompt: "triage_ticket", arguments: { id: "T-3" } } ``` ```ts triage.prompt.ts active import { Prompt, PromptContext, PublicMcpError, type GetPromptResult } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; const instructions = "Triage support tickets. Answer with a priority (low, normal, high or urgent), the team that should own the ticket, and a two-sentence reply to the customer."; const exampleTicket = { id: "T-0", title: "Password reset email never arrives", status: "open", customer: "Initech" }; const exampleAnswer = [ "Priority: high", "Team: Accounts", "Reply: Sorry the reset email hasn't reached you. We've sent a fresh link, and if it isn't in your inbox within ten minutes, please check your spam folder.", ].join("\n"); @Prompt({ name: "triage_ticket", title: "Triage a ticket", description: "Suggest a priority, an owning team and a first reply for one support ticket", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class TriageTicket extends PromptContext { async execute({ id }: { id: string }): Promise { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); return { messages: [ { role: "user", content: { type: "text", text: `${instructions}\n\n${JSON.stringify(exampleTicket)}` } }, { role: "assistant", content: { type: "text", text: exampleAnswer } }, { role: "user", content: { type: "resource", resource: { uri: `tickets://${ticket.id}`, mimeType: "application/json", text: JSON.stringify(ticket) }, }, }, ], }; } } ``` ```ts ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets = [ { id: "T-1", title: "Cannot log in", status: "open", customer: "Acme Corp" }, { id: "T-2", title: "Invoice total is wrong", status: "closed", customer: "Globex" }, { id: "T-3", title: "Login link expired", status: "open", customer: "Acme Corp" }, ]; get(id: string) { return this.tickets.find((t) => t.id === id); } } ``` The client adds all three messages to the conversation in order: the instructions with a made-up ticket, a model turn that answers it in exactly the format you want, and then the real ticket. The model continues the pattern. An example like this pins down the format far better than describing it, and it costs you one extra message. ## How clients show prompts Clients discover prompts with `prompts/list`, and most show them as slash commands or in a menu of actions. When the user picks one, the client asks for its arguments, then sends `prompts/get` with the answers and puts the messages it gets back into the conversation. What the user sees comes from your metadata: - **`title`** is the label in the menu. Without one, clients fall back to `name`. - **`description`** says what the prompt will do, so the user can pick the right one. - **Each argument's `description`** is the hint next to its input, and `required` decides whether the user can skip it. The Playground works the same way. On the Capabilities tab, a prompt card lists its arguments, with `?` after optional ones, and the Call tab turns them into a form. ### What does the client actually receive? For the `triage_ticket` prompt with an `id` and a `language`, `prompts/list` returns: ```json { "prompts": [ { "name": "triage_ticket", "title": "Triage a ticket", "description": "Suggest a priority, an owning team and a first reply for one support ticket", "arguments": [ { "name": "id", "description": "Ticket id, like T-1", "required": true }, { "name": "language", "description": "Language for the reply to the customer. Defaults to English." } ] } ] } ``` An argument you didn't mark `required` is sent without the field, which MCP reads as optional. The client then sends `prompts/get` with `{ "name": "triage_ticket", "arguments": { "id": "T-2" } }`, and gets back the `messages` exactly as `execute()` returned them. If you don't return a `description` of your own, FrontMCP adds the prompt's. Open the Wire tab of any example above to see the full exchange. ## Recap - A prompt is a message template the user picks. Getting it returns messages for the conversation; it doesn't run the model. - A `@Prompt` has a `name`, a `title` and `description` for people, `arguments`, and a class that extends `PromptContext` and returns `{ messages }`. - Required arguments must be sent, or `prompts/get` fails with `-32602`. Optional ones are `undefined` when left out. FrontMCP checks nothing else about them. - Put data in a prompt as an embedded resource (`type: "resource"`) with a URI, and get it from the same provider your resources use. - Messages can alternate between `user` and `assistant`. An example exchange shows the model the answer format you want. - Throw `PublicMcpError` for problems the user should see. A plain `Error` is a server failure, and production hides its message. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Add an optional tone The `draft_reply` prompt always asks for a friendly reply. Some customers need a formal one. Add an optional `tone` argument, with a description, that can be `friendly` or `formal`, and use `friendly` when it's left out. Initial call configuration: ```js { prompt: "draft_reply", arguments: { id: "T-1" } } ``` ```ts draft-reply.prompt.ts import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "draft_reply", title: "Draft a reply", description: "Draft a reply to the customer on a ticket", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class DraftReply extends PromptContext { async execute({ id }: { id: string }): Promise { return { messages: [ { role: "user", content: { type: "text", text: `Look up ticket ${id} with get_ticket and draft a friendly reply to the customer.` }, }, ], }; } } ``` **Solution file (alternative to the starter):** ```ts draft-reply.prompt.ts solution import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "draft_reply", title: "Draft a reply", description: "Draft a reply to the customer on a ticket", arguments: [ { name: "id", description: "Ticket id, like T-1", required: true }, { name: "tone", description: "friendly or formal. Defaults to friendly." }, ], }) export class DraftReply extends PromptContext { async execute({ id, tone }: { id: string; tone?: string }): Promise { return { messages: [ { role: "user", content: { type: "text", text: `Look up ticket ${id} with get_ticket and draft a ${tone ?? "friendly"} reply to the customer.` }, }, ], }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts draft-reply.test.ts hidden import { test, expect } from "@frontmcp/testing"; const textOf = (prompt: { messages: any[] }) => prompt.messages.map((m) => m.content.text ?? "").join("\n"); test("`tone` is listed, with a description, and is optional", async ({ mcp }) => { const prompt = (await mcp.prompts.list()).find((p: any) => p.name === "draft_reply"); const tone = prompt?.arguments?.find((a: any) => a.name === "tone"); expect(tone).toBeDefined(); expect(tone?.description?.length ?? 0).toBeGreaterThan(5); expect(tone?.required ?? false).toBe(false); }); test("without `tone`, it asks for a friendly reply", async ({ mcp }) => { const prompt = await mcp.prompts.get("draft_reply", { id: "T-1" }); expect(prompt).not.toBeError(); expect(textOf(prompt)).toContain("friendly"); }); test("`tone: formal` asks for a formal reply", async ({ mcp }) => { const prompt = await mcp.prompts.get("draft_reply", { id: "T-1", tone: "formal" }); expect(textOf(prompt)).toContain("formal"); expect(textOf(prompt)).not.toContain("friendly"); }); ``` **Hint:** Add a second entry to `arguments` without `required: true`. In `execute()`, it arrives as `undefined` when the user leaves it out. **Solution:** `tone` has no `required`, so clients let the user skip it, and `prompts/list` doesn't mark it required. `tone ?? "friendly"` supplies the default. The prompt passes the value through as text, which is fine here: the model reads it, and nothing breaks if a user types something else. ### Challenge: Show the model an example The triage answers come back in a different format every time. Turn this prompt into three messages: the instructions with the example ticket (from the user), the example answer (from the assistant), and then the real ticket (from the user), as text like `Ticket T-3: Login link expired`. Initial call configuration: ```js { prompt: "triage_ticket", arguments: { id: "T-3" } } ``` ```ts triage.prompt.ts import { Prompt, PromptContext, PublicMcpError, type GetPromptResult } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in" }, { id: "T-2", title: "Invoice total is wrong" }, { id: "T-3", title: "Login link expired" }, ]; const instructions = "Triage support tickets. Answer with a priority (low, normal, high or urgent) and the team that should own the ticket."; const exampleTicket = "Ticket T-0: Password reset email never arrives"; const exampleAnswer = "Priority: high\nTeam: Accounts"; @Prompt({ name: "triage_ticket", title: "Triage a ticket", description: "Suggest a priority and an owning team for one support ticket", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class TriageTicket extends PromptContext { async execute({ id }: { id: string }): Promise { const ticket = tickets.find((t) => t.id === id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); return { messages: [ { role: "user", content: { type: "text", text: `${instructions}\n\nTicket ${ticket.id}: ${ticket.title}` } }, ], }; } } ``` **Solution file (alternative to the starter):** ```ts triage.prompt.ts solution import { Prompt, PromptContext, PublicMcpError, type GetPromptResult } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in" }, { id: "T-2", title: "Invoice total is wrong" }, { id: "T-3", title: "Login link expired" }, ]; const instructions = "Triage support tickets. Answer with a priority (low, normal, high or urgent) and the team that should own the ticket."; const exampleTicket = "Ticket T-0: Password reset email never arrives"; const exampleAnswer = "Priority: high\nTeam: Accounts"; @Prompt({ name: "triage_ticket", title: "Triage a ticket", description: "Suggest a priority and an owning team for one support ticket", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class TriageTicket extends PromptContext { async execute({ id }: { id: string }): Promise { const ticket = tickets.find((t) => t.id === id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); return { messages: [ { role: "user", content: { type: "text", text: `${instructions}\n\n${exampleTicket}` } }, { role: "assistant", content: { type: "text", text: exampleAnswer } }, { role: "user", content: { type: "text", text: `Ticket ${ticket.id}: ${ticket.title}` } }, ], }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts triage.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("it returns three messages: user, assistant, user", async ({ mcp }) => { const prompt = await mcp.prompts.get("triage_ticket", { id: "T-3" }); expect(prompt).toHaveMessages(3); expect(prompt.messages.map((m: any) => m.role)).toEqual(["user", "assistant", "user"]); }); test("the first message has the instructions and the example ticket", async ({ mcp }) => { const [first] = (await mcp.prompts.get("triage_ticket", { id: "T-3" })).messages; expect(first.content.text).toContain("Triage support tickets"); expect(first.content.text).toContain("Password reset email never arrives"); }); test("the assistant message is the example answer", async ({ mcp }) => { const [, second] = (await mcp.prompts.get("triage_ticket", { id: "T-3" })).messages; expect(second.content.text).toBe("Priority: high\nTeam: Accounts"); }); test("the last message is the real ticket", async ({ mcp }) => { const [, , third] = (await mcp.prompts.get("triage_ticket", { id: "T-3" })).messages; expect(third.content.text).toContain("Login link expired"); expect(third.content.text).not.toContain("Password reset"); }); ``` **Hint:** `messages` is an array. Each entry has its own `role` and `content`. `exampleTicket` and `exampleAnswer` are already defined; they just aren't used yet. **Solution:** The model sees a finished example exchange before it gets the real ticket, so it answers in the same two lines. The instructions still say what to do; the example shows what the answer looks like. ### Challenge: Attach the ticket instead of pasting it This prompt pastes the ticket's JSON into the instructions. Send it as an embedded resource instead, with the URI `tickets://` and the `application/json` type, followed by the instructions as a separate text message. Initial call configuration: ```js { prompt: "summarize_ticket", arguments: { id: "T-2" } } ``` ```ts summarize.prompt.ts import { Prompt, PromptContext, PublicMcpError, type GetPromptResult } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Prompt({ name: "summarize_ticket", title: "Summarize a ticket", description: "Summarize a ticket for a teammate who's taking it over", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class SummarizeTicket extends PromptContext { async execute({ id }: { id: string }): Promise { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); return { messages: [ { role: "user", content: { type: "text", text: `Here is a ticket: ${JSON.stringify(ticket)}\n\nSummarize it in three bullet points for a teammate who's taking it over.`, }, }, ], }; } } ``` **Solution file (alternative to the starter):** ```ts summarize.prompt.ts solution import { Prompt, PromptContext, PublicMcpError, type GetPromptResult } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Prompt({ name: "summarize_ticket", title: "Summarize a ticket", description: "Summarize a ticket for a teammate who's taking it over", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class SummarizeTicket extends PromptContext { async execute({ id }: { id: string }): Promise { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); return { messages: [ { role: "user", content: { type: "resource", resource: { uri: `tickets://${ticket.id}`, mimeType: "application/json", text: JSON.stringify(ticket) }, }, }, { role: "user", content: { type: "text", text: "Summarize the ticket above in three bullet points for a teammate who's taking it over." }, }, ], }; } } ``` ```ts ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets = [ { id: "T-1", title: "Cannot log in", status: "open", customer: "Acme Corp" }, { id: "T-2", title: "Invoice total is wrong", status: "closed", customer: "Globex" }, ]; get(id: string) { return this.tickets.find((t) => t.id === id); } } ``` **Hidden fixture/test file (runs with the example):** ```ts summarize.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("the first message is the ticket, as a resource at `tickets://T-2`", async ({ mcp }) => { const prompt = await mcp.prompts.get("summarize_ticket", { id: "T-2" }); expect(prompt.messages[0]?.content).toMatchObject({ type: "resource", resource: { uri: "tickets://T-2", mimeType: "application/json" }, }); expect(JSON.parse(prompt.messages[0].content.resource.text)).toMatchObject({ id: "T-2", title: "Invoice total is wrong" }); }); test("the instructions follow as text, without the JSON", async ({ mcp }) => { const prompt = await mcp.prompts.get("summarize_ticket", { id: "T-2" }); expect(prompt).toHaveMessages(2); expect(prompt.messages[1].content.type).toBe("text"); expect(prompt.messages[1].content.text).toContain("Summarize"); expect(prompt.messages[1].content.text).not.toContain("Invoice total is wrong"); }); test("an unknown ticket is still an error", async ({ mcp }) => { expect(await mcp.prompts.get("summarize_ticket", { id: "T-9" })).toBeError(); }); ``` **Hint:** An embedded resource is `{ type: "resource", resource: { uri, mimeType, text } }` as a message's `content`. Each message has one piece of content, so the ticket and the instructions become two messages. **Solution:** The ticket now travels as data with an address, `tickets://T-2`, and the instructions are only instructions. A client can show the ticket as an attachment, and the model doesn't have to find where the JSON starts and ends. --- # Grouping Capabilities into Apps > How @App groups tools, resources, prompts and providers, how a @FrontMcp server composes several apps, what happens when their names collide, and how apps share a provider. Source: https://frontmcp.dev/learn/grouping-capabilities-into-apps [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 As a server grows, its tools, resources and prompts fall into areas: tickets, billing, a knowledge base. A FrontMCP **app** (`@App`) is one of those areas, with the capabilities that belong to it and the providers they use. A **server** (`@FrontMcp`) puts several apps behind one endpoint. Clients never see the apps, only one list of everything, so splitting a server into apps is mostly about organising your code. It does change two things, and this lesson covers both: what happens when names collide, and which providers each app can reach. **You will learn** - What an `@App` groups, and how a `@FrontMcp` server lists apps - What clients see when a server has several apps - What FrontMCP does when two apps use the same name - Why a provider registered on one app isn't available to another, and how to share it - When it's worth splitting a server into apps ## One app Here is the help desk from the previous lessons, laid out as a small project: a server in `main.ts`, and one app with its tools, resource, prompt and provider in its own folder. Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { TicketsApp } from "./apps/tickets/tickets.app"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [TicketsApp], }) export default class HelpDeskServer {} ``` ```ts apps/tickets/tickets.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket, SearchTickets } from "./tools"; import { TicketResource } from "./ticket.resource"; import { TriageTicket } from "./triage.prompt"; import { TicketStore } from "./ticket-store"; @App({ id: "tickets", name: "Tickets", description: "Search, read and triage support tickets", tools: [SearchTickets, GetTicket], resources: [TicketResource], prompts: [TriageTicket], providers: [TicketStore], }) export class TicketsApp {} ``` ```ts apps/tickets/tools.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title", inputSchema: { query: z.string().min(2) }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: this.get(TicketStore).search(query) }; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); return ticket; } } ``` ```ts apps/tickets/ticket.resource.ts import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "One support ticket", }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) throw new ResourceNotFoundError(uri); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] }; } } ``` ```ts apps/tickets/triage.prompt.ts import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "triage_ticket", title: "Triage a ticket", description: "Suggest a priority and an owning team for one ticket", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class TriageTicket extends PromptContext { async execute({ id }: { id: string }): Promise { return { messages: [ { role: "user", content: { type: "text", text: `Read ticket ${id} with get_ticket, then suggest a priority and an owning team.` } }, ], }; } } ``` ```ts apps/tickets/ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; type Ticket = { id: string; title: string; status: "open" | "closed" }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { 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" }, { id: "T-4", title: "Charged twice in March", status: "open" }, ]; search(query: string) { return this.tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())); } get(id: string) { return this.tickets.find((t) => t.id === id); } close(id: string) { const ticket = this.get(id); if (ticket) ticket.status = "closed"; return ticket; } } ``` Two decorators hold it together: - **`@App`** lists what belongs to one area: its `tools`, `resources`, `prompts`, and the `providers` they get with `this.get()`. `id` is a short, stable identifier that FrontMCP uses to tell apps apart, and `name` and `description` are for people reading your code and logs. A capability that isn't listed in an app doesn't exist as far as clients are concerned. - **`@FrontMcp`** is the server. It lists `apps` and holds server-wide settings like `info`, which clients see as the server's name and version. Open the **Capabilities** tab. It shows two tools, a template and a prompt, and nothing about an app called Tickets. MCP has no idea of apps: `tools/list`, `resources/templates/list` and `prompts/list` each return one flat list. (The [`@App`](https://frontmcp.dev/reference/sdk/app) and [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp) references list every option.) ## A server with several apps Billing is a different area, with its own data and its own tools, so it gets its own app. The server lists both: Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-7" } } ``` ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { TicketsApp } from "./apps/tickets/tickets.app"; import { BillingApp } from "./apps/billing/billing.app"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [TicketsApp, BillingApp], }) export default class HelpDeskServer {} ``` ```ts apps/billing/billing.app.ts import { App } from "@frontmcp/sdk"; import { GetInvoice, RefundInvoice } from "./tools"; @App({ id: "billing", name: "Billing", description: "Look up and refund invoices", tools: [GetInvoice, RefundInvoice], }) export class BillingApp {} ``` ```ts apps/billing/tools.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const invoices = [ { id: "INV-7", customer: "Acme Corp", total: 1200, status: "paid", ticket: "T-4" }, { id: "INV-8", customer: "Globex", total: 300, status: "paid", ticket: null }, ]; @Tool({ name: "get_invoice", description: "Get one invoice by its id, like INV-7", inputSchema: { id: z.string() }, annotations: { readOnlyHint: true }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { const invoice = invoices.find((i) => i.id === id); if (!invoice) this.fail(new PublicMcpError(`There's no invoice ${id}.`)); return invoice; } } @Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { id: z.string() }, }) export class RefundInvoice extends ToolContext { async execute({ id }: { id: string }) { const invoice = invoices.find((i) => i.id === id); if (!invoice) this.fail(new PublicMcpError(`There's no invoice ${id}.`)); invoice.status = "refunded"; return invoice; } } ``` ```ts apps/tickets/tickets.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket, SearchTickets } from "./tools"; import { TicketStore } from "./ticket-store"; @App({ id: "tickets", name: "Tickets", description: "Search and read support tickets", tools: [SearchTickets, GetTicket], providers: [TicketStore], }) export class TicketsApp {} ``` **Hidden fixture/test file (runs with the example):** ```ts apps/tickets/tools.ts hidden import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title", inputSchema: { query: z.string().min(2) }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: this.get(TicketStore).search(query) }; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); return ticket; } } ``` **Hidden fixture/test file (runs with the example):** ```ts apps/tickets/ticket-store.ts hidden import { Provider, ProviderScope } from "@frontmcp/sdk"; type Ticket = { id: string; title: string; status: "open" | "closed" }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { 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" }, { id: "T-4", title: "Charged twice in March", status: "open" }, ]; search(query: string) { return this.tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())); } get(id: string) { return this.tickets.find((t) => t.id === id); } close(id: string) { const ticket = this.get(id); if (ticket) ticket.status = "closed"; return ticket; } } ``` ```ts server.test.ts import { test, expect } from "@frontmcp/testing"; test("clients see one list, with every tool under its own name", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t: { name: string }) => t.name); expect(names).toEqual(["get_invoice", "get_ticket", "refund_invoice", "search_tickets"]); }); test("each tool still works from its own app", async ({ mcp }) => { expect((await mcp.tools.call("get_invoice", { id: "INV-7" })).json()).toMatchObject({ total: 1200 }); expect((await mcp.tools.call("get_ticket", { id: "T-4" })).json()).toMatchObject({ status: "open" }); }); ``` The **Tests** tab shows what a client sees: four tools in one list, each under the name it was declared with. Nothing tells the client which app a tool came from. For a client, this server is no different from one app with four tools. ## When two apps use the same name Each app is written on its own, often by a different person, so it's easy for two of them to pick the same name. Say both teams called their search tool `search`: Initial call configuration: ```js { tool: "search", arguments: { query: "log" } } ``` ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { TicketsApp } from "./apps/tickets.app"; import { BillingApp } from "./apps/billing.app"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [TicketsApp, BillingApp], }) export default class HelpDeskServer {} ``` ```ts apps/tickets.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in" }, { id: "T-3", title: "Login link expired" }, ]; @Tool({ name: "search", description: "Search support tickets by title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) }; } } @App({ id: "tickets", name: "Tickets", tools: [SearchTickets] }) export class TicketsApp {} ``` ```ts apps/billing.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; const invoices = [ { id: "INV-7", customer: "Acme Corp" }, { id: "INV-8", customer: "Globex" }, ]; @Tool({ name: "search", description: "Search invoices by customer name", inputSchema: { query: z.string() } }) export class SearchInvoices extends ToolContext { async execute({ query }: { query: string }) { return { invoices: invoices.filter((i) => i.customer.toLowerCase().includes(query.toLowerCase())) }; } } @App({ id: "billing", name: "Billing", tools: [SearchInvoices] }) export class BillingApp {} ``` ```ts collision.test.ts import { test, expect } from "@frontmcp/testing"; test("both tools are renamed to `:search`", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t: { name: string }) => t.name); expect(names).toEqual(["billing:search", "tickets:search"]); }); test("neither has a title to tell them apart", async ({ mcp }) => { const titles = (await mcp.tools.list()).map((t: { title?: string }) => t.title); expect(titles).toEqual([undefined, undefined]); }); test("each can be called by its new name", async ({ mcp }) => { expect((await mcp.tools.call("billing:search", { query: "acme" })).json()).toHaveProperty("invoices"); expect((await mcp.tools.call("tickets:search", { query: "log" })).json()).toHaveProperty("tickets"); }); test("plain `search` still works, and reaches the first app in `apps`", async ({ mcp }) => { expect((await mcp.tools.call("search", { query: "log" })).json()).toHaveProperty("tickets"); }); ``` FrontMCP doesn't refuse to start. When a tool name appears in more than one app, it renames **every** tool with that name to `:`, and logs a warning about the conflict (look in the **Logs** tab). Names that don't collide are left alone. Open the **Tests** tab to see the result. That keeps each tool reachable, but it isn't a good place to stay: - **The names depend on what else is installed.** `search` was called `search` until the billing app arrived. Anything that referred to it by name, like a prompt that says "call search", now means something else. - **The old name still answers.** A client that calls plain `search` reaches the tool in the first app listed in `apps`, here the ticket search, with no error to say it was ambiguous. - **The model has only the prefixes and the descriptions to tell them apart.** Neither tool declared a `title`, so `tools/list` has none for them, and an app id like `billing` is a word you picked for your code, not for the model. The fix is to give tools names that are unique across the whole server, and that say what they search: `search_tickets` and `search_invoices`. Treat the prefixes as a warning, not as a way to namespace tools. > **Pitfall: Resource URIs are never renamed** > > FrontMCP can't rename a URI, because the URI is the address clients read. If two apps both serve `policy://sla`, the first app in `apps` serves it, and the other app's resource is neither listed nor read. FrontMCP logs a warning that names both (look in the **Logs** tab), and starts anyway: > > Initial call configuration: > > ```js > { resource: "policy://sla" } > ``` > > ```ts main.ts active > import { FrontMcp } from "@frontmcp/sdk"; > import { TicketsApp, BillingApp } from "./apps"; > > @FrontMcp({ > info: { name: "Help Desk", version: "1.0.0" }, > apps: [TicketsApp, BillingApp], > }) > export default class HelpDeskServer {} > ``` > > ```ts apps.ts > import { App, Resource, ResourceContext } from "@frontmcp/sdk"; > > @Resource({ name: "sla", uri: "policy://sla", mimeType: "text/markdown" }) > export class TicketsSla extends ResourceContext { > async execute(uri: string) { > return "# Support SLA\n\nFirst reply within 4 business hours."; > } > } > > @Resource({ name: "sla", uri: "policy://sla", mimeType: "text/markdown" }) > export class BillingSla extends ResourceContext { > async execute(uri: string) { > return "# Billing SLA\n\nRefunds are processed within 5 business days."; > } > } > > @App({ id: "tickets", name: "Tickets", resources: [TicketsSla] }) > export class TicketsApp {} > > @App({ id: "billing", name: "Billing", resources: [BillingSla] }) > export class BillingApp {} > ``` > > ```ts sla.test.ts > import { test, expect } from "@frontmcp/testing"; > > test("only the first app's resource is listed", async ({ mcp }) => { > const listed = (await mcp.resources.list()).map((r: { name: string; uri: string }) => [r.name, r.uri]); > expect(listed).toEqual([["sla", "policy://sla"]]); > }); > > test("reading the URI returns the first app's SLA, never the billing one", async ({ mcp }) => { > expect((await mcp.resources.read("policy://sla")).text()).toContain("Support SLA"); > }); > ``` > > Give each app its own URI scheme, like `tickets://sla` and `billing://sla`, so their addresses can't meet. ## Sharing a provider between apps When an invoice is refunded, the ticket that asked for the refund should close. So the billing app's `refund_invoice` needs the `TicketStore`, which belongs to the tickets app: Initial call configuration: ```js { tool: "refund_invoice", arguments: { id: "INV-7" } } ``` ```ts apps/billing/tools.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "../tickets/ticket-store"; const invoices = [ { id: "INV-7", customer: "Acme Corp", total: 1200, status: "paid", ticket: "T-4" }, { id: "INV-8", customer: "Globex", total: 300, status: "paid", ticket: null }, ]; @Tool({ name: "refund_invoice", description: "Refund an invoice in full, and close the ticket that asked for it", inputSchema: { id: z.string() }, }) export class RefundInvoice extends ToolContext { async execute({ id }: { id: string }) { const invoice = invoices.find((i) => i.id === id); if (!invoice) this.fail(new PublicMcpError(`There's no invoice ${id}.`)); invoice.status = "refunded"; if (invoice.ticket) this.get(TicketStore).close(invoice.ticket); return invoice; } } ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { TicketsApp } from "./apps/tickets/tickets.app"; import { BillingApp } from "./apps/billing/billing.app"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [TicketsApp, BillingApp], }) export default class HelpDeskServer {} ``` ```ts apps/billing/billing.app.ts import { App } from "@frontmcp/sdk"; import { RefundInvoice } from "./tools"; @App({ id: "billing", name: "Billing", tools: [RefundInvoice] }) export class BillingApp {} ``` ```ts apps/tickets/tickets.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket } from "./tools"; import { TicketStore } from "./ticket-store"; @App({ id: "tickets", name: "Tickets", tools: [GetTicket], providers: [TicketStore] }) export class TicketsApp {} ``` **Hidden fixture/test file (runs with the example):** ```ts apps/tickets/tools.ts hidden import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); return ticket; } } ``` ```ts apps/tickets/ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; type Ticket = { id: string; title: string; status: "open" | "closed" }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { 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" }, { id: "T-4", title: "Charged twice in March", status: "open" }, ]; get(id: string) { return this.tickets.find((t) => t.id === id); } close(id: string) { const ticket = this.get(id); if (ticket) ticket.status = "closed"; return ticket; } } ``` The call fails with `Provider "TicketStore" is not available: not found in local or parent registries`. Importing the class isn't enough. A provider registered on an app is **private to that app**: FrontMCP looks for it in the calling tool's app, then in the server, and `TicketStore` is in neither place for a billing tool. Move it up to the server. Providers listed in `@FrontMcp({ providers })` are available to every app, and with `ProviderScope.GLOBAL` there's one instance for all of them: Initial call configuration: ```js { tool: "refund_invoice", arguments: { id: "INV-7" } } ``` ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { TicketsApp } from "./apps/tickets/tickets.app"; import { BillingApp } from "./apps/billing/billing.app"; import { TicketStore } from "./shared/ticket-store"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [TicketsApp, BillingApp], providers: [TicketStore], }) export default class HelpDeskServer {} ``` ```ts apps/tickets/tickets.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket } from "./tools"; @App({ id: "tickets", name: "Tickets", tools: [GetTicket] }) export class TicketsApp {} ``` ```ts apps/billing/billing.app.ts import { App } from "@frontmcp/sdk"; import { RefundInvoice } from "./tools"; @App({ id: "billing", name: "Billing", tools: [RefundInvoice] }) export class BillingApp {} ``` ```ts apps/billing/tools.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "../../shared/ticket-store"; const invoices = [ { id: "INV-7", customer: "Acme Corp", total: 1200, status: "paid", ticket: "T-4" }, { id: "INV-8", customer: "Globex", total: 300, status: "paid", ticket: null }, ]; @Tool({ name: "refund_invoice", description: "Refund an invoice in full, and close the ticket that asked for it", inputSchema: { id: z.string() }, }) export class RefundInvoice extends ToolContext { async execute({ id }: { id: string }) { const invoice = invoices.find((i) => i.id === id); if (!invoice) this.fail(new PublicMcpError(`There's no invoice ${id}.`)); invoice.status = "refunded"; if (invoice.ticket) this.get(TicketStore).close(invoice.ticket); return invoice; } } ``` **Hidden fixture/test file (runs with the example):** ```ts apps/tickets/tools.ts hidden import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "../../shared/ticket-store"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); return ticket; } } ``` ```ts shared/ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; type Ticket = { id: string; title: string; status: "open" | "closed" }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { 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" }, { id: "T-4", title: "Charged twice in March", status: "open" }, ]; get(id: string) { return this.tickets.find((t) => t.id === id); } close(id: string) { const ticket = this.get(id); if (ticket) ticket.status = "closed"; return ticket; } } ``` ```ts refund.test.ts import { test, expect } from "@frontmcp/testing"; test("refunding INV-7 closes T-4, which the tickets app then sees", async ({ mcp }) => { expect((await mcp.tools.call("get_ticket", { id: "T-4" })).json()).toMatchObject({ status: "open" }); const refund = await mcp.tools.call("refund_invoice", { id: "INV-7" }); expect(refund).toBeSuccessful(); expect((await mcp.tools.call("get_ticket", { id: "T-4" })).json()).toMatchObject({ status: "closed" }); }); ``` The store moved to `shared/`, `TicketsApp` no longer lists it, and the server does. Both apps' tools get it with the same `this.get(TicketStore)`, and the **Tests** tab shows a refund in one app closing a ticket that the other app then reads as closed. > **Pitfall: Registering a provider in two apps makes two of them** > > It's tempting to fix the error by adding `TicketStore` to `BillingApp`'s `providers` as well. The error goes away, but each app now builds its own `TicketStore`, even with `ProviderScope.GLOBAL`. The refund closes T-4 in billing's copy, and the tickets app keeps saying it's open. The last challenge below starts from exactly this mistake. If apps need to share state, register the provider once, on the server. ## When to split into apps Apps are mostly for you: clients get the same flat list either way. Splitting changes two things at runtime, both covered above: providers registered on an app are private to it, and names that collide across apps get prefixed. That suggests when to split and when not to: - **Split along the areas of your domain.** Tickets, billing and a knowledge base each have their own data, their own services and their own words for things. An app per area keeps each one's providers out of the others' reach. - **Split where ownership splits.** If a different team owns billing, its app is the unit they change, review and test, and it's a class another `@FrontMcp` server could list too. - **Put what several apps need on the server.** A ticket store that billing also uses, a clock, an HTTP client with your credentials: register it in `@FrontMcp({ providers })` once. - **Don't split just to organise files.** Folders do that. Two apps that share everything are one app with extra wiring. - **Keep names unique across the server.** The model sees one list, so `search_tickets` and `search_invoices` are better than two `search` tools in two apps. ## Recap - `@App` groups the tools, resources, prompts and providers of one area. `@FrontMcp` is the server that lists the apps and holds settings like `info`. - Clients don't see apps. `tools/list`, `resources/list` and `prompts/list` each return one flat list across all apps. - When two apps use the same tool name, FrontMCP renames each to `:` and logs a warning. The bare name still reaches the first app. Pick names that are unique across the server instead. - Resource URIs are never renamed. When two apps serve the same URI, only the first app's resource is listed and read. Give each app its own scheme. - A provider registered on an app is private to that app. Register providers that several apps need on the server, once. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Register the billing app The billing app is written, but nobody can call `get_invoice`. Add the app to the server, and name the server `Help Desk`. ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { TicketsApp } from "./apps/tickets.app"; @FrontMcp({ info: { name: "Untitled", version: "1.0.0" }, apps: [TicketsApp], }) export default class HelpDeskServer {} ``` **Solution file (alternative to the starter):** ```ts main.ts solution import { FrontMcp } from "@frontmcp/sdk"; import { TicketsApp } from "./apps/tickets.app"; import { BillingApp } from "./apps/billing.app"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [TicketsApp, BillingApp], }) export default class HelpDeskServer {} ``` ```ts apps/tickets.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @App({ id: "tickets", name: "Tickets", tools: [GetTicket] }) export class TicketsApp {} ``` ```ts apps/billing.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_invoice", description: "Get one invoice by its id, like INV-7", inputSchema: { id: z.string() } }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, customer: "Acme Corp", total: 1200 }; } } @App({ id: "billing", name: "Billing", tools: [GetInvoice] }) export class BillingApp {} ``` **Hidden fixture/test file (runs with the example):** ```ts server.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`get_invoice` is listed", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("get_invoice"); }); test("`get_ticket` is still listed", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("get_ticket"); }); test("the server is called Help Desk", async ({ mcp }) => { const response = await mcp.raw.request({ method: "tools/list" }); expect(response.result._meta["io.modelcontextprotocol/serverInfo"].name).toBe("Help Desk"); }); ``` **Hint:** An app only exists for clients once it's in the server's `apps` array. Import it into `main.ts` first. **Solution:** `apps: [TicketsApp, BillingApp]` puts both apps behind the server, and their tools appear in one list. `info.name` is what clients see as the server's name: FrontMCP sends it in the `_meta` of every result. ### Challenge: Fix a name collision Both apps call their search tool `search`, so clients see `billing:search` and `tickets:search`. Rename them so each has a clear name of its own, `search_tickets` and `search_invoices`, and no tool is prefixed. ```ts apps.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in" }, { id: "T-3", title: "Login link expired" }, ]; const invoices = [ { id: "INV-7", customer: "Acme Corp" }, { id: "INV-8", customer: "Globex" }, ]; @Tool({ name: "search", description: "Search support tickets by title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) }; } } @Tool({ name: "search", description: "Search invoices by customer name", inputSchema: { query: z.string() } }) export class SearchInvoices extends ToolContext { async execute({ query }: { query: string }) { return { invoices: invoices.filter((i) => i.customer.toLowerCase().includes(query.toLowerCase())) }; } } @App({ id: "tickets", name: "Tickets", tools: [SearchTickets] }) export class TicketsApp {} @App({ id: "billing", name: "Billing", tools: [SearchInvoices] }) export class BillingApp {} ``` **Solution file (alternative to the starter):** ```ts apps.ts solution import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in" }, { id: "T-3", title: "Login link expired" }, ]; const invoices = [ { id: "INV-7", customer: "Acme Corp" }, { id: "INV-8", customer: "Globex" }, ]; @Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) }; } } @Tool({ name: "search_invoices", description: "Search invoices by customer name", inputSchema: { query: z.string() } }) export class SearchInvoices extends ToolContext { async execute({ query }: { query: string }) { return { invoices: invoices.filter((i) => i.customer.toLowerCase().includes(query.toLowerCase())) }; } } @App({ id: "tickets", name: "Tickets", tools: [SearchTickets] }) export class TicketsApp {} @App({ id: "billing", name: "Billing", tools: [SearchInvoices] }) export class BillingApp {} ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { TicketsApp, BillingApp } from "./apps"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [TicketsApp, BillingApp], }) export default class HelpDeskServer {} ``` **Hidden fixture/test file (runs with the example):** ```ts names.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("the tools are `search_invoices` and `search_tickets`", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t: { name: string }) => t.name); expect(names).toEqual(["search_invoices", "search_tickets"]); }); test("`search_tickets` finds tickets", async ({ mcp }) => { const result = await mcp.tools.call("search_tickets", { query: "log" }); expect(result).toBeSuccessful(); expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-1", "T-3"]); }); test("`search_invoices` finds invoices", async ({ mcp }) => { const result = await mcp.tools.call("search_invoices", { query: "globex" }); expect(result).toBeSuccessful(); expect(result.json().invoices.map((i: { id: string }) => i.id)).toEqual(["INV-8"]); }); ``` **Hint:** The prefix only appears while two tools share a name. Change the `name` in each `@Tool`, not the app ids. **Solution:** With unique names there's no conflict, so FrontMCP leaves both names as declared. The names are also better for the model: `search_invoices` says what it searches before the model reads a word of the description. ### Challenge: Give each app its own URI scheme Both apps serve their SLA at `policy://sla`, so the billing SLA is never listed or read. Move them to `tickets://sla` and `billing://sla`. ```ts apps.ts import { App, Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "sla", uri: "policy://sla", mimeType: "text/markdown", description: "How fast support replies" }) export class TicketsSla extends ResourceContext { async execute(uri: string) { return "# Support SLA\n\nFirst reply within 4 business hours."; } } @Resource({ name: "sla", uri: "policy://sla", mimeType: "text/markdown", description: "How fast refunds are paid" }) export class BillingSla extends ResourceContext { async execute(uri: string) { return "# Billing SLA\n\nRefunds are processed within 5 business days."; } } @App({ id: "tickets", name: "Tickets", resources: [TicketsSla] }) export class TicketsApp {} @App({ id: "billing", name: "Billing", resources: [BillingSla] }) export class BillingApp {} ``` **Solution file (alternative to the starter):** ```ts apps.ts solution import { App, Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "support-sla", uri: "tickets://sla", mimeType: "text/markdown", description: "How fast support replies" }) export class TicketsSla extends ResourceContext { async execute(uri: string) { return "# Support SLA\n\nFirst reply within 4 business hours."; } } @Resource({ name: "billing-sla", uri: "billing://sla", mimeType: "text/markdown", description: "How fast refunds are paid" }) export class BillingSla extends ResourceContext { async execute(uri: string) { return "# Billing SLA\n\nRefunds are processed within 5 business days."; } } @App({ id: "tickets", name: "Tickets", resources: [TicketsSla] }) export class TicketsApp {} @App({ id: "billing", name: "Billing", resources: [BillingSla] }) export class BillingApp {} ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { TicketsApp, BillingApp } from "./apps"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [TicketsApp, BillingApp], }) export default class HelpDeskServer {} ``` **Hidden fixture/test file (runs with the example):** ```ts sla.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`tickets://sla` is the support SLA", async ({ mcp }) => { expect((await mcp.resources.read("tickets://sla")).text()).toContain("Support SLA"); }); test("`billing://sla` is the billing SLA", async ({ mcp }) => { expect((await mcp.resources.read("billing://sla")).text()).toContain("Billing SLA"); }); test("both SLAs are listed", async ({ mcp }) => { expect(await mcp.resources.list()).toHaveLength(2); }); ``` **Hint:** Only the `uri` has to change. While you're there, give the two resources different `name`s too, so they don't get prefixed. **Solution:** Each app now owns a scheme, so its URIs can't collide with another app's, and both SLAs can be read. The solution also renames them `support-sla` and `billing-sla`, which keeps the names in `resources/list` as declared. ### Challenge: Share one ticket store Refunding INV-7 should close ticket T-4, but `get_ticket` keeps saying it's open. `TicketStore` is registered in both apps, so each app has its own copy. Make both apps use the same one. Initial call configuration: ```js { tool: "refund_invoice", arguments: { id: "INV-7" } } ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { TicketsApp } from "./apps/tickets.app"; import { BillingApp } from "./apps/billing.app"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [TicketsApp, BillingApp], }) export default class HelpDeskServer {} ``` **Solution file (alternative to the starter):** ```ts main.ts solution import { FrontMcp } from "@frontmcp/sdk"; import { TicketsApp } from "./apps/tickets.app"; import { BillingApp } from "./apps/billing.app"; import { TicketStore } from "./ticket-store"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [TicketsApp, BillingApp], providers: [TicketStore], }) export default class HelpDeskServer {} ``` ```ts apps/tickets.app.ts import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "../ticket-store"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); return ticket; } } @App({ id: "tickets", name: "Tickets", tools: [GetTicket], providers: [TicketStore] }) export class TicketsApp {} ``` **Solution file (alternative to the starter):** ```ts apps/tickets.app.ts solution import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "../ticket-store"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); return ticket; } } @App({ id: "tickets", name: "Tickets", tools: [GetTicket] }) export class TicketsApp {} ``` ```ts apps/billing.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "../ticket-store"; @Tool({ name: "refund_invoice", description: "Refund an invoice in full, and close the ticket that asked for it", inputSchema: { id: z.string() }, }) export class RefundInvoice extends ToolContext { async execute({ id }: { id: string }) { const ticket = id === "INV-7" ? "T-4" : null; if (ticket) this.get(TicketStore).close(ticket); return { invoice: id, status: "refunded", closedTicket: ticket }; } } @App({ id: "billing", name: "Billing", tools: [RefundInvoice], providers: [TicketStore] }) export class BillingApp {} ``` **Solution file (alternative to the starter):** ```ts apps/billing.app.ts solution import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "../ticket-store"; @Tool({ name: "refund_invoice", description: "Refund an invoice in full, and close the ticket that asked for it", inputSchema: { id: z.string() }, }) export class RefundInvoice extends ToolContext { async execute({ id }: { id: string }) { const ticket = id === "INV-7" ? "T-4" : null; if (ticket) this.get(TicketStore).close(ticket); return { invoice: id, status: "refunded", closedTicket: ticket }; } } @App({ id: "billing", name: "Billing", tools: [RefundInvoice] }) export class BillingApp {} ``` ```ts ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; type Ticket = { id: string; title: string; status: "open" | "closed" }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", status: "open" }, { id: "T-4", title: "Charged twice in March", status: "open" }, ]; get(id: string) { return this.tickets.find((t) => t.id === id); } close(id: string) { const ticket = this.get(id); if (ticket) ticket.status = "closed"; return ticket; } } ``` **Hidden fixture/test file (runs with the example):** ```ts refund.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("after refunding INV-7, `get_ticket` says T-4 is closed", async ({ mcp }) => { expect(await mcp.tools.call("refund_invoice", { id: "INV-7" })).toBeSuccessful(); expect((await mcp.tools.call("get_ticket", { id: "T-4" })).json()).toMatchObject({ status: "closed" }); }); test("tickets that weren't refunded stay open", async ({ mcp }) => { expect((await mcp.tools.call("get_ticket", { id: "T-1" })).json()).toMatchObject({ status: "open" }); }); ``` **Hint:** Take `TicketStore` out of both apps' `providers`, and list it once in `@FrontMcp`. **Solution:** A provider registered on an app belongs to that app, so two registrations meant two stores. Registered once on the server, there's a single `TicketStore`, and both apps' tools reach it through the same `this.get(TicketStore)`. --- # Writing Descriptions Agents Understand > How to write tool, argument and resource descriptions that tell a model when to use a capability, how to fill it in, and what comes back, and how to check them with tests. Source: https://frontmcp.dev/learn/writing-descriptions-agents-understand [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A description is an instruction to a model. When a model decides which tool to call, whether to call one at all, and what to put in each argument, the names and descriptions your FrontMCP server sends in `tools/list` are all it has to go on. A tool with a vague description gets skipped or misused however good its code is, and two tools with similar descriptions get mixed up. This lesson is about writing descriptions that steer a model to the right call. **You will learn** - What a tool description should say: what it does, what it returns, when to use it - How to tell a model when *not* to use a tool - How to describe arguments with units, formats and examples - Why overlapping tools confuse models, and how to merge them - The difference between `name` and `title`, and how to check descriptions with tests ## Descriptions are instructions Here are three help desk tools, written quickly. Open the **Capabilities** tab: ```ts tickets.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [{ id: "T-1", title: "Cannot log in", status: "open" }], query }; } } @Tool({ name: "get_ticket", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open", messages: [] as string[] }; } } @Tool({ name: "reply_to_ticket", inputSchema: { id: z.string(), body: z.string() } }) export class ReplyToTicket extends ToolContext { async execute({ id }: { id: string; body: string }) { return { id, sent: true }; } } ``` Each card says **No description. The model has only the name to go on.** A user writes: "Tell Dana we're looking into her login problem." The model can guess that `search_tickets` finds tickets, but not whether it searches titles, messages or customer names, or what comes back. It can guess that `reply_to_ticket` replies, but not that the customer receives it by email the moment it's called. Here are the same tools, described: ```ts tickets.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Returns up to 10 matches, newest first, each with its id, title and status. To read a whole ticket, pass its id to get_ticket.", inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles, like \"log in\"") }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [{ id: "T-1", title: "Cannot log in", status: "open" }], query }; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id, with every message in its conversation. Use it when you already have an id like T-12; to find an id, use search_tickets.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open", messages: [] as string[] }; } } @Tool({ name: "reply_to_ticket", description: "Email a reply to the customer who opened a ticket, and add it to the ticket's conversation. The customer receives it immediately, so only reply when the user asked you to.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12"), body: z.string().min(1).describe("The reply, in plain text, written to the customer"), }, }) export class ReplyToTicket extends ToolContext { async execute({ id }: { id: string; body: string }) { return { id, sent: true }; } } ``` Each description answers the questions a model has, roughly in the order it has them: 1. **What does it do?** Lead with one plain sentence in the user's terms: "Search support tickets by words in their title." 2. **What comes back?** "Up to 10 matches, newest first, each with its id, title and status." Now the model knows it may not see everything, and that the ids are there for the next step. 3. **When is it the right tool, and what comes next?** "To read a whole ticket, pass its id to get_ticket." Point from one tool to the next by name. 4. **What happens as a side effect?** "The customer receives it immediately." Anything a user would want to know before the call happens belongs here. Write for a reader who has never seen your code or your database. "Queries `tkt_main` with an `ILIKE`" describes your implementation; "Search tickets by words in their title" describes what the model can do with it. > **Note** > > `outputSchema` tells clients the *shape* of a result, like `{ tickets, total }`. The description is still where you say what it *means*: how many results at most, in what order, and what an empty list means. See [Shaping Tool Results](https://frontmcp.dev/learn/shaping-tool-results) for `outputSchema`. ### Putting the result's shape in the description Not every client passes `outputSchema` on to the model. FrontMCP can also write a summary of it at the end of the description, with the `output` option: ```ts search.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Returns up to 10 matches, newest first.", inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles") }, outputSchema: z.object({ tickets: z.array(z.object({ id: z.string(), title: z.string() })), total: z.number().int().describe("How many tickets matched"), }), output: { schemaMode: "both" }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [], total: 0 }; } } ``` ```ts description.test.ts import { test, expect } from "@frontmcp/testing"; test("the description ends with a summary of the result", async ({ mcp }) => { const [tool] = await mcp.tools.list(); expect(tool.description).toContain("**Returns:**"); expect(tool.description).toContain("`total`: integer (required) — How many tickets matched"); }); test("outputSchema is still listed too", async ({ mcp }) => { const [tool] = await mcp.tools.list(); expect(tool.outputSchema?.properties).toHaveProperty("total"); }); ``` `schemaMode` can be `"definition"` (the default: `outputSchema` only), `"description"` (the summary instead of `outputSchema`), `"both"`, or `"none"`. The summary lists top-level fields with their types and `.describe()` notes, so describing output fields pays off here too. ## Say when not to use a tool Some tools are easy to mistake for each other because they do almost the same thing. A help desk has two ways to write on a ticket: a reply the customer receives, and a note only agents see. If a user says "Note that Dana is on the legacy plan," a model that picks the wrong one emails the customer your internal notes. Each description should say what makes it different, and name the other tool: Initial call configuration: ```js { tool: "add_internal_note", arguments: { id: "T-1", note: "Dana is on the legacy plan. Check before offering a refund." } } ``` ```ts write.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "reply_to_ticket", description: "Email a reply to the customer who opened a ticket. The customer receives it immediately. For anything the customer shouldn't see, use add_internal_note instead.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12"), body: z.string().min(1).describe("The reply, in plain text, written to the customer"), }, }) export class ReplyToTicket extends ToolContext { async execute({ id }: { id: string; body: string }) { return { id, emailed: true }; } } @Tool({ name: "add_internal_note", description: "Add a note to a ticket that only support agents can see. The customer is never told. Use it for context, handover notes and anything private; to answer the customer, use reply_to_ticket.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12"), note: z.string().min(1).describe("The note, in plain text"), }, }) export class AddInternalNote extends ToolContext { async execute({ id }: { id: string; note: string }) { return { id, visibleTo: "agents" }; } } ``` "Instead" sentences are the most useful ones in a description. They turn a guess between two similar tools into a rule: *this* tool for customers, *that* one for agents. The same pattern works for any pair that's easy to confuse, like search and get, close and delete, or preview and send. ## Describe arguments: units, formats and examples A field's name and type are rarely enough. This tool snoozes a ticket, hiding it from the queue for a while: Initial call configuration: ```js { tool: "snooze_ticket", arguments: { id: "T-1", duration: 3 } } ``` ```ts snooze.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "snooze_ticket", description: "Hide a ticket from the queue until a later time, then bring it back as open.", inputSchema: { id: z.string(), duration: z.number(), }, }) export class SnoozeTicket extends ToolContext { async execute({ id, duration }: { id: string; duration: number }) { const until = new Date(Date.UTC(2026, 8, 24, 9, 0) + duration * 60_000); return { id, snoozedUntil: until.toISOString() }; } } ``` The user said "snooze it for three days", and the model sent `duration: 3`. The code reads minutes, so the ticket comes back three minutes later. Nothing in the schema said minutes, and `id` doesn't say what an id looks like either. Put the unit in the name, say it again in the description, and give an example: Initial call configuration: ```js { tool: "snooze_ticket", arguments: { id: "T-1", minutes: 4320 } } ``` ```ts snooze.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "snooze_ticket", description: "Hide a ticket from the queue until a later time, then bring it back as open.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12. Get one from search_tickets."), minutes: z .number() .int() .min(5) .max(10080) .describe("How long to snooze, in minutes: 60 is an hour, 1440 is a day. At most 7 days."), }, }) export class SnoozeTicket extends ToolContext { async execute({ id, minutes }: { id: string; minutes: number }) { const until = new Date(Date.UTC(2026, 8, 24, 9, 0) + minutes * 60_000); return { id, snoozedUntil: until.toISOString() }; } } ``` What to put in an argument's description: - **Units.** Minutes or hours, cents or dollars, bytes or megabytes. Put the unit in the field name too (`minutes`, `amount_cents`), because a name is harder to overlook than a note. - **Formats.** `T-12`, `YYYY-MM-DD`, an email address. A `.regex()` enforces the format; the note shows the model what it looks like. - **Examples.** Short, realistic values: "like T-12", "60 is an hour". One example often says more than a sentence of explanation. - **Where the value comes from.** "Get one from search_tickets" tells the model how to get an id it doesn't have yet. The constraints matter as much as the words. `.int().min(5).max(10080)` appears in `tools/list` as `minimum` and `maximum`, and FrontMCP rejects anything outside that range before `execute()` runs, as covered in [Schemas Are Contracts](https://frontmcp.dev/learn/schemas-are-contracts). ### Does the examples option reach the model? `@Tool` also has an `examples` option, with sample inputs. FrontMCP 1.8 doesn't send it in `tools/list`: it's used by FrontMCP's [CodeCall plugin](https://frontmcp.dev/reference/plugins/codecall), which shows a tool's examples to a model that [looks the tool up](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall#finding-tools-search-then-describe). A model connected over MCP only sees the examples you write into descriptions, like "60 is an hour" above. The test below shows `examples` missing from the listed tool: ```ts snooze.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "snooze_ticket", description: "Hide a ticket from the queue until a later time, then bring it back as open.", inputSchema: { id: z.string(), minutes: z.number().int() }, examples: [{ description: "Snooze T-1 for a day", input: { id: "T-1", minutes: 1440 } }], }) export class SnoozeTicket extends ToolContext { async execute({ id, minutes }: { id: string; minutes: number }) { return { id, minutes }; } } ``` ```ts examples.test.ts import { test, expect } from "@frontmcp/testing"; test("`examples` isn't part of tools/list", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "snooze_ticket"); expect(tool).not.toHaveProperty("examples"); expect(JSON.stringify(tool)).not.toContain("Snooze T-1 for a day"); }); ``` ## One tool per job Every tool you add is one more option the model has to weigh. When several tools do nearly the same thing, it has to guess between them. These three all return tickets: ```ts search.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title.", inputSchema: { query: z.string().min(3) }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [] as { id: string }[], query }; } } @Tool({ name: "find_customer_tickets", description: "Find a customer's support tickets.", inputSchema: { email: z.string().email() }, }) export class FindCustomerTickets extends ToolContext { async execute({ email }: { email: string }) { return { tickets: [] as { id: string }[], email }; } } @Tool({ name: "list_open_tickets", description: "List open support tickets.", inputSchema: {}, }) export class ListOpenTickets extends ToolContext { async execute() { return { tickets: [] as { id: string }[] }; } } ``` "Are there any open tickets from Dana about logging in?" needs all three filters, and no tool has them. The model picks one and filters the rest itself, or calls all three and merges the lists, or gives up. When tools differ only by a filter, merge them into one tool with optional arguments: Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log", status: "open", customer: "dana@acme.com" } } ``` ```ts search.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @Tool({ name: "search_tickets", description: "Search support tickets. Every filter is optional, and filters combine: words in the title, status, and the customer's email. Returns up to 10 matches, newest first. With no filters, returns the 10 newest tickets.", inputSchema: { query: z.string().min(3).optional().describe("Words to look for in ticket titles, like \"log in\""), status: z.enum(["open", "closed"]).optional().describe("Leave out to include both"), customer: z.string().email().optional().describe("The customer's email address, like dana@acme.com"), }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query, status, customer }: { query?: string; status?: "open" | "closed"; customer?: string }) { const matches = tickets.filter( (t) => (!query || t.title.toLowerCase().includes(query.toLowerCase())) && (!status || t.status === status) && (!customer || t.customer === customer), ); return { tickets: matches.slice(0, 10), total: matches.length }; } } ``` ```ts tickets.ts export const tickets = [ { id: "T-4", title: "Login link expired", status: "open", customer: "dana@acme.com" }, { id: "T-3", title: "Cannot log in", status: "closed", customer: "dana@acme.com" }, { id: "T-2", title: "Invoice total is wrong", status: "open", customer: "dana@acme.com" }, { id: "T-1", title: "Cannot log in", status: "open", customer: "sam@globex.com" }, ]; ``` One tool, one description, three described filters, and the question takes one call. Keep tools separate when they genuinely do different things: they have different side effects (reading and writing), different permissions, or return different kinds of results. Merge them when the only difference is which records they return. ## `name` is for the model, `title` is for people A tool has two names, and they have different jobs: - **`name`** is the identifier the model uses to call the tool, like `search_tickets`. It should be a `snake_case` verb and noun, unique on your server, and stable: models, prompts and tests refer to it, so renaming it breaks them. - **`title`** is a display name for people, like "Search tickets". Clients can show it in their UI, for example when they ask the user to approve a call. Set it with `@Tool({ title })`. `tools/list` sends `title` only for tools that have one; for the others, clients show the name. Resources and prompts take a `title` option too: ```ts search.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", title: "Search tickets", description: "Search support tickets by words in their title. Returns up to 10 matches, newest first.", inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles") }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [] as { id: string }[], query }; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id, with every message in its conversation.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, messages: [] as string[] }; } } ``` ```ts refund-policy.resource.ts import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "refund-policy", title: "Refund policy", uri: "docs://refund-policy", mimeType: "text/markdown", description: "When customers can get a refund, and how much. Read it before answering any question about refunds or charges.", }) export class RefundPolicy extends ResourceContext { async execute(uri: string) { return { contents: [{ uri, mimeType: "text/markdown", text: "# Refunds\n\nFull refund within 30 days of purchase." }] }; } } ``` ```ts titles.test.ts import { test, expect } from "@frontmcp/testing"; test("search_tickets has the display name \"Search tickets\"", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "search_tickets"); expect(tool?.title).toBe("Search tickets"); }); test("get_ticket has no title, so it's listed without one", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "get_ticket"); expect(tool).not.toHaveProperty("title"); }); test("the resource has its own `title`", async ({ mcp }) => { const [resource] = await mcp.resources.list(); expect(resource.title).toBe("Refund policy"); }); ``` Open the Wire tab to see both lists. MCP also has an older place for a tool's display name, `annotations.title`. FrontMCP sends it as you set it, but `title` is the one to use. The resource's description follows the same rules as a tool's: say what's in it and when it's useful. Resources are usually picked by an application or a user rather than by the model, so the reader of that description may be a person choosing what to attach to a conversation. See [Exposing Data with Resources](https://frontmcp.dev/learn/exposing-data-with-resources). ## Checking descriptions with tests Descriptions drift. A tool gets renamed and another tool's description still points to the old name; someone adds an argument without a note. Tests over `tools/list` catch these, the same way they catch bugs in `execute()`: ```ts tickets.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Returns up to 10 matches, newest first, each with its id, title and status. To read a whole ticket, pass its id to get_ticket.", inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles, like \"log in\"") }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [] as { id: string }[], query }; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id, with every message in its conversation. Use it when you already have an id like T-12; to find an id, use search_tickets.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, messages: [] as string[] }; } } ``` ```ts descriptions.test.ts import { test, expect } from "@frontmcp/testing"; test("every tool has a description of at least 60 characters", async ({ mcp }) => { for (const tool of await mcp.tools.list()) { expect(tool.description?.length ?? 0).toBeGreaterThanOrEqual(60); } }); test("every argument has a description", async ({ mcp }) => { for (const tool of await mcp.tools.list()) { for (const [name, field] of Object.entries(tool.inputSchema.properties ?? {})) { expect([tool.name, name, field.description?.length > 0]).toEqual([tool.name, name, true]); } } }); test("tools named in descriptions exist", async ({ mcp }) => { const tools = await mcp.tools.list(); const names = tools.map((t) => t.name); for (const tool of tools) { for (const mentioned of tool.description?.match(/\b[a-z]+(?:_[a-z]+)+\b/g) ?? []) { expect(names).toContain(mentioned); } } }); ``` The **Tests** tab runs three checks: every tool has a real description, every argument has a note, and every `snake_case` tool name mentioned in a description is a tool that exists. Try renaming `get_ticket` to `read_ticket` and run the tests again: the third check fails, because `search_tickets` still sends the model to `get_ticket`. The second check puts the tool and field names in its comparison, so a failure says which argument is missing its note. Tests like these check that the words are there, not that a model understands them. For that, connect a real client (see [Installation](https://frontmcp.dev/learn/installation#connecting-a-client)), ask the questions your users ask, and watch which tools the model picks and what it sends. ## Recap - A model chooses tools and fills in arguments from names, descriptions and schemas alone. Descriptions are instructions. - A tool description says what it does, what it returns, when to use it (and which tool to use instead), and any side effects. - Name the other tool in an "instead" sentence when two tools are easy to confuse. - Argument descriptions give units, formats, examples and where values come from. Put units in field names too. - `@Tool({ examples })` isn't sent in `tools/list` in FrontMCP 1.8. Write examples into descriptions. - Merge tools that differ only by a filter into one tool with optional arguments. - `name` is the stable identifier the model calls; `title` is a display name for people. Tools, resources and prompts all take a `title` option. - Test descriptions over `tools/list`: presence, length, argument notes, and references to other tools. ## Try some challenges ### Challenge: Keep the model from deleting real tickets `close_ticket` and `delete_ticket` have descriptions a model can't tell apart. Rewrite both descriptions so that `close_ticket` says it's for solved problems and names `delete_ticket` as the one *not* to use, and `delete_ticket` says it's permanent, is only for spam, and names `close_ticket` as the one to use instead. Each should be at least 60 characters. ```ts tickets.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a ticket.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "delete_ticket", description: "Remove a ticket.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") }, annotations: { destructiveHint: true }, }) export class DeleteTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, deleted: true }; } } ``` **Solution file (alternative to the starter):** ```ts tickets.tools.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket once the customer's problem is solved. The ticket stays in the history and can be reopened. Don't use delete_ticket for this.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "delete_ticket", description: "Permanently delete a ticket and its conversation. Only for spam or tickets opened by mistake; to finish a real ticket, use close_ticket instead. This can't be undone.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") }, annotations: { destructiveHint: true }, }) export class DeleteTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, deleted: true }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts descriptions.test.ts hidden import { test, expect } from "@frontmcp/testing"; async function descriptionOf(mcp: any, name: string): Promise { const tool = (await mcp.tools.list()).find((t: any) => t.name === name); return tool?.description ?? ""; } test("both descriptions are at least 60 characters", async ({ mcp }) => { expect((await descriptionOf(mcp, "close_ticket")).length).toBeGreaterThanOrEqual(60); expect((await descriptionOf(mcp, "delete_ticket")).length).toBeGreaterThanOrEqual(60); }); test("close_ticket says it's for solved problems", async ({ mcp }) => { expect(await descriptionOf(mcp, "close_ticket")).toMatch(/solved|resolved|fixed/i); }); test("close_ticket names delete_ticket", async ({ mcp }) => { expect(await descriptionOf(mcp, "close_ticket")).toContain("delete_ticket"); }); test("delete_ticket says it's permanent", async ({ mcp }) => { expect(await descriptionOf(mcp, "delete_ticket")).toMatch(/permanent|can't be undone|cannot be undone/i); }); test("delete_ticket says it's only for spam", async ({ mcp }) => { expect(await descriptionOf(mcp, "delete_ticket")).toMatch(/spam/i); }); test("delete_ticket names close_ticket as the alternative", async ({ mcp }) => { expect(await descriptionOf(mcp, "delete_ticket")).toContain("close_ticket"); }); ``` **Hint:** Write each description in the order from this lesson: what it does, then when to use the other tool instead. The riskier tool needs the clearer warning. **Solution:** Each description now says what the tool is for and names the other one, so "we fixed Dana's problem" leads to `close_ticket` and "this ticket is spam" leads to `delete_ticket`. The `destructiveHint` annotation tells clients the call is risky, but only the description tells the model *when* it's the right call. ### Challenge: Say which unit `issue_refund` takes an `amount`, and the code reads it in cents. A model asked to "refund $19.99" will send `19.99`, and the customer gets 20 cents back. Rename the argument to `amount_cents`, make it a whole number of at least 1, and describe it with the unit and an example. Initial call configuration: ```js { tool: "issue_refund", arguments: { id: "T-2", amount: 19.99 } } ``` ```ts refund.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "issue_refund", description: "Refund part or all of a customer's payment on a ticket. The money goes back to the card they paid with.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12"), amount: z.number(), }, }) export class IssueRefund extends ToolContext { async execute({ id, amount }: { id: string; amount: number }) { return { id, refunded: `$${(amount / 100).toFixed(2)}` }; } } ``` **Solution file (alternative to the starter):** ```ts refund.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "issue_refund", description: "Refund part or all of a customer's payment on a ticket. The money goes back to the card they paid with.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12"), amount_cents: z.number().int().min(1).describe("How much to refund, in cents: 1999 is $19.99"), }, }) export class IssueRefund extends ToolContext { async execute({ id, amount_cents }: { id: string; amount_cents: number }) { return { id, refunded: `$${(amount_cents / 100).toFixed(2)}` }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts refund.test.ts hidden import { test, expect } from "@frontmcp/testing"; async function fields(mcp: any) { const tool = (await mcp.tools.list()).find((t: any) => t.name === "issue_refund"); return tool.inputSchema.properties; } test("the argument is called `amount_cents`", async ({ mcp }) => { const props = await fields(mcp); expect(props).toHaveProperty("amount_cents"); expect(props).not.toHaveProperty("amount"); }); test("its description says \"cents\"", async ({ mcp }) => { expect((await fields(mcp)).amount_cents?.description).toMatch(/cents/i); }); test("it's a whole number of at least 1", async ({ mcp }) => { const field = (await fields(mcp)).amount_cents; expect(field?.type).toBe("integer"); expect(field?.minimum).toBe(1); }); test("refunding 1999 cents refunds $19.99", async ({ mcp }) => { const result = await mcp.tools.call("issue_refund", { id: "T-2", amount_cents: 1999 }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ id: "T-2", refunded: "$19.99" }); }); test("19.99 is rejected instead of refunding 20 cents", async ({ mcp }) => { const result = await mcp.tools.call("issue_refund", { id: "T-2", amount_cents: 19.99 }); expect(result.raw._meta?.code).toBe("INVALID_INPUT"); }); ``` **Hint:** `.int()` makes a number whole, and shows up in `tools/list` as `"type": "integer"`. Rename the field in `execute()` too. **Solution:** The unit is now in three places a model reads: the name `amount_cents`, the note "in cents: 1999 is $19.99", and `"type": "integer"`, which rules out `19.99`. If a model still sends dollars, FrontMCP rejects the call instead of refunding the wrong amount. ### Challenge: Make the description checks pass These tools fail the description checks from this lesson. `search_tickets` points to a tool that was renamed, `get_ticket` has no description, and `reply_to_ticket` has an argument without a note. Fix the descriptions until every check passes. ```ts tickets.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Returns up to 10 matches. Use fetch_ticket for details.", inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles") }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [] as { id: string }[], query }; } } @Tool({ name: "get_ticket", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, messages: [] as string[] }; } } @Tool({ name: "reply_to_ticket", description: "Email a reply to the customer who opened a ticket. The customer receives it immediately.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12"), body: z.string().min(1), }, }) export class ReplyToTicket extends ToolContext { async execute({ id }: { id: string; body: string }) { return { id, sent: true }; } } ``` **Solution file (alternative to the starter):** ```ts tickets.tools.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Returns up to 10 matches. Use get_ticket for details.", inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles") }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [] as { id: string }[], query }; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id, with every message in its conversation. To find an id, use search_tickets.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, messages: [] as string[] }; } } @Tool({ name: "reply_to_ticket", description: "Email a reply to the customer who opened a ticket. The customer receives it immediately.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12"), body: z.string().min(1).describe("The reply, in plain text, written to the customer"), }, }) export class ReplyToTicket extends ToolContext { async execute({ id }: { id: string; body: string }) { return { id, sent: true }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts descriptions.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("every tool has a description of at least 60 characters", async ({ mcp }) => { for (const tool of await mcp.tools.list()) { expect([tool.name, tool.description?.length >= 60]).toEqual([tool.name, true]); } }); test("every argument has a description", async ({ mcp }) => { for (const tool of await mcp.tools.list()) { for (const [name, field] of Object.entries(tool.inputSchema.properties ?? {})) { expect([tool.name, name, field.description?.length > 0]).toEqual([tool.name, name, true]); } } }); test("tools named in descriptions exist", async ({ mcp }) => { const tools = await mcp.tools.list(); const names = tools.map((t) => t.name); for (const tool of tools) { for (const mentioned of tool.description?.match(/\b[a-z]+(?:_[a-z]+)+\b/g) ?? []) { expect(names).toContain(mentioned); } } }); ``` **Hint:** Open the **Checks** tab after pressing Check: each failure names the tool, and for arguments, the field. **Solution:** Three small fixes: point `search_tickets` at `get_ticket`, give `get_ticket` a description that says what it returns and where ids come from, and describe `body`. Checks like these are cheap to keep in your own test suite, so the next rename fails a test instead of confusing a model. ### Challenge: Give a tool a display title Clients show tool names to users, for example when asking them to approve a call, and `close_ticket` isn't friendly. Give the tool the display title "Close ticket" without changing its `name`. ```ts close.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket once the customer's problem is solved. The ticket can be reopened later.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } ``` **Solution file (alternative to the starter):** ```ts close.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket once the customer's problem is solved. The ticket can be reopened later.", title: "Close ticket", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-12") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts title.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("the display title is \"Close ticket\"", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "close_ticket"); expect(tool?.title).toBe("Close ticket"); }); test("the name is still close_ticket", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("close_ticket"); }); ``` **Hint:** Look at how the lesson gave `search_tickets` its display name. **Solution:** `title` goes out in `tools/list` next to the name, for clients to show. The `name` stays `close_ticket`, so nothing that calls the tool has to change. --- # Talking to Clients > How a tool talks to the client while it runs. Asking the user a question mid-call, and reporting progress and logs from long-running tools. Source: https://frontmcp.dev/learn/talking-to-clients [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 So far, every call has gone one way: the model calls a tool, the tool runs, and a result comes back. A FrontMCP tool can also talk to the client in between. It can stop and ask the person using the client a question, and it can report how far along it is while it works. This chapter covers both, and what each one looks like on the wire under MCP 2026-07-28. **In this chapter** - [How to ask the user a question in the middle of a call](https://frontmcp.dev/learn/asking-the-user) - [How to report progress and send log messages from a long-running tool](https://frontmcp.dev/learn/reporting-progress) ## Asking the user mid-call Some calls need a person, not a model: a yes before something that can't be undone, or a detail only the user knows. `this.elicit()` sends the user a question with a small form and gives your tool their answer. The call below stops to ask. Press **Accept**, **Decline** or **Cancel** in the Call tab to answer it: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket and email the customer. Asks the user to confirm first.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit( `Close ${id} and email the customer that it's resolved?`, z.object({ confirm: z.boolean().describe("Tick to close the ticket") }), ); if (answer.status !== "accept" || answer.content?.confirm !== true) { return { id, closed: false, reason: `The user didn't confirm (${answer.status}).` }; } return { id, closed: true }; } } ``` Under 2026-07-28 nothing waits on the server while the user thinks: the call returns `input_required`, and the client sends the same request again with the answer. ### [Asking the User Mid-Call](https://frontmcp.dev/learn/asking-the-user) Learn how to handle accept, decline and cancel, why `execute()` runs twice, what happens with clients that can't show a form, and how to turn elicitation on and test it. ## Reporting progress and logs A slow tool looks just like a stuck one until it returns. `this.progress()` reports how much is done, and `this.notify()` sends log messages about what's happening. Clients ask for them per request, and get them as a stream before the result: Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts export-tickets.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; const tickets = ["T-1", "T-2", "T-3", "T-4"]; @Tool({ name: "export_tickets", description: "Export every support ticket to tickets.csv. Takes a few seconds.", inputSchema: {}, }) export class ExportTickets extends ToolContext { async execute() { await this.notify(`Exporting ${tickets.length} tickets`); for (const [i, id] of tickets.entries()) { await new Promise((resolve) => setTimeout(resolve, 100)); await this.progress(i + 1, tickets.length, `Exported ${id}`); } return { file: "tickets.csv", rows: tickets.length }; } } ``` The Call tab shows the notifications above the result. Untick **Stream progress & logs** and call again: the tool works the same, it just has nobody to report to. ### [Reporting Progress and Logs](https://frontmcp.dev/learn/reporting-progress) Learn what to pass to `progress()` and `notify()`, how log levels work, what the event stream looks like, and how to test notifications. ## What's next? Start the chapter with [Asking the User Mid-Call](https://frontmcp.dev/learn/asking-the-user). After it, [Structuring a Server](https://frontmcp.dev/learn/structuring-a-server) covers how a server grows: shared state in providers, plugins, and hooks around every call. --- # Asking the User Mid-Call > How a tool asks the person using the client a question in the middle of a call, with this.elicit(), and what happens on the wire under MCP 2026-07-28. Source: https://frontmcp.dev/learn/asking-the-user [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Some tools can't finish on their own. A tool that closes a ticket and emails the customer is about to do something that can't be taken back. A tool that assigns a ticket needs to know who should get it, and only the person using the client knows. `this.elicit()` lets a FrontMCP tool pause its call to ask that person a question, through a form their client shows them. The model doesn't answer it. The user does. **You will learn** - How to ask the user a question with `this.elicit()` - How to handle each answer: accept, decline and cancel - What goes over the wire under MCP 2026-07-28, and why `execute()` runs twice - What happens with clients that can't show a form - How to turn elicitation on in your server, and how to test a tool that asks ## A tool that acts without asking This tool closes a ticket and emails the customer that their problem is solved. A user says "I think the login problem is sorted", and the model calls it: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { outbox, tickets } from "./store"; @Tool({ name: "close_ticket", description: "Close a support ticket and email the customer that it's resolved.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); ticket.status = "closed"; outbox.push(`To the customer of ${id}: "${ticket.title}" is resolved.`); return { id, closed: true }; } } ``` ```ts store.ts export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }], ["T-3", { id: "T-3", title: "Login link expired", status: "open" }], ]); export const outbox: string[] = []; ``` The ticket is closed and the email is gone before anyone confirmed that the problem really is solved. `destructiveHint: true` lets a client warn the user before it sends the call, but it's a hint: whether anyone is asked depends on the client. If the tool itself needs a yes before it acts, the tool has to ask. ## Asking with `this.elicit()` `this.elicit(message, schema)` asks the user a question and returns their answer. The message is the question. The schema is a `z.object()` describing the form the client shows: here, one checkbox. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { outbox, tickets } from "./store"; @Tool({ name: "close_ticket", description: "Close a support ticket and email the customer that it's resolved. Asks the user to confirm first.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); const answer = await this.elicit( `Close ${id} ("${ticket.title}") and email the customer that it's resolved?`, z.object({ confirm: z.boolean().describe("Tick to close the ticket") }), ); if (answer.status === "decline") return { id, closed: false, reason: "The user said no. Leave the ticket open." }; if (answer.status === "cancel") return { id, closed: false, reason: "The user dismissed the question without answering." }; if (answer.content?.confirm !== true) return { id, closed: false, reason: "The user didn't tick the box." }; ticket.status = "closed"; outbox.push(`To the customer of ${id}: "${ticket.title}" is resolved.`); return { id, closed: true }; } } ``` ```ts store.ts export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }], ["T-3", { id: "T-3", title: "Login link expired", status: "open" }], ]); export const outbox: string[] = []; ``` The Call tab shows the form the user would see. Tick the box and press **Accept**, then call the tool again and try **Decline** and **Cancel**. `elicit()` returns an object with a `status`, and `content` when there is one: | `status` | What the user did | `content` | | --- | --- | --- | | `"accept"` | Submitted the form | The form's values, like `{ confirm: true }` | | `"decline"` | Said no | none | | `"cancel"` | Closed the form without choosing | none | Handle all three, and say in the result what happened. The model reads the result: "The user said no" tells it to stop, where a bare `closed: false` might send it straight back to try again. Keep the schema a flat object of strings, numbers, booleans and enums. Clients have to turn it into a form, and that's all the MCP spec allows in one. `.describe()` on a field goes into the schema as its description, which clients show next to the field. This tool asks on every call. To ask once and let later calls through, put the tool behind [approval](https://frontmcp.dev/learn/asking-for-approval), which records the user's yes and checks for it before each call. ## Asking for a missing detail Elicitation isn't only for confirmations. When a tool needs something only the user can decide, it can ask for it. Here, the model may pass an `agent`, and if it doesn't, the tool asks the user to pick one: Initial call configuration: ```js { tool: "assign_ticket", arguments: { id: "T-2" } } ``` ```ts assign-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const AGENTS = ["Nour", "Sam", "Priya"] as const; const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", agent: "Nour" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", agent: "" }], ]); @Tool({ name: "assign_ticket", description: "Assign a support ticket to an agent. If you don't know who should take it, leave `agent` out and the user will be asked.", inputSchema: { id: z.string().describe("Ticket id, like T-1"), agent: z.enum(AGENTS).optional().describe("Who should take the ticket"), }, }) export class AssignTicket extends ToolContext { async execute({ id, agent }: { id: string; agent?: (typeof AGENTS)[number] }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); if (!agent) { const answer = await this.elicit( `Who should take ${id} ("${ticket.title}")?`, z.object({ agent: z.enum(AGENTS).describe("Support agent") }), ); const picked = answer.content?.agent; if (answer.status !== "accept" || !picked || !AGENTS.includes(picked)) { return { id, assigned: false, reason: "The user didn't pick an agent." }; } agent = picked; } ticket.agent = agent; return { id, assigned: true, agent }; } } ``` Pick an agent and press **Accept**. Then change the call to include `agent` and send it again: the tool doesn't ask, because it doesn't need to. That's the split to aim for. The model fills in `inputSchema` from what it knows. The user answers an elicitation, for what only they can decide. Asking for something the model could have passed just interrupts the user. > **Pitfall: Check the answer** > > Under protocol 2026-07-28, FrontMCP checks an accepted answer against the schema you passed to `elicit()` before `elicit()` returns. A missing or mistyped field fails the call with `INVALID_INPUT` and "The elicitation answer does not match the requested schema", so your tool never sees it. Clients on older protocol versions aren't checked this way, and a `decline` or `cancel` can carry any `content`, so still check the fields you rely on: `answer.content?.confirm !== true` rather than `!answer.content.confirm`, and `AGENTS.includes(picked)` above. The tests [further down](https://frontmcp.dev/learn/asking-the-user#testing-a-tool-that-asks) show a malformed answer being rejected. ## What happens on the wire In MCP 2026-07-28, the server never holds a call open while it waits for the user. Open the **Wire** tab in the example above, answer the question, and look at the two `tools/call` requests: 1. The first `tools/call` runs `execute()` until it reaches `elicit()`. Instead of a normal result, the call returns `resultType: "input_required"`, with the question in `inputRequests` and a signed `requestState`: ```json { "resultType": "input_required", "inputRequests": { "elicitation-1": { "method": "elicitation/create", "params": { "message": "Who should take T-2 (\"Invoice total is wrong\")?", "requestedSchema": { "type": "object", "properties": { "agent": { "type": "string", "enum": ["Nour", "Sam", "Priya"], "description": "Support agent" } }, "required": ["agent"], "additionalProperties": false } } } }, "requestState": "eyJyIjp7fSwicCI6…" } ``` 2. The client shows the form. Nothing is waiting on the server: the first request is finished. 3. The client sends the **same** `tools/call` again, with the answer in `inputResponses` under the same key, and the `requestState` it was given: ```json { "name": "assign_ticket", "arguments": { "id": "T-2" }, "inputResponses": { "elicitation-1": { "action": "accept", "content": { "agent": "Priya" } } }, "requestState": "eyJyIjp7fSwicCI6…" } ``` 4. FrontMCP runs `execute()` again **from the top**. This time `elicit()` finds an answer for `elicitation-1` and returns it instead of asking, and the call finishes with a normal result. Both messages are trimmed here; the Wire tab shows them in full, with `_meta` and the schema's `$schema`. The key counts the `elicit()` calls in one run of `execute()`: the first is `elicitation-1`, a second would be `elicitation-2`. On the wire the answer is `action`, and FrontMCP hands it to your code as `status`. ### What's in requestState? `requestState` is a token FrontMCP creates and the client passes back without reading. It holds the answers from earlier rounds, and it's signed with an HMAC, bound to the caller and to the tool name and arguments, and expires after 10 minutes. If a returned state fails any of those checks, FrontMCP ignores it and logs a warning. The signing key is `VAULT_SECRET` or `JWT_SECRET` from the environment. Without either, each server process picks a random key, so if you run several instances behind a load balancer, give them all [the same secret](https://frontmcp.dev/learn/running-several-instances#the-secrets-every-instance-shares). ## Ask first, then act Because `execute()` starts over when the answer arrives, every line before `elicit()` runs once per round trip. This version writes to the ticket's history before it asks. Accept the question and look at `history`: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = new Map([["T-1", { id: "T-1", status: "open", history: ["Opened by the customer"] }]]); @Tool({ name: "close_ticket", description: "Close a support ticket. Asks the user to confirm first.", inputSchema: { id: z.string() }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); ticket.history.push("Close requested"); // 🚩 runs on both round trips const answer = await this.elicit(`Close ${id}?`, z.object({ confirm: z.boolean() })); if (answer.status !== "accept" || answer.content?.confirm !== true) { return { id, closed: false, history: ticket.history }; } ticket.status = "closed"; ticket.history.push("Closed"); return { id, closed: true, history: ticket.history }; } } ``` "Close requested" is there twice. Reading data before you ask is fine; changing anything, sending anything or charging anything is not. Do those after the answer, as the `close_ticket` [above](https://frontmcp.dev/learn/asking-the-user#asking-with-thiselicit) does. > **Pitfall: Ask for everything in one form** > > Each question is a full round trip, and each one runs `execute()` again from the top. If you need several details, ask for them together, in one `z.object()` with several fields. ## Testing a tool that asks In a test, nobody is there to fill in the form. `mcp.onElicitation()` registers the answer the test client gives when a tool asks. The handler receives the request, with its `message` and `requestedSchema`, and returns `{ action, content }`. Open the **Tests** tab: ```ts close-ticket.test.ts active import { test, expect } from "@frontmcp/testing"; test.describe("close_ticket", () => { test("asks about the right ticket, and closes it on yes", async ({ mcp }) => { let question = ""; mcp.onElicitation((request) => { question = request.message; return { action: "accept", content: { confirm: true } }; }); const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(question).toContain("T-1"); expect(result.json()).toEqual({ id: "T-1", closed: true }); }); test("leaves the ticket open when the user declines", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "decline" })); const result = await mcp.tools.call("close_ticket", { id: "T-2" }); expect(result.json()).toMatchObject({ closed: false }); }); test("an answer that doesn't match the schema is rejected", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "accept", content: { confirm: "yes" } })); const result = await mcp.tools.call("close_ticket", { id: "T-3" }); expect(result).toBeError("INVALID_INPUT"); expect(result.text()).toContain("does not match the requested schema"); }); }); ``` ```ts close-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { outbox, tickets } from "./store"; @Tool({ name: "close_ticket", description: "Close a support ticket and email the customer that it's resolved. Asks the user to confirm first.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); const answer = await this.elicit( `Close ${id} ("${ticket.title}") and email the customer that it's resolved?`, z.object({ confirm: z.boolean().describe("Tick to close the ticket") }), ); if (answer.status === "decline") return { id, closed: false, reason: "The user said no. Leave the ticket open." }; if (answer.status === "cancel") return { id, closed: false, reason: "The user dismissed the question without answering." }; if (answer.content?.confirm !== true) return { id, closed: false, reason: "The user didn't tick the box." }; ticket.status = "closed"; outbox.push(`To the customer of ${id}: "${ticket.title}" is resolved.`); return { id, closed: true }; } } ``` ```ts store.ts export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }], ["T-3", { id: "T-3", title: "Login link expired", status: "open" }], ]); export const outbox: string[] = []; ``` The last test sends `confirm: "yes"`, a string where the schema says boolean. FrontMCP rejects the answer before `elicit()` returns, so the call fails with `INVALID_INPUT` and `execute()` never gets past the question. [Testing Your Server](https://frontmcp.dev/learn/testing-your-server) covers the rest of `@frontmcp/testing`. ## Clients that can't ask Not every client can show a form. Under 2026-07-28, every request says what its client supports, in `_meta["io.modelcontextprotocol/clientCapabilities"]`. The Playground sends `{ elicitation: { form: {} } }`, so its calls can be asked questions. A client that leaves `elicitation` out gets a JSON-RPC error instead: `-32021`, with the capability it's missing in `data.requiredCapabilities`. The first test below sends a 2026-07-28 request the way such a client would, with an empty capabilities object: ```ts capabilities.test.ts active import { test, expect } from "@frontmcp/testing"; test("a client without the elicitation capability gets error -32021", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "close_ticket", arguments: { id: "T-1" }, _meta: { "io.modelcontextprotocol/clientCapabilities": {} }, }, }); expect(response).toHaveErrorCode(-32021); expect(response.error?.data).toEqual({ requiredCapabilities: { elicitation: { form: {} } } }); }); test("2026-07-28 clients don't see the fallback tool", async ({ mcp }) => { expect(await mcp.tools.list()).not.toContainTool("sendElicitationResult"); }); ``` ```ts close-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket. Asks the user to confirm first.", inputSchema: { id: z.string() }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ${id}?`, z.object({ confirm: z.boolean() })); return { id, closed: answer.status === "accept" && answer.content?.confirm === true }; } } ``` The error happens at `elicit()`, so the tool never gets an answer and the client gets no result. If a tool can do something useful without asking, like a read-only preview, make asking optional in its input rather than letting such clients fail. The second test is about a tool you may see in other clients: `sendElicitationResult`, which you didn't write. FrontMCP adds it when elicitation is enabled, as a fallback for clients on older protocol versions that keep a session open but can't show forms. For those clients, FrontMCP tells the model what to ask instead, in the tool's result or in a log message while the call waits. The model asks the user in the chat and calls `sendElicitationResult` with the answer, and FrontMCP runs the original tool again with it. FrontMCP lists the tool only for those clients, so a 2026-07-28 client like the Playground never sees it; one without the capability gets `-32021`, as above. ## Turning elicitation on The Playground turns elicitation on for you when an example calls `this.elicit()`. A real server has to ask for it. This example exports its own `@FrontMcp` server, so the Playground uses its configuration as written: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; @App({ id: "help-desk", name: "Help desk", tools: [CloseTicket] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts close-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket. Asks the user to confirm first.", inputSchema: { id: z.string() }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ${id}?`, z.object({ confirm: z.boolean() })); return { id, closed: answer.status === "accept" && answer.content?.confirm === true }; } } ``` The call fails with `ELICITATION_DISABLED`: "Elicitation is disabled in server configuration", followed by how to enable it. Elicitation is off unless you enable it in `@FrontMcp`: ```ts main.ts @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true }, }) export default class Server {} ``` ## Recap - `this.elicit(message, z.object({...}))` asks the user a question through their client. The model doesn't answer it. - Handle `accept`, `decline` and `cancel`, and say in the result what happened. Under 2026-07-28, FrontMCP rejects an accepted answer that doesn't match the schema; still check the fields of `content` you rely on, for older clients. - Use `inputSchema` for what the model knows and elicitation for what only the user can decide. - Under 2026-07-28 the call returns `input_required`, and the client sends the same request again with `inputResponses`. `execute()` runs again from the top, so don't change anything before you ask. - A client that doesn't declare the `elicitation` capability gets error `-32021`. - Enable elicitation with `@FrontMcp({ elicitation: { enabled: true } })`, and answer questions in tests with `mcp.onElicitation()`. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Confirm before deleting `delete_ticket` deletes a ticket as soon as it's called. Make it ask the user first, with a `confirm` checkbox and a message that names the ticket. Only delete when the user accepts and ticks the box. Initial call configuration: ```js { tool: "delete_ticket", arguments: { id: "T-1" } } ``` ```ts delete-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in" }], ["T-2", { id: "T-2", title: "Invoice total is wrong" }], ["T-3", { id: "T-3", title: "Login link expired" }], ["T-4", { id: "T-4", title: "Password reset email missing" }], ]); @Tool({ name: "delete_ticket", description: "Permanently delete a support ticket.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, }) export class DeleteTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); tickets.delete(id); return { id, deleted: true }; } } ``` **Solution file (alternative to the starter):** ```ts delete-ticket.tool.ts solution import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in" }], ["T-2", { id: "T-2", title: "Invoice total is wrong" }], ["T-3", { id: "T-3", title: "Login link expired" }], ["T-4", { id: "T-4", title: "Password reset email missing" }], ]); @Tool({ name: "delete_ticket", description: "Permanently delete a support ticket. Asks the user to confirm first.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, }) export class DeleteTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); const answer = await this.elicit( `Permanently delete ${id} ("${ticket.title}")? This can't be undone.`, z.object({ confirm: z.boolean().describe("Tick to delete the ticket") }), ); if (answer.status !== "accept" || answer.content?.confirm !== true) { return { id, deleted: false, reason: `The user didn't confirm (${answer.status}).` }; } tickets.delete(id); return { id, deleted: true }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts delete-ticket.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("it asks about the ticket it's deleting", async ({ mcp }) => { let question = ""; mcp.onElicitation((request) => { question = request.message; return { action: "decline" }; }); await mcp.tools.call("delete_ticket", { id: "T-1" }); expect(question).toContain("T-1"); }); test("declining keeps the ticket", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "decline" })); const result = await mcp.tools.call("delete_ticket", { id: "T-2" }); expect(result).toBeSuccessful(); expect(result.json()).toMatchObject({ deleted: false }); }); test("cancelling keeps the ticket", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "cancel" })); const result = await mcp.tools.call("delete_ticket", { id: "T-3" }); expect(result.json()).toMatchObject({ deleted: false }); }); test("accepting without ticking `confirm` keeps the ticket", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "accept", content: { confirm: false } })); const result = await mcp.tools.call("delete_ticket", { id: "T-4" }); expect(result.json()).toMatchObject({ deleted: false }); }); test("accepting with `confirm` ticked deletes it", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "accept", content: { confirm: true } })); const result = await mcp.tools.call("delete_ticket", { id: "T-4" }); expect(result.json()).toEqual({ id: "T-4", deleted: true }); }); ``` **Hint:** Call `this.elicit()` after looking the ticket up and before `tickets.delete()`. Its second argument is `z.object({ confirm: z.boolean() })`. **Solution:** The tool asks first, and deletes only when `status` is `"accept"` and `confirm` is exactly `true`. Everything else, a decline, a cancel or an unticked box, keeps the ticket and says why. Asking comes after the lookup, which only reads, and before the delete, which is the one thing that mustn't run on the first round. ### Challenge: Send the email once This `close_ticket` asks before closing, but customers get their "resolved" email twice, and sometimes when the user said no. Fix it so accepting sends exactly one email and declining sends none. `list_outbox` shows what was sent. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { outbox, tickets } from "./store"; @Tool({ name: "close_ticket", description: "Close a support ticket and email the customer that it's resolved. Asks the user to confirm first.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); outbox.push(`To the customer of ${id}: "${ticket.title}" is resolved.`); const answer = await this.elicit(`Close ${id} and email the customer?`, z.object({ confirm: z.boolean() })); if (answer.status !== "accept" || answer.content?.confirm !== true) { return { id, closed: false }; } ticket.status = "closed"; return { id, closed: true }; } } ``` **Solution file (alternative to the starter):** ```ts close-ticket.tool.ts solution import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { outbox, tickets } from "./store"; @Tool({ name: "close_ticket", description: "Close a support ticket and email the customer that it's resolved. Asks the user to confirm first.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); const answer = await this.elicit(`Close ${id} and email the customer?`, z.object({ confirm: z.boolean() })); if (answer.status !== "accept" || answer.content?.confirm !== true) { return { id, closed: false }; } ticket.status = "closed"; outbox.push(`To the customer of ${id}: "${ticket.title}" is resolved.`); return { id, closed: true }; } } ``` ```ts outbox.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; import { outbox } from "./store"; @Tool({ name: "list_outbox", description: "List the emails sent to customers.", inputSchema: {}, annotations: { readOnlyHint: true }, }) export class ListOutbox extends ToolContext { async execute() { return { emails: outbox }; } } ``` ```ts store.ts export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }], ]); export const outbox: string[] = []; ``` **Hidden fixture/test file (runs with the example):** ```ts close-ticket.test.ts hidden import { test, expect } from "@frontmcp/testing"; const emailsAbout = async (mcp: any, id: string) => (await mcp.tools.call("list_outbox", {})).json().emails.filter((e: string) => e.includes(id)); test("accepting closes the ticket and sends exactly one email", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "accept", content: { confirm: true } })); const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.json()).toEqual({ id: "T-1", closed: true }); expect(await emailsAbout(mcp, "T-1")).toHaveLength(1); }); test("declining sends no email", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "decline" })); const result = await mcp.tools.call("close_ticket", { id: "T-2" }); expect(result.json()).toEqual({ id: "T-2", closed: false }); expect(await emailsAbout(mcp, "T-2")).toHaveLength(0); }); ``` **Hint:** Count the round trips. The first run of `execute()` stops at `elicit()`; the second runs from the top with the answer. Which lines run in both? **Solution:** `outbox.push()` ran before `elicit()`, so it ran on the first round trip, when nobody had answered yet, and again on the second. Moving it after the check means it runs once, and only when the user said yes. The same goes for anything else with a side effect: ask first, then act. ### Challenge: Turn elicitation on This server exports its own `@FrontMcp` configuration, and `close_ticket` fails with "Elicitation is disabled". Turn elicitation on so the tool can ask. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; @App({ id: "help-desk", name: "Help desk", tools: [CloseTicket] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` **Solution file (alternative to the starter):** ```ts main.ts solution import { App, FrontMcp } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; @App({ id: "help-desk", name: "Help desk", tools: [CloseTicket] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true }, }) export default class Server {} ``` ```ts close-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket. Asks the user to confirm first.", inputSchema: { id: z.string() }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ${id}?`, z.object({ confirm: z.boolean() })); return { id, closed: answer.status === "accept" && answer.content?.confirm === true }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts main.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("close_ticket asks the user and closes on yes", async ({ mcp }) => { let asked = false; mcp.onElicitation(() => { asked = true; return { action: "accept", content: { confirm: true } }; }); const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result).toBeSuccessful(); expect(asked).toBe(true); expect(result.json()).toEqual({ id: "T-1", closed: true }); }); ``` **Hint:** It's one option on `@FrontMcp`, next to `info` and `apps`. **Solution:** `elicitation: { enabled: true }` in `@FrontMcp` lets tools call `this.elicit()`. With it, FrontMCP also adds the `sendElicitationResult` fallback tool for clients on older protocol versions that can't show forms. The Playground speaks 2026-07-28, so its Capabilities tab still lists only `close_ticket`. --- # Reporting Progress and Logs > How a long-running tool tells the client how far along it is and what it's doing, with this.progress() and this.notify(), and how clients opt in under MCP 2026-07-28. Source: https://frontmcp.dev/learn/reporting-progress [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A tool that takes ten seconds looks exactly like a tool that's stuck, until it returns. FrontMCP tools report while they work with `this.progress()` and `this.notify()`: how much is done, and anything worth knowing along the way. The client can show a progress bar, or a line in a log, and the person waiting knows the call is still alive. **You will learn** - How to report progress with `this.progress()` - How to send log messages with `this.notify()`, and which level to use - How a client asks for notifications under MCP 2026-07-28, and what the response looks like - What happens when a client doesn't ask - How to test progress and logs ## A tool that goes quiet This tool exports every ticket to a CSV file, one ticket at a time. Each ticket takes a moment: Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts export-tickets.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; import { exportRow, tickets } from "./store"; @Tool({ name: "export_tickets", description: "Export every support ticket to tickets.csv. Takes a few seconds.", inputSchema: {}, }) export class ExportTickets extends ToolContext { async execute() { const rows: string[] = []; for (const ticket of tickets) { rows.push(await exportRow(ticket)); } return { file: "tickets.csv", rows: rows.length }; } } ``` ```ts store.ts export const tickets = [ { id: "T-1", title: "Cannot log in" }, { id: "T-2", title: "" }, { id: "T-3", title: "Login link expired" }, { id: "T-4", title: "Password reset email missing" }, { id: "T-5", title: "Invoice total is wrong" }, ]; /** Pretend each ticket takes a moment to export. */ export async function exportRow(ticket: { id: string; title: string }) { await new Promise((resolve) => setTimeout(resolve, 150)); return `${ticket.id},"${ticket.title}"`; } ``` With five tickets that's under a second. With five thousand, the user stares at a spinner for minutes and can't tell a slow export from a stuck one. Nothing about this tool is wrong, except that it says nothing until it's done. ## Reporting progress `this.progress(progress, total, message)` sends a progress notification to the client that made the call: - `progress` is how much is done so far. It should go up with every call; the MCP spec requires it. - `total` is how much there is in all, if you know it. Leave it out if you don't. - `message` is a short description of the current step. It's optional too. Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts export-tickets.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; import { exportRow, tickets } from "./store"; @Tool({ name: "export_tickets", description: "Export every support ticket to tickets.csv. Takes a few seconds.", inputSchema: {}, }) export class ExportTickets extends ToolContext { async execute() { const rows: string[] = []; for (const [i, ticket] of tickets.entries()) { rows.push(await exportRow(ticket)); await this.progress(i + 1, tickets.length, `Exported ${ticket.id}`); } return { file: "tickets.csv", rows: rows.length }; } } ``` ```ts store.ts export const tickets = [ { id: "T-1", title: "Cannot log in" }, { id: "T-2", title: "" }, { id: "T-3", title: "Login link expired" }, { id: "T-4", title: "Password reset email missing" }, { id: "T-5", title: "Invoice total is wrong" }, ]; /** Pretend each ticket takes a moment to export. */ export async function exportRow(ticket: { id: string; title: string }) { await new Promise((resolve) => setTimeout(resolve, 150)); return `${ticket.id},"${ticket.title}"`; } ``` The Call tab now shows a progress bar and five `progress` notifications above the result. A real client gets each one as it's sent, while the tool is still running. The Playground collects the whole response first, so here they appear together when the call ends. Report after each real unit of work, like a ticket, a page or a file. Progress that jumps from 0 to 100% at the end doesn't tell anyone anything. ## Sending log messages Progress says how far along a tool is. `this.notify(message, level)` says what's happening, as a log message: Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts export-tickets.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; import { exportRow, tickets } from "./store"; @Tool({ name: "export_tickets", description: "Export every support ticket to tickets.csv. Takes a few seconds.", inputSchema: {}, }) export class ExportTickets extends ToolContext { async execute() { await this.notify(`Exporting ${tickets.length} tickets to tickets.csv`); const rows: string[] = []; for (const [i, ticket] of tickets.entries()) { if (!ticket.title) await this.notify(`${ticket.id} has no title. It's exported with an empty one.`, "warning"); rows.push(await exportRow(ticket)); await this.progress(i + 1, tickets.length, `Exported ${ticket.id}`); } await this.notify({ file: "tickets.csv", bytes: rows.join("\n").length }, "debug"); return { file: "tickets.csv", rows: rows.length }; } } ``` ```ts store.ts export const tickets = [ { id: "T-1", title: "Cannot log in" }, { id: "T-2", title: "" }, { id: "T-3", title: "Login link expired" }, { id: "T-4", title: "Password reset email missing" }, { id: "T-5", title: "Invoice total is wrong" }, ]; /** Pretend each ticket takes a moment to export. */ export async function exportRow(ticket: { id: string; title: string }) { await new Promise((resolve) => setTimeout(resolve, 150)); return `${ticket.id},"${ticket.title}"`; } ``` Each call to `notify()` sends a `notifications/message`: - A string becomes `data: { message: "..." }`. An object is sent as `data` as it is, for clients that read structured logs. - `level` is one of MCP's log levels, from least to most severe: `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"` and `"emergency"`. It defaults to `"info"`. - FrontMCP sets `logger` to the tool's name, so the client knows which tool said it. The client chooses the lowest level it wants, and FrontMCP drops anything below it. The Call tab asks for `"info"` and up, which is why the `debug` message with the byte count isn't in the list above. > **Note** > > A client may show notifications to the user or write them to a log, and it doesn't have to pass them to the model. The model reads the result. If the model needs to know something, like which tickets were skipped, put it in the result, and use `notify()` for the person watching. ## How a client asks for notifications In MCP 2026-07-28, notifications belong to the request that caused them, and a client asks for them in that request's `_meta`: - a `progressToken` asks for progress. The server copies the token into every progress notification, so a client with several calls in flight knows which one each update is about. - `io.modelcontextprotocol/logLevel` asks for log messages at that level and above. Open the **Wire** tab in the example above and expand the `tools/call`. The request carries both: ```json { "method": "tools/call", "params": { "name": "export_tickets", "arguments": {}, "_meta": { "progressToken": "progress-1", "io.modelcontextprotocol/logLevel": "info" } } } ``` (The real request's `_meta` also holds the protocol version, client info and capabilities.) When a request asks for either, FrontMCP answers with `Content-Type: text/event-stream` instead of plain JSON. Each notification is an event, sent as it happens, and the last event is the result: ```text event: message data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","logger":"export_tickets","data":{"message":"Exporting 5 tickets to tickets.csv"}}} event: message data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"progress-1","progress":1,"total":5,"message":"Exported T-1"}} … event: message data: {"jsonrpc":"2.0","id":6,"result":{"content":[…],"structuredContent":{"file":"tickets.csv","rows":5}}} ``` The Wire tab labels this response **SSE** and shows each event before the final response. When the call ends, the stream ends with it. A client in your own code asks the same way. One made with [`connect()`](https://frontmcp.dev/reference/sdk/connect#hearing-a-tools-progress) passes `{ onProgress }` as `callTool()`'s third argument, which sends a progress token with that call, and gets each update: see [`this.progress()`](https://frontmcp.dev/reference/sdk/progress#asking-for-progress). (Changed in 1.9.3: before, a `connect()` client had no way to ask.) ## When the client doesn't ask Not every client asks for notifications, and a client that asks for some calls may not ask for others. A tool has to work either way. This version puts what `progress()` returned into the result, so you can see it: Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts export-tickets.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; import { exportRow, tickets } from "./store"; @Tool({ name: "export_tickets", description: "Export every support ticket to tickets.csv. Takes a few seconds.", inputSchema: {}, }) export class ExportTickets extends ToolContext { async execute() { let reported = false; for (const [i, ticket] of tickets.entries()) { await exportRow(ticket); reported = await this.progress(i + 1, tickets.length, `Exported ${ticket.id}`); } // Only here to show what progress() returned. return { file: "tickets.csv", rows: tickets.length, progressReported: reported }; } } ``` ```ts no-listener.test.ts import { test, expect } from "@frontmcp/testing"; test("the export works for a client that doesn't ask for notifications", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "export_tickets", arguments: {} }, }); expect(response.result).toMatchObject({ structuredContent: { file: "tickets.csv", rows: 5, progressReported: false }, }); }); ``` ```ts store.ts export const tickets = [ { id: "T-1", title: "Cannot log in" }, { id: "T-2", title: "" }, { id: "T-3", title: "Login link expired" }, { id: "T-4", title: "Password reset email missing" }, { id: "T-5", title: "Invoice total is wrong" }, ]; /** Pretend each ticket takes a moment to export. */ export async function exportRow(ticket: { id: string; title: string }) { await new Promise((resolve) => setTimeout(resolve, 150)); return `${ticket.id},"${ticket.title}"`; } ``` The first call asked for progress, so `progressReported` is `true`. Untick **Stream progress & logs** and call again: the response is plain JSON, with no notifications, and `progressReported` is `false`. The export itself is the same. `progress()` and `notify()` return `true` when the notification was queued for the client, and `false` when the client didn't ask for it (or, for `notify()`, asked only for higher levels). They never throw for that reason, so you don't need to check before calling them. The test file does the same thing from code: `mcp.raw.request()` sends a request exactly as you write it, without a `progressToken`. ## Testing progress and logs The test client records the notifications that come back. `mcp.notifications.collectProgress().all` lists the progress updates, and `mcp.notifications.collect().received` lists every notification, logs included. In the Playground, each `mcp.tools.call()` asks for progress and for every log level, `debug` included: ```ts export-tickets.test.ts active import { test, expect } from "@frontmcp/testing"; test("reports progress once per ticket", async ({ mcp }) => { const progress = mcp.notifications.collectProgress(); const result = await mcp.tools.call("export_tickets", {}); expect(result).toBeSuccessful(); expect(progress.all.map((p) => p.progress)).toEqual([1, 2, 3, 4, 5]); expect(progress.all.every((p) => p.total === 5)).toBe(true); }); type LogMessage = { level: string; logger?: string; data: { message?: string } }; test("warns about the ticket with no title", async ({ mcp }) => { const notifications = mcp.notifications.collect(); await mcp.tools.call("export_tickets", {}); const warnings = notifications.received .filter((n) => n.method === "notifications/message") .map((n) => n.params as LogMessage) .filter((log) => log.level === "warning"); expect(warnings).toHaveLength(1); expect(warnings[0].data.message).toContain("T-2"); }); ``` ```ts export-tickets.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; import { exportRow, tickets } from "./store"; @Tool({ name: "export_tickets", description: "Export every support ticket to tickets.csv. Takes a few seconds.", inputSchema: {}, }) export class ExportTickets extends ToolContext { async execute() { await this.notify(`Exporting ${tickets.length} tickets to tickets.csv`); const rows: string[] = []; for (const [i, ticket] of tickets.entries()) { if (!ticket.title) await this.notify(`${ticket.id} has no title. It's exported with an empty one.`, "warning"); rows.push(await exportRow(ticket)); await this.progress(i + 1, tickets.length, `Exported ${ticket.id}`); } await this.notify({ file: "tickets.csv", bytes: rows.join("\n").length }, "debug"); return { file: "tickets.csv", rows: rows.length }; } } ``` ```ts store.ts export const tickets = [ { id: "T-1", title: "Cannot log in" }, { id: "T-2", title: "" }, { id: "T-3", title: "Login link expired" }, { id: "T-4", title: "Password reset email missing" }, { id: "T-5", title: "Invoice total is wrong" }, ]; /** Pretend each ticket takes a moment to export. */ export async function exportRow(ticket: { id: string; title: string }) { await new Promise((resolve) => setTimeout(resolve, 150)); return `${ticket.id},"${ticket.title}"`; } ``` > **Pitfall: Under frontmcp test, ask for notifications and wait for them** > > The Playground's test client asks for progress and for every log level with each call. The one `frontmcp test` uses records notifications too, but sends a `progressToken` only once `collectProgress()` is active, never sets a log level by itself, and can return from `tools.call()` before the notifications arrive. So in your project, call `collectProgress()` before the call, send `logging/setLevel` for log messages, and wait with `waitForComplete()` or `waitFor()`. [Checking notifications](https://frontmcp.dev/reference/testing#checking-notifications) has a test that passes in both. ## Recap - `this.progress(progress, total, message)` reports how far along a tool is. `progress` should go up with every call, and `total` and `message` are optional. - `this.notify(message, level)` sends a log message. A string becomes `data.message`, an object is sent as `data`, and `level` defaults to `"info"`. - Under 2026-07-28 a client asks per request, with a `progressToken` and `io.modelcontextprotocol/logLevel` in `_meta`. The response is then an event stream: notifications first, the result last. - A client that doesn't ask gets a plain JSON result. `progress()` and `notify()` return `false`, and the call works the same. - Notifications are for the client and the person watching. Put what the model needs in the result. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Report progress while importing `import_tickets` copies tickets from the old help desk, one at a time, and says nothing until it's done. Report progress after each ticket, with the total number of tickets. Initial call configuration: ```js { tool: "import_tickets", arguments: { tickets: [{ id: "OLD-1", title: "Printer on fire" }, { id: "OLD-2", title: "Cannot log in" }, { id: "OLD-3", title: "Refund request" }] } } ``` ```ts import-tickets.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const imported: { id: string; title: string }[] = []; @Tool({ name: "import_tickets", description: "Import tickets from the old help desk. Takes about a second per 5 tickets.", inputSchema: { tickets: z.array(z.object({ id: z.string(), title: z.string() })).min(1).max(20), }, }) export class ImportTickets extends ToolContext { async execute({ tickets }: { tickets: { id: string; title: string }[] }) { for (const ticket of tickets) { await new Promise((resolve) => setTimeout(resolve, 100)); imported.push(ticket); } return { imported: tickets.length }; } } ``` **Solution file (alternative to the starter):** ```ts import-tickets.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; const imported: { id: string; title: string }[] = []; @Tool({ name: "import_tickets", description: "Import tickets from the old help desk. Takes about a second per 5 tickets.", inputSchema: { tickets: z.array(z.object({ id: z.string(), title: z.string() })).min(1).max(20), }, }) export class ImportTickets extends ToolContext { async execute({ tickets }: { tickets: { id: string; title: string }[] }) { for (const [i, ticket] of tickets.entries()) { await new Promise((resolve) => setTimeout(resolve, 100)); imported.push(ticket); await this.progress(i + 1, tickets.length, `Imported ${ticket.id}`); } return { imported: tickets.length }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts import-tickets.test.ts hidden import { test, expect } from "@frontmcp/testing"; const three = [ { id: "OLD-1", title: "Printer on fire" }, { id: "OLD-2", title: "Cannot log in" }, { id: "OLD-3", title: "Refund request" }, ]; test("there's a progress update for each ticket", async ({ mcp }) => { const progress = mcp.notifications.collectProgress(); await mcp.tools.call("import_tickets", { tickets: three }); expect(progress.all.length).toBeGreaterThanOrEqual(3); }); test("every update has `total` set to the number of tickets", async ({ mcp }) => { const progress = mcp.notifications.collectProgress(); await mcp.tools.call("import_tickets", { tickets: three }); expect(progress.all.length).toBeGreaterThan(0); expect(progress.all.map((p) => p.total)).toEqual(progress.all.map(() => 3)); }); test("progress goes up and ends at the total", async ({ mcp }) => { const progress = mcp.notifications.collectProgress(); await mcp.tools.call("import_tickets", { tickets: three }); const values = progress.all.map((p) => p.progress); expect(values.length).toBeGreaterThan(0); expect(values.every((v, i) => i === 0 || v > values[i - 1])).toBe(true); expect(values.at(-1)).toBe(3); }); test("the import still returns its result", async ({ mcp }) => { const result = await mcp.tools.call("import_tickets", { tickets: three }); expect(result.json()).toEqual({ imported: 3 }); }); ``` **Hint:** `tickets.entries()` gives you the index as well as the ticket. The first ticket done is progress `1`, not `0`. **Solution:** After each ticket is imported, `this.progress(i + 1, tickets.length, ...)` reports how many are done out of how many. The last update has `progress` equal to `total`, which is how a client knows the bar is full. ### Challenge: Warn about skipped tickets Tickets with an empty title can't be imported, and this tool skips them without a word. Send a `"warning"` log message for each skipped ticket, naming its id. Don't warn about tickets that were imported. Initial call configuration: ```js { tool: "import_tickets", arguments: { tickets: [{ id: "OLD-1", title: "Printer on fire" }, { id: "OLD-2", title: "" }, { id: "OLD-3", title: "Refund request" }] } } ``` ```ts import-tickets.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const imported: { id: string; title: string }[] = []; @Tool({ name: "import_tickets", description: "Import tickets from the old help desk. Tickets without a title are skipped.", inputSchema: { tickets: z.array(z.object({ id: z.string(), title: z.string() })).min(1).max(20), }, }) export class ImportTickets extends ToolContext { async execute({ tickets }: { tickets: { id: string; title: string }[] }) { let count = 0; for (const ticket of tickets) { if (!ticket.title.trim()) continue; imported.push(ticket); count++; } return { imported: count }; } } ``` **Solution file (alternative to the starter):** ```ts import-tickets.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; const imported: { id: string; title: string }[] = []; @Tool({ name: "import_tickets", description: "Import tickets from the old help desk. Tickets without a title are skipped.", inputSchema: { tickets: z.array(z.object({ id: z.string(), title: z.string() })).min(1).max(20), }, }) export class ImportTickets extends ToolContext { async execute({ tickets }: { tickets: { id: string; title: string }[] }) { let count = 0; for (const ticket of tickets) { if (!ticket.title.trim()) { await this.notify(`Skipped ${ticket.id}: it has no title.`, "warning"); continue; } imported.push(ticket); count++; } return { imported: count }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts import-tickets.test.ts hidden import { test, expect } from "@frontmcp/testing"; const warningsIn = (notifications: { received: any[] }) => notifications.received.filter((n) => n.method === "notifications/message" && n.params.level === "warning"); test("a skipped ticket gets a warning that names it", async ({ mcp }) => { const notifications = mcp.notifications.collect(); await mcp.tools.call("import_tickets", { tickets: [{ id: "OLD-1", title: "Printer on fire" }, { id: "OLD-2", title: "" }], }); const warnings = warningsIn(notifications); expect(warnings).toHaveLength(1); expect(JSON.stringify(warnings[0].params.data)).toContain("OLD-2"); }); test("each skipped ticket gets its own warning", async ({ mcp }) => { const notifications = mcp.notifications.collect(); await mcp.tools.call("import_tickets", { tickets: [{ id: "OLD-3", title: " " }, { id: "OLD-4", title: "" }, { id: "OLD-5", title: "Refund request" }], }); const text = warningsIn(notifications).map((w) => JSON.stringify(w.params.data)); expect(text).toHaveLength(2); expect(text.some((t) => t.includes("OLD-3"))).toBe(true); expect(text.some((t) => t.includes("OLD-4"))).toBe(true); }); test("no warnings when nothing is skipped", async ({ mcp }) => { const notifications = mcp.notifications.collect(); await mcp.tools.call("import_tickets", { tickets: [{ id: "OLD-6", title: "Cannot log in" }] }); expect(warningsIn(notifications)).toHaveLength(0); }); test("the import still counts what it imported", async ({ mcp }) => { const result = await mcp.tools.call("import_tickets", { tickets: [{ id: "OLD-7", title: "Printer on fire" }, { id: "OLD-8", title: "" }], }); expect(result.json()).toEqual({ imported: 1 }); }); ``` **Hint:** `this.notify(message, "warning")`. Put the ticket's id in the message. **Solution:** Each skipped ticket sends one `notifications/message` at level `"warning"`, which clients can show differently from routine `"info"` messages. The result is unchanged: the warnings are for the person watching the import. ### Challenge: Tell the model what was skipped This version warns about skipped tickets, but the model only gets `{ imported: 1 }` and has no idea anything went wrong. Add a `skipped` list of ticket ids to the result, so the model can tell the user. It must work for clients that don't ask for notifications, too. Initial call configuration: ```js { tool: "import_tickets", arguments: { tickets: [{ id: "OLD-1", title: "Printer on fire" }, { id: "OLD-2", title: "" }] } } ``` ```ts import-tickets.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const imported: { id: string; title: string }[] = []; @Tool({ name: "import_tickets", description: "Import tickets from the old help desk. Tickets without a title are skipped.", inputSchema: { tickets: z.array(z.object({ id: z.string(), title: z.string() })).min(1).max(20), }, }) export class ImportTickets extends ToolContext { async execute({ tickets }: { tickets: { id: string; title: string }[] }) { let count = 0; for (const ticket of tickets) { if (!ticket.title.trim()) { await this.notify(`Skipped ${ticket.id}: it has no title.`, "warning"); continue; } imported.push(ticket); count++; } return { imported: count }; } } ``` **Solution file (alternative to the starter):** ```ts import-tickets.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; const imported: { id: string; title: string }[] = []; @Tool({ name: "import_tickets", description: "Import tickets from the old help desk. Tickets without a title are skipped and listed in `skipped`.", inputSchema: { tickets: z.array(z.object({ id: z.string(), title: z.string() })).min(1).max(20), }, }) export class ImportTickets extends ToolContext { async execute({ tickets }: { tickets: { id: string; title: string }[] }) { const skipped: string[] = []; for (const ticket of tickets) { if (!ticket.title.trim()) { skipped.push(ticket.id); await this.notify(`Skipped ${ticket.id}: it has no title.`, "warning"); continue; } imported.push(ticket); } return { imported: tickets.length - skipped.length, skipped }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts import-tickets.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("the result lists the skipped ids in `skipped`", async ({ mcp }) => { const result = await mcp.tools.call("import_tickets", { tickets: [{ id: "OLD-1", title: "Printer on fire" }, { id: "OLD-2", title: "" }, { id: "OLD-3", title: " " }], }); expect(result.json()).toEqual({ imported: 1, skipped: ["OLD-2", "OLD-3"] }); }); test("`skipped` is empty when nothing is skipped", async ({ mcp }) => { const result = await mcp.tools.call("import_tickets", { tickets: [{ id: "OLD-4", title: "Cannot log in" }] }); expect(result.json()).toEqual({ imported: 1, skipped: [] }); }); test("a client that doesn't ask for notifications still gets `skipped`", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "import_tickets", arguments: { tickets: [{ id: "OLD-5", title: "" }] } }, }); expect(response.result).toMatchObject({ structuredContent: { imported: 0, skipped: ["OLD-5"] } }); }); test("skipped tickets still get a warning", async ({ mcp }) => { const notifications = mcp.notifications.collect(); await mcp.tools.call("import_tickets", { tickets: [{ id: "OLD-6", title: "" }] }); expect(notifications.received.some((n) => n.params?.level === "warning")).toBe(true); }); ``` **Hint:** Collect the ids in an array as you skip them, and return it next to `imported`. **Solution:** The warning tells the person watching; `skipped` tells the model, which reads the result whether or not the client asked for notifications. Keep both: they're for different readers. --- # Structuring a Server > Where the code that isn't any one tool's job goes in a FrontMCP server. Providers for shared services and state, plugins for reusable behaviour, and hooks that run around every call. Source: https://frontmcp.dev/learn/structuring-a-server [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 [Grouping Capabilities into Apps](https://frontmcp.dev/learn/grouping-capabilities-into-apps) split a server into apps, and showed a provider shared between them. As a server grows, more of its code stops belonging to any one tool: the store every tool reads, the settings they all follow, the audit log every call should reach, the checks that should run before any tool does. This chapter is about where that code goes in a FrontMCP server, so that tools stay small and new ones get it all for free. **In this chapter** - [How to share services and state between tools with providers](https://frontmcp.dev/learn/sharing-state-with-providers) - [How to package behaviour for many tools as a plugin](https://frontmcp.dev/learn/extending-with-plugins) - [How to run code before and after the stages of every call with hooks](https://frontmcp.dev/learn/hooking-into-calls) ## Sharing state with providers A variable at the top of a file is shared by every request, and nothing can replace it in a test. A **provider** is a class FrontMCP creates for you and hands to any tool that asks with `this.get()`. Its scope decides how long it lives: `GLOBAL` for as long as the server runs, `CONTEXT` for one request. Call `close_ticket` twice, once for `T-1` and once for `T-3`: the store remembers the first call, and each receipt only has its own steps. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts help-desk.app.ts import { App, Provider, ProviderScope, Tool, ToolContext, z } from "@frontmcp/sdk"; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { open = new Set(["T-1", "T-3"]); } @Provider({ name: "Receipt", scope: ProviderScope.CONTEXT }) export class Receipt { steps: string[] = []; } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const store = this.get(TicketStore); this.get(Receipt).steps.push(store.open.delete(id) ? `closed ${id}` : `${id} was already closed`); return { receipt: this.get(Receipt).steps, stillOpen: [...store.open] }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket], providers: [TicketStore, Receipt] }) export class HelpDeskApp {} ``` ### [Sharing State with Providers](https://frontmcp.dev/learn/sharing-state-with-providers) Learn why module-level state breaks under concurrent calls, what `GLOBAL` and `CONTEXT` mean per request, how to build a provider from configuration, and how to swap one for a fake in tests. ## Extending with plugins Some behaviour belongs around every tool rather than inside one: an audit log, a maintenance switch, a cache. A **plugin** (`@Plugin`) packages it, with its own providers, the tools it adds and the hooks it runs, and an app turns it on in `plugins`. Call `close_ticket`, then `audit_log`: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts help-desk.app.ts import { App, DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook, z } from "@frontmcp/sdk"; @Provider({ name: "AuditLog" }) export class AuditLog { entries: string[] = []; } @Tool({ name: "audit_log", description: "Show every tool call made so far", inputSchema: {} }) export class ShowAuditLog extends ToolContext { async execute() { return { entries: [...this.get(AuditLog).entries] }; } } @Plugin({ name: "audit", providers: [AuditLog], tools: [ShowAuditLog] }) export class AuditPlugin extends DynamicPlugin { @ToolHook.Did("execute") async record(ctx: FlowCtxOf<"tools:call-tool">) { this.get(AuditLog).entries.push(`${ctx.state.tool?.metadata.name} ${JSON.stringify(ctx.state.toolContext?.input)}`); } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket], plugins: [AuditPlugin] }) export class HelpDeskApp {} ``` ### [Extending with Plugins](https://frontmcp.dev/learn/extending-with-plugins) Learn what a plugin can add to an app, how it shares its providers, how to make it configurable, where to register it, and what the official plugins do. ## Hooking into calls Every `tools/call` runs through a flow of named stages: find the tool, validate the arguments, execute, format the result. A **hook** in a plugin runs just before (`Will`) or just after (`Did`) a stage, on every call. This one cleans up ticket ids before the schema checks them, so `" t-2 "` works: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: " t-2 " } } ``` ```ts help-desk.app.ts import { App, DynamicPlugin, FlowCtxOf, Plugin, Tool, ToolContext, ToolHook, z } from "@frontmcp/sdk"; @Plugin({ name: "ticket-ids" }) export class TicketIdsPlugin extends DynamicPlugin { @ToolHook.Will("validateInput") async normalizeTicketId(ctx: FlowCtxOf<"tools:call-tool">) { const args = ctx.state.input?.arguments; if (typeof args?.id === "string") args.id = args.id.trim().toUpperCase(); } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Invoice total is wrong", status: "closed" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [TicketIdsPlugin] }) export class HelpDeskApp {} ``` ### [Hooking into Calls](https://frontmcp.dev/learn/hooking-into-calls) Learn the stages of a tool call, what a hook can read and change at each, how to log calls, block them and rewrite their results, and the order hooks run in. ## What's next? Start the chapter with [Sharing State with Providers](https://frontmcp.dev/learn/sharing-state-with-providers). After it, [Securing a Server](https://frontmcp.dev/learn/securing-a-server) covers who may call your server and what each caller is allowed to do. --- # Sharing State with Providers > Why tools shouldn't keep shared state in module variables, and how @Provider shares services instead. Scopes per request, factories built from configuration, and swapping a provider in tests. Source: https://frontmcp.dev/learn/sharing-state-with-providers [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Tools rarely work alone. They read the same ticket store, call the same API client and follow the same settings. The quickest way to share something between tools is a variable at the top of a file, and that works right up until two calls run at the same time, or a test needs a different one. A **provider** is FrontMCP's place for shared things. You declare it with `@Provider`, register it where it belongs, and any tool that needs it asks for it with `this.get()`. FrontMCP decides when to create it and how long it lives. **You will learn** - Why a module-level variable is the wrong place for shared state - How `@Provider` and `this.get()` share one service between tools - What `GLOBAL` and `CONTEXT` scopes mean for each request - How to build a provider from configuration with a factory - How to swap a provider for a fake in tests, and why that decides where you register it ## State in a module variable `close_ticket` returns a receipt of what it did, so the model can tell the user. It collects the steps in an array that lives at the top of `receipt.ts`, and empties it at the start of each call: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1", notify: true } } ``` ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { receipt, sendEmail } from "./receipt"; @Tool({ name: "close_ticket", description: "Close a support ticket, and optionally email the customer. Returns a receipt of the steps taken.", inputSchema: { id: z.string().describe("Ticket id, like T-1"), notify: z.boolean().optional().describe("Email the customer that the ticket is closed"), }, }) export class CloseTicket extends ToolContext { async execute({ id, notify }: { id: string; notify?: boolean }) { receipt.length = 0; // start this call's receipt receipt.push(`closed ${id}`); if (notify) { await sendEmail(id); receipt.push(`emailed the customer about ${id}`); } return { id, receipt: [...receipt] }; } } ``` ```ts receipt.ts // 🚩 One array for the whole server, shared by every call export const receipt: string[] = []; export async function sendEmail(ticketId: string) { await new Promise((resolve) => setTimeout(resolve, 200)); // stands in for a mail server } ``` ```ts receipt.test.ts import { test, expect } from "@frontmcp/testing"; test("one call on its own gets the right receipt", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1", notify: true }); expect(result.json().receipt).toEqual(["closed T-1", "emailed the customer about T-1"]); }); test("two calls at once share one receipt", async ({ mcp }) => { const [first, second] = await Promise.all([ mcp.tools.call("close_ticket", { id: "T-1", notify: true }), mcp.tools.call("close_ticket", { id: "T-2" }), ]); expect(second.json().receipt).toEqual(["closed T-2"]); // 🚩 T-1's receipt lost its first step and gained one from T-2 expect(first.json().receipt).toEqual(["closed T-2", "emailed the customer about T-1"]); }); ``` One call at a time, the receipt is right. Open the **Tests** tab to see two calls at once. While the first call waits for the mail server, the second one empties the array and writes its own step, and the first call's receipt comes back with a step from a ticket it never touched. A server handles many requests at the same time, from many clients, so this isn't rare in production. A module variable belongs to the file, not to the call, and that causes more than this one bug: 1. **Every request shares it.** Anything that should belong to one call, like this receipt, leaks between calls. 2. **Nothing can replace it.** A test can't give the tool a different store or a fake mail server, because the tool imports the real one directly. 3. **It's built when the file is imported**, before your configuration is read or a database is connected. 4. **It's invisible.** Nothing in the app's declaration says `close_ticket` depends on it. Providers fix all four, and the rest of this lesson shows how. For a value one caller should get back on their next call, like a preference, [Remembering Across Calls](https://frontmcp.dev/learn/remembering-across-calls) uses the Remember plugin. ## Sharing a service with `@Provider` A provider is a class that FrontMCP creates for you. Using one takes three steps: decorate the class with `@Provider`, list it in the app's `providers`, and get it inside `execute()` with `this.get()`: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Tool({ name: "close_ticket", description: "Close a support ticket by its id", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return this.get(TicketStore).close(id); } } @Tool({ name: "list_open_tickets", description: "List the support tickets that are still open", inputSchema: {}, annotations: { readOnlyHint: true }, }) export class ListOpenTickets extends ToolContext { async execute() { return { tickets: this.get(TicketStore).open().map((t) => t.id) }; } } ``` ```ts ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; type Ticket = { id: string; title: string; status: "open" | "closed" }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { 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" }, ]; open() { return this.tickets.filter((t) => t.status === "open"); } close(id: string) { const ticket = this.tickets.find((t) => t.id === id); if (ticket) ticket.status = "closed"; return { id, status: ticket?.status ?? "not found" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket, ListOpenTickets } from "./tools"; import { TicketStore } from "./ticket-store"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket, ListOpenTickets], providers: [TicketStore], }) export class HelpDeskApp {} ``` ```ts store.test.ts import { test, expect } from "@frontmcp/testing"; test("a ticket closed by one tool is gone from the other's list", async ({ mcp }) => { expect((await mcp.tools.call("list_open_tickets", {})).json()).toEqual({ tickets: ["T-1", "T-3"] }); await mcp.tools.call("close_ticket", { id: "T-1" }); expect((await mcp.tools.call("list_open_tickets", {})).json()).toEqual({ tickets: ["T-3"] }); }); ``` - **`@Provider({ name })`** marks the class. `name` is required and shows up in logs and error messages. - **`providers: [TicketStore]`** registers it. Decorating the class isn't enough: if neither the tool's app nor the server lists it, the call fails with `Provider "TicketStore" is not available`. - **`this.get(TicketStore)`** returns the instance. The class itself is the key you ask with, so `this.get()` knows the type it returns. Both tools get the same `TicketStore`, so a ticket closed by one is gone from the other's list. The tools also say what they depend on now: the app's `providers` lists it, and a test can put something else in its place. > **Note** > > `ProviderScope.GLOBAL` is the default, and means one instance for each place the provider is registered. List the same class in two apps and each app gets its own. To share one instance between apps, register it once on the server, as [Grouping Capabilities into Apps](https://frontmcp.dev/learn/grouping-capabilities-into-apps#sharing-a-provider-between-apps) shows. ## State for one request: `CONTEXT` The ticket store should outlive every call. The receipt should live for exactly one. That's what a provider's **scope** decides: | Scope | One instance per | Created | | --- | --- | --- | | `ProviderScope.GLOBAL` (the default) | place it's registered: an app, or the server | When the server starts | | `ProviderScope.CONTEXT` | request, under MCP 2026-07-28 | At the start of each tool call | Here is the receipt as a `CONTEXT` provider. Every `this.get(Receipt)` during one call returns the same receipt, and the next call gets a new one: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1", notify: true } } ``` ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { Receipt, sendEmail } from "./receipt"; @Tool({ name: "close_ticket", description: "Close a support ticket, and optionally email the customer. Returns a receipt of the steps taken.", inputSchema: { id: z.string().describe("Ticket id, like T-1"), notify: z.boolean().optional().describe("Email the customer that the ticket is closed"), }, }) export class CloseTicket extends ToolContext { async execute({ id, notify }: { id: string; notify?: boolean }) { const receipt = this.get(Receipt); receipt.add(`closed ${id}`); if (notify) { await sendEmail(id); receipt.add(`emailed the customer about ${id}`); } return { id, receipt: receipt.steps }; } } ``` ```ts receipt.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; // ✅ A new receipt for every request @Provider({ name: "Receipt", scope: ProviderScope.CONTEXT }) export class Receipt { steps: string[] = []; add(step: string) { this.steps.push(step); } } export async function sendEmail(ticketId: string) { await new Promise((resolve) => setTimeout(resolve, 200)); // stands in for a mail server } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; import { Receipt } from "./receipt"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket], providers: [Receipt] }) export class HelpDeskApp {} ``` ```ts receipt.test.ts import { test, expect } from "@frontmcp/testing"; // In Node the two calls run at once. Your browser has no AsyncLocalStorage, so there two calls // that overlap can see each other's providers: the Playground sends them one after the other. const inNode = typeof (globalThis as { WorkerGlobalScope?: unknown }).WorkerGlobalScope === "undefined"; test("two calls keep separate receipts", async ({ mcp }) => { const calls = [ () => mcp.tools.call("close_ticket", { id: "T-1", notify: true }), () => mcp.tools.call("close_ticket", { id: "T-2" }), ]; const [first, second] = inNode ? await Promise.all(calls.map((call) => call())) : [await calls[0](), await calls[1]()]; expect(first.json().receipt).toEqual(["closed T-1", "emailed the customer about T-1"]); expect(second.json().receipt).toEqual(["closed T-2"]); }); ``` The code no longer empties anything, because there's nothing left over to empty. The **Tests** tab runs the same two calls as before, and each receipt has only its own steps. (In Node they run at once. Your browser can't keep two overlapping calls apart, so the Playground runs them one after the other there.) "Per request" is how MCP 2026-07-28 works: every request stands alone, and there are no sessions to keep a provider alive between them. Clients on older protocol versions open a session, and for them a `CONTEXT` provider lasts for the whole session instead. If something must survive between calls, don't count on either: make it `GLOBAL`, or better, keep it in a database. > **Pitfall: A GLOBAL provider can't use a CONTEXT one** > > A `GLOBAL` provider lives longer than any request, so FrontMCP won't build one that depends on a `CONTEXT` provider: the server refuses to start with `Invalid dependency: DEFAULT-scoped provider … cannot depend on scoped provider …`. Pass the per-request value into the global provider's methods as an argument instead. ### When exactly is a provider built? A `GLOBAL` provider is built once, before the server answers its first request. A `CONTEXT` provider is built at the start of **every tool call in its app**, whether or not the tool asks for it. This example counts constructions. Call `ping` a few times: Initial call configuration: ```js { tool: "ping", arguments: {} } ``` ```ts help-desk.app.ts active import { App, Provider, ProviderScope, Tool, ToolContext } from "@frontmcp/sdk"; const built: string[] = []; @Provider({ name: "Shared", scope: ProviderScope.GLOBAL }) export class Shared { constructor() { built.push("Shared"); } } @Provider({ name: "PerRequest", scope: ProviderScope.CONTEXT }) export class PerRequest { constructor() { built.push("PerRequest"); } } @Tool({ name: "ping", description: "List the providers built so far", inputSchema: {} }) export class Ping extends ToolContext { async execute() { return { built: [...built] }; // never calls this.get() } } @App({ id: "help-desk", name: "Help Desk", tools: [Ping], providers: [Shared, PerRequest] }) export class HelpDeskApp {} ``` ```ts built.test.ts import { test, expect } from "@frontmcp/testing"; test("CONTEXT providers are built for every call, even unused", async ({ mcp }) => { await mcp.tools.call("ping", {}); await mcp.tools.call("ping", {}); const result = await mcp.tools.call("ping", {}); expect(result.json().built).toEqual(["Shared", "PerRequest", "PerRequest", "PerRequest"]); }); ``` So keep `CONTEXT` providers cheap to construct: hold per-request data in them, and leave connections and clients to `GLOBAL` ones. ## Building a provider from configuration Some things you want to share aren't classes you wrote: a settings object, a client from a library, something that has to be loaded first. For those, register a **factory**: an object with the key to provide, a `name`, the providers it needs, and a function that builds the value. The SLA hours below come from `Settings`, which stands in for environment variables, so they arrive as strings. The factory turns them into the numbers the tool needs: Initial call configuration: ```js { tool: "reply_due", arguments: { priority: "high" } } ``` ```ts help-desk.app.ts active import { App } from "@frontmcp/sdk"; import { DeskConfig, Settings } from "./config"; import { ReplyDue } from "./reply-due.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [ReplyDue], providers: [ Settings, { provide: DeskConfig, name: "DeskConfig", inject: () => [Settings] as const, useFactory: (settings) => ({ slaHours: { high: Number(settings.get("SLA_HIGH_HOURS")), normal: Number(settings.get("SLA_NORMAL_HOURS")), }, }), }, ], }) export class HelpDeskApp {} ``` ```ts config.ts import { Provider } from "@frontmcp/sdk"; // The key tools ask for. An abstract class gives this.get() a type. export abstract class DeskConfig { abstract slaHours: { high: number; normal: number }; } @Provider({ name: "Settings" }) export class Settings { private values: Record = { SLA_HIGH_HOURS: "4", SLA_NORMAL_HOURS: "24" }; get(key: string) { return this.values[key]; } } ``` ```ts reply-due.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { DeskConfig } from "./config"; @Tool({ name: "reply_due", description: "How many hours support has to reply to a ticket of this priority", inputSchema: { priority: z.enum(["high", "normal"]) }, annotations: { readOnlyHint: true }, }) export class ReplyDue extends ToolContext { async execute({ priority }: { priority: "high" | "normal" }) { return { priority, hours: this.get(DeskConfig).slaHours[priority] }; } } ``` ```ts config.test.ts import { test, expect } from "@frontmcp/testing"; test("hours come from the settings, as numbers", async ({ mcp }) => { expect((await mcp.tools.call("reply_due", { priority: "high" })).json()).toEqual({ priority: "high", hours: 4 }); expect((await mcp.tools.call("reply_due", { priority: "normal" })).json()).toEqual({ priority: "normal", hours: 24 }); }); ``` A factory has four parts: - **`provide`** is the key tools pass to `this.get()`. An abstract class works well: it exists at run time, so it can be a key, and `this.get(DeskConfig)` knows the value's type. A `Symbol` works too, with the type passed yourself: `this.get(SLA_HOURS)`. - **`name`** is required for factories. - **`inject`** is a function that returns the providers to pass in, in order. With `as const`, TypeScript infers `settings` in `useFactory`. - **`useFactory`** builds the value. It can be `async`, for something that has to be loaded first ([example](https://frontmcp.dev/reference/sdk/provider#providing-a-value-or-configuration)). A factory can take a `scope` too. It's `GLOBAL` unless you say otherwise, so this one runs once, when the server starts. > **Pitfall: Not every provider shape works in 1.8** > > FrontMCP 1.8 accepts three shapes in `providers`: a `@Provider` class, a factory with a `name`, and `{ provide, name, useValue }` where the value is an **instance of a `@Provider` class**, like the clocks below. It refuses to start for a plain object in `useValue` and for `useClass`, even though the TypeScript types allow both. It also expects `inject` to be a function: an array is ignored, and the factory is called with no arguments. The [`@Provider` troubleshooting](https://frontmcp.dev/reference/sdk/provider#troubleshooting) has the exact error messages. ## Swapping a provider in tests `snooze_ticket` hides a ticket for some hours and says when it comes back. It reads the time from a `Clock` provider rather than calling `new Date()` itself, and that's what makes it testable. The real server gives it the system clock: ```ts src/main.ts import { FrontMcp } from "@frontmcp/sdk"; import { TicketsApp } from "./tickets.app"; import { Clock, SystemClock } from "./clock"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [TicketsApp], providers: [{ provide: Clock, name: "Clock", useValue: new SystemClock() }], }) export default class HelpDeskServer {} ``` A test can't check the answer against a clock that keeps moving. So the tests start a second entry file, which lists the same app with a clock stopped at 09:00. This Playground runs that test server: Initial call configuration: ```js { tool: "snooze_ticket", arguments: { id: "T-1", hours: 2 } } ``` ```ts e2e/test-server.ts active import { FrontMcp } from "@frontmcp/sdk"; import { TicketsApp } from "../src/tickets.app"; import { Clock } from "../src/clock"; import { FixedClock } from "./fixed-clock"; @FrontMcp({ info: { name: "Help Desk (tests)", version: "1.0.0" }, apps: [TicketsApp], providers: [{ provide: Clock, name: "Clock", useValue: new FixedClock("2026-03-02T09:00:00.000Z") }], }) export default class TestServer {} ``` ```ts e2e/fixed-clock.ts import { Provider } from "@frontmcp/sdk"; import { Clock } from "../src/clock"; @Provider({ name: "FixedClock" }) export class FixedClock extends Clock { constructor(private readonly time: string) { super(); } now() { return new Date(this.time); } } ``` ```ts e2e/snooze.test.ts import { test, expect } from "@frontmcp/testing"; test("a 2-hour snooze from 09:00 ends at 11:00", async ({ mcp }) => { const result = await mcp.tools.call("snooze_ticket", { id: "T-1", hours: 2 }); expect(result.json()).toEqual({ id: "T-1", until: "2026-03-02T11:00:00.000Z" }); }); ``` ```ts src/clock.ts import { Provider } from "@frontmcp/sdk"; export abstract class Clock { abstract now(): Date; } @Provider({ name: "SystemClock" }) export class SystemClock extends Clock { now() { return new Date(); } } ``` ```ts src/snooze.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { Clock } from "./clock"; @Tool({ name: "snooze_ticket", description: "Hide a ticket from the queue for some hours. Returns when it comes back.", inputSchema: { id: z.string().describe("Ticket id, like T-1"), hours: z.number().int().min(1).max(72), }, }) export class SnoozeTicket extends ToolContext { async execute({ id, hours }: { id: string; hours: number }) { const until = new Date(this.get(Clock).now().getTime() + hours * 60 * 60 * 1000); return { id, until: until.toISOString() }; } } ``` ```ts src/tickets.app.ts import { App } from "@frontmcp/sdk"; import { SnoozeTicket } from "./snooze.tool"; // No Clock here: each entry file (main.ts, test-server.ts) provides its own. @App({ id: "tickets", name: "Tickets", tools: [SnoozeTicket] }) export class TicketsApp {} ``` The tool and the app are the same code in both servers. Only the entry file differs, and it chooses the clock. In a project, point `@frontmcp/testing` at the test entry file: ```ts e2e/snooze.e2e.spec.ts import { test, expect } from "@frontmcp/testing"; test.use({ server: "./e2e/test-server.ts" }); test("a 2-hour snooze from 09:00 ends at 11:00", async ({ mcp }) => { const result = await mcp.tools.call("snooze_ticket", { id: "T-1", hours: 2 }); expect(result.json()).toEqual({ id: "T-1", until: "2026-03-02T11:00:00.000Z" }); }); ``` The same approach works for anything a test shouldn't touch for real: a mail sender that records messages instead of sending them, or an API client that returns canned answers. ### An app's own provider comes first Notice that `TicketsApp` doesn't register `Clock`. When a tool calls `this.get()`, FrontMCP looks in the tool's own app first and only then in the server. So an app that registers a provider hides the server's provider with the same key. That's handy for overrides: here the whole server replies within 24 hours, and the VIP app within 2. Initial call configuration: ```js { tool: "vip_reply_due", arguments: {} } ``` ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { TicketsApp, VipApp } from "./apps"; import { deskConfig } from "./config"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [TicketsApp, VipApp], providers: [deskConfig(24)], }) export default class HelpDeskServer {} ``` ```ts apps.ts import { App, Tool, ToolContext } from "@frontmcp/sdk"; import { DeskConfig, deskConfig } from "./config"; @Tool({ name: "reply_due", description: "Hours support has to reply to a ticket", inputSchema: {} }) export class ReplyDue extends ToolContext { async execute() { return { hours: this.get(DeskConfig).replyHours }; } } @Tool({ name: "vip_reply_due", description: "Hours support has to reply to a VIP ticket", inputSchema: {} }) export class VipReplyDue extends ToolContext { async execute() { return { hours: this.get(DeskConfig).replyHours }; } } @App({ id: "tickets", name: "Tickets", tools: [ReplyDue] }) export class TicketsApp {} @App({ id: "vip", name: "VIP", tools: [VipReplyDue], providers: [deskConfig(2)] }) export class VipApp {} ``` ```ts config.ts export abstract class DeskConfig { abstract replyHours: number; } export function deskConfig(replyHours: number) { return { provide: DeskConfig, name: "DeskConfig", inject: () => [] as const, useFactory: () => ({ replyHours }) }; } ``` ```ts override.test.ts import { test, expect } from "@frontmcp/testing"; test("the VIP app's own DeskConfig wins over the server's", async ({ mcp }) => { expect((await mcp.tools.call("vip_reply_due", {})).json()).toEqual({ hours: 2 }); }); test("other apps still get the server's", async ({ mcp }) => { expect((await mcp.tools.call("reply_due", {})).json()).toEqual({ hours: 24 }); }); ``` The same rule works against you in tests. If `TicketsApp` registered `SystemClock` itself, the test server's `FixedClock` would never be seen, and the snooze test would fail. Register a provider that differs between environments on the **server**, in each entry file, and keep it out of the app. The last challenge below starts from exactly that mistake. ## Recap - A module-level variable is shared by every request and every test, can't be replaced, and doesn't show up as a dependency. Use a provider. - Decorate the class with `@Provider({ name })`, list it in `providers`, and get it with `this.get()`. The class is the key. - `GLOBAL` (the default) is one instance per place it's registered. `CONTEXT` is a new instance for each request under MCP 2026-07-28, built at the start of every tool call. - A factory (`provide`, `name`, `inject: () => [...]`, `useFactory`) builds a provider from other providers or configuration, and can be `async`. - `this.get()` looks in the tool's app first, then in the server. Register providers that differ between environments on the server, and swap them in a test entry file. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Give each call its own receipt The receipt is a provider now, but the second call's receipt still starts with the first call's steps. Make every request get a fresh receipt, without emptying it by hand. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts receipt.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; @Provider({ name: "Receipt", scope: ProviderScope.GLOBAL }) export class Receipt { steps: string[] = []; add(step: string) { this.steps.push(step); } } ``` **Solution file (alternative to the starter):** ```ts receipt.ts solution import { Provider, ProviderScope } from "@frontmcp/sdk"; @Provider({ name: "Receipt", scope: ProviderScope.CONTEXT }) export class Receipt { steps: string[] = []; add(step: string) { this.steps.push(step); } } ``` ```ts close-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { Receipt } from "./receipt"; @Tool({ name: "close_ticket", description: "Close a support ticket. Returns a receipt of the steps taken.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const receipt = this.get(Receipt); receipt.add(`checked ${id} exists`); receipt.add(`closed ${id}`); return { id, receipt: [...receipt.steps] }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; import { Receipt } from "./receipt"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket], providers: [Receipt] }) export class HelpDeskApp {} ``` **Hidden fixture/test file (runs with the example):** ```ts receipt.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("the first call's receipt has its two steps", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.json().receipt).toEqual(["checked T-1 exists", "closed T-1"]); }); test("the second call's receipt has only its own steps", async ({ mcp }) => { await mcp.tools.call("close_ticket", { id: "T-1" }); const result = await mcp.tools.call("close_ticket", { id: "T-2" }); expect(result.json().receipt).toEqual(["checked T-2 exists", "closed T-2"]); }); ``` **Hint:** The class is fine. What decides how long one instance lives? **Solution:** `ProviderScope.CONTEXT` makes FrontMCP build a new `Receipt` at the start of every request, so each call starts with an empty `steps` array. With `GLOBAL`, there was one receipt for the whole app, and it kept growing. ### Challenge: Build the SLA config with a factory `reply_due` asks for `DeskConfig`, but nothing provides it, so every call fails. Register a factory that builds `DeskConfig` from `Settings`. The settings are strings, and `hours` should be a number. Initial call configuration: ```js { tool: "reply_due", arguments: { priority: "high" } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { Settings } from "./config"; import { ReplyDue } from "./reply-due.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [ReplyDue], providers: [Settings], }) export class HelpDeskApp {} ``` **Solution file (alternative to the starter):** ```ts help-desk.app.ts solution import { App } from "@frontmcp/sdk"; import { DeskConfig, Settings } from "./config"; import { ReplyDue } from "./reply-due.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [ReplyDue], providers: [ Settings, { provide: DeskConfig, name: "DeskConfig", inject: () => [Settings] as const, useFactory: (settings) => ({ slaHours: { high: Number(settings.get("SLA_HIGH_HOURS")), normal: Number(settings.get("SLA_NORMAL_HOURS")), }, }), }, ], }) export class HelpDeskApp {} ``` ```ts config.ts import { Provider } from "@frontmcp/sdk"; export abstract class DeskConfig { abstract slaHours: { high: number; normal: number }; } @Provider({ name: "Settings" }) export class Settings { private values: Record = { SLA_HIGH_HOURS: "2", SLA_NORMAL_HOURS: "12" }; get(key: string) { return this.values[key]; } } ``` ```ts reply-due.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { DeskConfig } from "./config"; @Tool({ name: "reply_due", description: "How many hours support has to reply to a ticket of this priority", inputSchema: { priority: z.enum(["high", "normal"]) }, }) export class ReplyDue extends ToolContext { async execute({ priority }: { priority: "high" | "normal" }) { return { priority, hours: this.get(DeskConfig).slaHours[priority] }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts config.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`reply_due` works", async ({ mcp }) => { expect(await mcp.tools.call("reply_due", { priority: "high" })).toBeSuccessful(); }); test("high priority comes from SLA_HIGH_HOURS, as a number", async ({ mcp }) => { expect((await mcp.tools.call("reply_due", { priority: "high" })).json()).toEqual({ priority: "high", hours: 2 }); }); test("normal priority comes from SLA_NORMAL_HOURS, as a number", async ({ mcp }) => { expect((await mcp.tools.call("reply_due", { priority: "normal" })).json()).toEqual({ priority: "normal", hours: 12 }); }); ``` **Hint:** A factory needs `provide`, `name`, `inject` and `useFactory`. `inject` is a function that returns an array, not an array. **Solution:** The factory is registered under `DeskConfig`, the key the tool asks for. `inject: () => [Settings] as const` hands it the `Settings` instance, and `useFactory` converts the strings with `Number()` so the result matches the `DeskConfig` type. ### Challenge: Let the test clock through The test server provides a clock stopped at 09:00, but the snooze test still gets the real time. Find out why, and fix the app so the test server's clock is the one the tool sees. Initial call configuration: ```js { tool: "snooze_ticket", arguments: { id: "T-1", hours: 2 } } ``` ```ts src/tickets.app.ts import { App } from "@frontmcp/sdk"; import { SnoozeTicket } from "./snooze.tool"; import { Clock, SystemClock } from "./clock"; @App({ id: "tickets", name: "Tickets", tools: [SnoozeTicket], providers: [{ provide: Clock, name: "Clock", useValue: new SystemClock() }], }) export class TicketsApp {} ``` **Solution file (alternative to the starter):** ```ts src/tickets.app.ts solution import { App } from "@frontmcp/sdk"; import { SnoozeTicket } from "./snooze.tool"; @App({ id: "tickets", name: "Tickets", tools: [SnoozeTicket] }) export class TicketsApp {} ``` ```ts e2e/test-server.ts import { FrontMcp } from "@frontmcp/sdk"; import { TicketsApp } from "../src/tickets.app"; import { Clock } from "../src/clock"; import { FixedClock } from "./fixed-clock"; @FrontMcp({ info: { name: "Help Desk (tests)", version: "1.0.0" }, apps: [TicketsApp], providers: [{ provide: Clock, name: "Clock", useValue: new FixedClock("2026-03-02T09:00:00.000Z") }], }) export default class TestServer {} ``` ```ts e2e/fixed-clock.ts import { Provider } from "@frontmcp/sdk"; import { Clock } from "../src/clock"; @Provider({ name: "FixedClock" }) export class FixedClock extends Clock { constructor(private readonly time: string) { super(); } now() { return new Date(this.time); } } ``` ```ts src/clock.ts import { Provider } from "@frontmcp/sdk"; export abstract class Clock { abstract now(): Date; } @Provider({ name: "SystemClock" }) export class SystemClock extends Clock { now() { return new Date(); } } ``` ```ts src/snooze.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { Clock } from "./clock"; @Tool({ name: "snooze_ticket", description: "Hide a ticket from the queue for some hours. Returns when it comes back.", inputSchema: { id: z.string().describe("Ticket id, like T-1"), hours: z.number().int().min(1).max(72), }, }) export class SnoozeTicket extends ToolContext { async execute({ id, hours }: { id: string; hours: number }) { const until = new Date(this.get(Clock).now().getTime() + hours * 60 * 60 * 1000); return { id, until: until.toISOString() }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts e2e/snooze.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("a 2-hour snooze from the test clock's 09:00 ends at 11:00", async ({ mcp }) => { const result = await mcp.tools.call("snooze_ticket", { id: "T-1", hours: 2 }); expect(result.json()).toEqual({ id: "T-1", until: "2026-03-02T11:00:00.000Z" }); }); test("a 24-hour snooze ends the next morning", async ({ mcp }) => { const result = await mcp.tools.call("snooze_ticket", { id: "T-1", hours: 24 }); expect(result.json().until).toBe("2026-03-03T09:00:00.000Z"); }); ``` **Hint:** Where does `this.get(Clock)` look first: in the tool's app, or in the server? **Solution:** `this.get()` checks the tool's own app before the server, so the app's `SystemClock` hid the test server's `FixedClock`. With `Clock` out of the app, each entry file decides: `src/main.ts` registers `SystemClock`, and `e2e/test-server.ts` registers the fixed one. --- # Extending with Plugins > What a FrontMCP plugin is, how to write one that adds providers, tools and hooks to an app, how to configure and register it, and what the official plugins do. Source: https://frontmcp.dev/learn/extending-with-plugins [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Some behaviour belongs to no single tool. Every call should land in an audit log, a server under maintenance should refuse changes, slow lookups should be cached. You could write that code into every tool, and every new tool would have to remember it. A FrontMCP **plugin** (`@Plugin`) packages it once: the providers it needs, the tools it adds, and hooks that run around every call. An app turns it on with one line. **You will learn** - What a plugin can add to an app - How to write a plugin with a provider, a tool and a hook - How a plugin shares its providers with the app's tools - How to make a plugin configurable - Where to register a plugin, and what the official plugins do ## Code every tool has to remember The help desk keeps an audit log of every change, and an `audit_log` tool that shows it. Each tool that changes a ticket records itself: Initial call configuration: ```js { tool: "audit_log", arguments: {} } ``` ```ts tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { AuditLog } from "./audit-log"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { this.get(AuditLog).record("close_ticket", { id }); return { id, status: "closed" }; } } @Tool({ name: "reopen_ticket", description: "Reopen a closed support ticket", inputSchema: { id: z.string() } }) export class ReopenTicket extends ToolContext { async execute({ id }: { id: string }) { this.get(AuditLog).record("reopen_ticket", { id }); return { id, status: "open" }; } } @Tool({ name: "assign_ticket", description: "Assign a support ticket to an agent", inputSchema: { id: z.string(), agent: z.string() }, }) export class AssignTicket extends ToolContext { async execute({ id, agent }: { id: string; agent: string }) { // 🚩 Forgot to record return { id, agent }; } } @Tool({ name: "audit_log", description: "Show every change made to tickets", inputSchema: {} }) export class ShowAuditLog extends ToolContext { async execute() { return { entries: [...this.get(AuditLog).entries] }; } } ``` ```ts audit-log.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "AuditLog" }) export class AuditLog { entries: { tool: string; input: Record }[] = []; record(tool: string, input: Record) { this.entries.push({ tool, input }); } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { AssignTicket, CloseTicket, ReopenTicket, ShowAuditLog } from "./tools"; import { AuditLog } from "./audit-log"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket, ReopenTicket, AssignTicket, ShowAuditLog], providers: [AuditLog], }) export class HelpDeskApp {} ``` ```ts audit.test.ts import { test, expect } from "@frontmcp/testing"; test("assign_ticket never reaches the log", async ({ mcp }) => { await mcp.tools.call("close_ticket", { id: "T-1" }); await mcp.tools.call("assign_ticket", { id: "T-1", agent: "nour" }); const tools = (await mcp.tools.call("audit_log", {})).json().entries.map((e: { tool: string }) => e.tool); expect(tools).toEqual(["close_ticket"]); }); ``` `assign_ticket` forgot, so the **Tests** tab shows a log with a hole in it. Nothing warned about it, and nothing will warn about the next tool either. The tool name is typed by hand in each tool, so a copied tool can record the wrong name too. The audit log isn't part of any tool's job. It's something that should happen around all of them. ## Writing a plugin Here the audit log is a plugin. The tools don't mention it at all: Initial call configuration: ```js { tool: "audit_log", arguments: {} } ``` ```ts audit.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, Tool, ToolContext, ToolHook } from "@frontmcp/sdk"; import { AuditLog } from "./audit-log"; @Tool({ name: "audit_log", description: "Show every tool call made so far", inputSchema: {} }) export class ShowAuditLog extends ToolContext { async execute() { return { entries: [...this.get(AuditLog).entries] }; } } @Plugin({ name: "audit", description: "Records every tool call in an audit log", providers: [AuditLog], tools: [ShowAuditLog], }) export class AuditPlugin extends DynamicPlugin { // Runs after every tool's execute() succeeds @ToolHook.Did("execute") async record(ctx: FlowCtxOf<"tools:call-tool">) { const { tool, toolContext } = ctx.state; if (!tool || !toolContext) return; // input is typed unknown here, because the hook runs for every tool this.get(AuditLog).record(tool.metadata.name, toolContext.input as Record); } } ``` ```ts audit-log.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "AuditLog" }) export class AuditLog { entries: { tool: string; input: Record }[] = []; record(tool: string, input: Record) { this.entries.push({ tool, input }); } } ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "reopen_ticket", description: "Reopen a closed support ticket", inputSchema: { id: z.string() } }) export class ReopenTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "open" }; } } @Tool({ name: "assign_ticket", description: "Assign a support ticket to an agent", inputSchema: { id: z.string(), agent: z.string() }, }) export class AssignTicket extends ToolContext { async execute({ id, agent }: { id: string; agent: string }) { return { id, agent }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { AssignTicket, CloseTicket, ReopenTicket } from "./tools"; import { AuditPlugin } from "./audit.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket, ReopenTicket, AssignTicket], plugins: [AuditPlugin], }) export class HelpDeskApp {} ``` ```ts audit.test.ts import { test, expect } from "@frontmcp/testing"; test("the plugin adds audit_log to the app's tools", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("audit_log"); }); test("every call is recorded, with its input", async ({ mcp }) => { await mcp.tools.call("close_ticket", { id: "T-1" }); await mcp.tools.call("assign_ticket", { id: "T-1", agent: "nour" }); expect((await mcp.tools.call("audit_log", {})).json().entries).toEqual([ { tool: "close_ticket", input: { id: "T-1" } }, { tool: "assign_ticket", input: { id: "T-1", agent: "nour" } }, ]); }); ``` Open the **Capabilities** tab: the app lists four tools, and one of them came from the plugin. The **Tests** tab shows every call in the log, with the name and input FrontMCP had, not ones typed by hand. A plugin is a class with `@Plugin`, and its metadata says what it adds: | Option | What it adds | | --- | --- | | `name` | **Required.** Names the plugin in logs and errors. | | `providers` | Providers for the plugin's own use: its hooks and its tools. | | `exports` | Which of those providers the app's own tools may use too. [Below.](https://frontmcp.dev/learn/extending-with-plugins#sharing-a-plugins-providers-with-the-app) | | `tools`, `resources`, `prompts` | Capabilities added to the app, listed to clients like the app's own. | | `contextExtensions` | Properties added to every tool's `this`, like `this.auditLog`. [Below.](https://frontmcp.dev/learn/extending-with-plugins#sharing-a-plugins-providers-with-the-app) | | `plugins` | Other plugins this one brings along. | The class body holds **hooks**: methods marked with a decorator like `@ToolHook.Did("execute")`, which FrontMCP calls at a point in every tool call. `Did("execute")` runs after a tool's `execute()` returns. Its argument, `ctx`, describes the call in progress: `ctx.state.tool` is the tool being called, and `ctx.state.toolContext` is the running tool, with the validated `input`. The [next lesson](https://frontmcp.dev/learn/hooking-into-calls) covers every point you can hook into, and what a hook can change. Extending `DynamicPlugin` gives the class a typed `this.get()`, which reaches the plugin's providers. Its type parameter is for options, which [come later](https://frontmcp.dev/learn/extending-with-plugins#configuring-a-plugin). `object` means none. Finally, the app lists the plugin in `plugins`. That's the one line: remove it, and the tool, the provider and the hook are all gone. ### Hooks on tools and providers Hook methods also run on two other kinds of class. On a `@Tool` class, a hook runs only for that tool's calls. On a provider listed in an app's `providers`, it runs for every tool in that app. Here the app's `LegalHold` provider refuses any call about ticket T-7, and `close_ticket` refuses to close T-2 twice: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-7" } } ``` ```ts help-desk.app.ts active import { App, FlowCtxOf, Provider, PublicMcpError, Tool, ToolContext, ToolHook, z } from "@frontmcp/sdk"; type CallCtx = FlowCtxOf<"tools:call-tool">; const idOf = (ctx: CallCtx) => (ctx.state.toolContext?.input as { id?: string } | undefined)?.id; @Provider({ name: "LegalHold" }) export class LegalHold { // On an app's provider: runs for every tool in the app @ToolHook.Will("execute") async refuseHeldTickets(ctx: CallCtx) { if (idOf(ctx) === "T-7") throw new PublicMcpError("Ticket T-7 is on legal hold."); } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { // On a tool class: runs only for close_ticket @ToolHook.Will("execute") async refuseClosedTickets(ctx: CallCtx) { if (idOf(ctx) === "T-2") throw new PublicMcpError("Ticket T-2 is already closed."); } async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: id === "T-2" ? "closed" : "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket, GetTicket], providers: [LegalHold] }) export class HelpDeskApp {} ``` ```ts hooks.test.ts import { test, expect } from "@frontmcp/testing"; test("the provider's hook runs for every tool in the app", async ({ mcp }) => { expect(await mcp.tools.call("close_ticket", { id: "T-7" })).toHaveTextContent("legal hold"); expect(await mcp.tools.call("get_ticket", { id: "T-7" })).toHaveTextContent("legal hold"); }); test("the tool's own hook runs only for that tool", async ({ mcp }) => { expect(await mcp.tools.call("close_ticket", { id: "T-2" })).toHaveTextContent("already closed"); expect(await mcp.tools.call("get_ticket", { id: "T-2" })).toBeSuccessful(); }); ``` Both are fine for code that belongs to one tool or one app. A plugin is still the way to share hooks between apps and servers, bring along the providers and tools they need, and take options. ## Sharing a plugin's providers with the app A plugin's providers are private to it by default. When the app's own `ticket_history` tool asks for the plugin's `AuditLog`, it only finds it because the plugin lists it in **`exports`**: Initial call configuration: ```js { tool: "ticket_history", arguments: { id: "T-1" } } ``` ```ts audit.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; import { AuditLog } from "./audit-log"; @Plugin({ name: "audit", description: "Records every tool call in an audit log", providers: [AuditLog], exports: [AuditLog], // ✅ the app's tools can this.get(AuditLog) too }) export class AuditPlugin extends DynamicPlugin { @ToolHook.Did("execute") async record(ctx: FlowCtxOf<"tools:call-tool">) { const { tool, toolContext } = ctx.state; if (!tool || !toolContext) return; this.get(AuditLog).record(tool.metadata.name, toolContext.input as Record); } } ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { AuditLog } from "./audit-log"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "ticket_history", description: "List the tool calls that touched one ticket, oldest first", inputSchema: { id: z.string() }, annotations: { readOnlyHint: true }, }) export class TicketHistory extends ToolContext { async execute({ id }: { id: string }) { const entries = this.get(AuditLog).entries.filter((e) => e.input.id === id); return { id, history: entries.map((e) => e.tool) }; } } ``` ```ts audit-log.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "AuditLog" }) export class AuditLog { entries: { tool: string; input: Record }[] = []; record(tool: string, input: Record) { this.entries.push({ tool, input }); } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket, TicketHistory } from "./tools"; import { AuditPlugin } from "./audit.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket, TicketHistory], plugins: [AuditPlugin] }) export class HelpDeskApp {} ``` ```ts history.test.ts import { test, expect } from "@frontmcp/testing"; test("the app's tool reads the plugin's log", async ({ mcp }) => { await mcp.tools.call("close_ticket", { id: "T-1" }); await mcp.tools.call("close_ticket", { id: "T-2" }); expect((await mcp.tools.call("ticket_history", { id: "T-1" })).json()).toEqual({ id: "T-1", history: ["close_ticket"] }); }); ``` Take `exports` out and `ticket_history` fails with `Provider "AuditLog" is not available`, even though the plugin's hook keeps recording. Exporting is a choice: export what the app is meant to use, and keep the plugin's internals private. The second challenge below starts without it. ### Adding this.auditLog to every tool `contextExtensions` goes one step further: it adds a property to every tool's `this`, so tools write `this.auditLog` instead of `this.get(AuditLog)`. It's how the official plugins add helpers like `this.remember`. Each extension names the property and the key of an exported provider: Initial call configuration: ```js { tool: "ticket_history", arguments: { id: "T-1" } } ``` ```ts audit.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; export class AuditLog { entries: { tool: string; input: Record }[] = []; record(tool: string, input: Record) { this.entries.push({ tool, input }); } } export const AUDIT_LOG = Symbol.for("help-desk.audit-log"); const auditLogProvider = { provide: AUDIT_LOG, name: "AuditLog", inject: () => [] as const, useFactory: () => new AuditLog() }; // Tells TypeScript that tools have this.auditLog declare module "@frontmcp/sdk" { interface ExecutionContextBase { readonly auditLog: AuditLog; } } @Plugin({ name: "audit", providers: [auditLogProvider], exports: [auditLogProvider], contextExtensions: [{ property: "auditLog", token: AUDIT_LOG }], }) export class AuditPlugin extends DynamicPlugin { @ToolHook.Did("execute") async record(ctx: FlowCtxOf<"tools:call-tool">) { const { tool, toolContext } = ctx.state; if (!tool || !toolContext) return; this.get(AUDIT_LOG).record(tool.metadata.name, toolContext.input as Record); } } ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "ticket_history", description: "List the tool calls that touched one ticket, oldest first", inputSchema: { id: z.string() }, annotations: { readOnlyHint: true }, }) export class TicketHistory extends ToolContext { async execute({ id }: { id: string }) { const entries = this.auditLog.entries.filter((e) => e.input.id === id); return { id, history: entries.map((e) => e.tool) }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket, TicketHistory } from "./tools"; import { AuditPlugin } from "./audit.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket, TicketHistory], plugins: [AuditPlugin] }) export class HelpDeskApp {} ``` ```ts history.test.ts import { test, expect } from "@frontmcp/testing"; test("tools read the log through this.auditLog", async ({ mcp }) => { await mcp.tools.call("close_ticket", { id: "T-1" }); expect((await mcp.tools.call("ticket_history", { id: "T-1" })).json()).toEqual({ id: "T-1", history: ["close_ticket"] }); }); ``` FrontMCP installs an extension once per process, on the class every tool context extends, and the first plugin to claim a property name keeps it. That's why this example uses a `Symbol.for()` key rather than the `AuditLog` class: the Playground builds your classes afresh on every run but keeps one FrontMCP, and a key made with `Symbol.for()` is the same on every run. In a project, where your files load once, a class works as the key too. ## Configuring a plugin Logging every read fills the audit log with noise. A plugin takes options through its constructor, and `init()` passes them in. Here `skipReadOnly` leaves out tools marked `readOnlyHint`: Initial call configuration: ```js { tool: "audit_log", arguments: {} } ``` ```ts audit.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, Tool, ToolContext, ToolHook } from "@frontmcp/sdk"; import { AuditLog } from "./audit-log"; export type AuditOptions = { skipReadOnly?: boolean }; @Tool({ name: "audit_log", description: "Show every change made so far", inputSchema: {}, annotations: { readOnlyHint: true } }) export class ShowAuditLog extends ToolContext { async execute() { return { entries: [...this.get(AuditLog).entries] }; } } @Plugin({ name: "audit", providers: [AuditLog], tools: [ShowAuditLog] }) export class AuditPlugin extends DynamicPlugin { constructor(private readonly options: AuditOptions = {}) { super(); } @ToolHook.Did("execute") async record(ctx: FlowCtxOf<"tools:call-tool">) { const { tool, toolContext } = ctx.state; if (!tool || !toolContext) return; if (this.options.skipReadOnly && tool.metadata.annotations?.readOnlyHint) return; this.get(AuditLog).record(tool.metadata.name, toolContext.input as Record); } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket, GetTicket } from "./tools"; import { AuditPlugin } from "./audit.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, CloseTicket], plugins: [AuditPlugin.init({ skipReadOnly: true })], }) export class HelpDeskApp {} ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } ``` ```ts audit-log.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "AuditLog" }) export class AuditLog { entries: { tool: string; input: Record }[] = []; record(tool: string, input: Record) { this.entries.push({ tool, input }); } } ``` ```ts options.test.ts import { test, expect } from "@frontmcp/testing"; test("reads are left out, changes are recorded", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-1" }); await mcp.tools.call("close_ticket", { id: "T-1" }); await mcp.tools.call("audit_log", {}); expect((await mcp.tools.call("audit_log", {})).json().entries).toEqual([{ tool: "close_ticket", input: { id: "T-1" } }]); }); ``` - **`DynamicPlugin`** declares the options type, so `init()` only accepts `AuditOptions`. - **`AuditPlugin.init({ skipReadOnly: true })`** builds the plugin with those options. It goes in `plugins` where the class went. - **`plugins: [AuditPlugin]`** still works, and calls the constructor with no options, so give every option a default. The plugin now reads `annotations`, which the tools already declare for clients. A plugin can also read keys of its own from `@Tool({ ... })`: unknown keys are kept in `tool.metadata`. The official Cache plugin works that way, with `cache: true` on each tool it should cache. ## Registering a plugin on the server A plugin in an app's `plugins` belongs to that app: its tools join the app, its exported providers are visible to the app's tools only, and its hooks run for the app's tools only. To give every app the audit log, register the plugin once on the server instead: Initial call configuration: ```js { tool: "refund_invoice", arguments: { id: "INV-7" } } ``` ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { BillingApp, TicketsApp } from "./apps"; import { AuditPlugin } from "./audit.plugin"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [TicketsApp, BillingApp], plugins: [AuditPlugin], }) export default class HelpDeskServer {} ``` ```ts apps.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { AuditLog } from "./audit.plugin"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { id: z.string() } }) export class RefundInvoice extends ToolContext { async execute({ id }: { id: string }) { const earlier = this.get(AuditLog).entries.filter((e) => e.input.id === id).length; return { id, status: "refunded", earlierCalls: earlier }; } } @App({ id: "tickets", name: "Tickets", tools: [CloseTicket] }) export class TicketsApp {} @App({ id: "billing", name: "Billing", tools: [RefundInvoice] }) export class BillingApp {} ``` ```ts audit.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk"; @Provider({ name: "AuditLog" }) export class AuditLog { entries: { tool: string; input: Record }[] = []; record(tool: string, input: Record) { this.entries.push({ tool, input }); } } @Tool({ name: "audit_log", description: "Show every tool call made so far", inputSchema: {} }) export class ShowAuditLog extends ToolContext { async execute() { return { entries: [...this.get(AuditLog).entries] }; } } @Plugin({ name: "audit", providers: [AuditLog], exports: [AuditLog], tools: [ShowAuditLog] }) export class AuditPlugin extends DynamicPlugin { @ToolHook.Did("execute") async record(ctx: FlowCtxOf<"tools:call-tool">) { const { tool, toolContext } = ctx.state; if (!tool || !toolContext) return; this.get(AuditLog).record(tool.metadata.name, toolContext.input as Record); } } ``` ```ts server.test.ts import { test, expect } from "@frontmcp/testing"; test("calls to both apps are recorded", async ({ mcp }) => { await mcp.tools.call("close_ticket", { id: "T-1" }); await mcp.tools.call("refund_invoice", { id: "INV-7" }); const tools = (await mcp.tools.call("audit_log", {})).json().entries.map((e: { tool: string }) => e.tool); expect(tools).toEqual(["close_ticket", "refund_invoice"]); }); test("every app's tools can use the exported AuditLog", async ({ mcp }) => { expect(await mcp.tools.call("refund_invoice", { id: "INV-7" })).toBeSuccessful(); }); ``` `audit_log` is listed once, the billing app's `refund_invoice` can `this.get(AuditLog)`, and the hook records calls to both apps. Server-wide concerns like auditing, tracing and policy belong here. Compare the same plugin registered on the tickets app only. Its tool is listed, but the billing app can't reach its `AuditLog`, and its hook doesn't see billing calls: Initial call configuration: ```js { tool: "refund_invoice", arguments: { id: "INV-7" } } ``` ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { BillingApp, TicketsApp } from "./apps"; @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [TicketsApp, BillingApp], }) export default class HelpDeskServer {} ``` ```ts apps.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { AuditLog, AuditPlugin } from "./audit.plugin"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { id: z.string() } }) export class RefundInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "refunded", canSeeAuditLog: this.tryGet(AuditLog) !== undefined }; } } @App({ id: "tickets", name: "Tickets", tools: [CloseTicket], plugins: [AuditPlugin] }) export class TicketsApp {} @App({ id: "billing", name: "Billing", tools: [RefundInvoice] }) export class BillingApp {} ``` ```ts audit.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk"; @Provider({ name: "AuditLog" }) export class AuditLog { entries: { tool: string; input: Record }[] = []; record(tool: string, input: Record) { this.entries.push({ tool, input }); } } @Tool({ name: "audit_log", description: "Show every tool call made so far", inputSchema: {} }) export class ShowAuditLog extends ToolContext { async execute() { return { entries: [...this.get(AuditLog).entries] }; } } @Plugin({ name: "audit", providers: [AuditLog], exports: [AuditLog], tools: [ShowAuditLog] }) export class AuditPlugin extends DynamicPlugin { @ToolHook.Did("execute") async record(ctx: FlowCtxOf<"tools:call-tool">) { const { tool, toolContext } = ctx.state; if (!tool || !toolContext) return; this.get(AuditLog).record(tool.metadata.name, toolContext.input as Record); } } ``` ```ts scope.test.ts import { test, expect } from "@frontmcp/testing"; test("the billing app can't use the tickets app's AuditLog", async ({ mcp }) => { expect((await mcp.tools.call("refund_invoice", { id: "INV-7" })).json().canSeeAuditLog).toBe(false); }); test("the tickets app's hook records only tickets calls", async ({ mcp }) => { await mcp.tools.call("refund_invoice", { id: "INV-7" }); await mcp.tools.call("close_ticket", { id: "T-1" }); const tools = (await mcp.tools.call("audit_log", {})).json().entries.map((e: { tool: string }) => e.tool); expect(tools).toEqual(["close_ticket"]); }); ``` Register a plugin on the app whose tools it's about, and on the server only when every app needs it. ## Official plugins The FrontMCP team publishes plugins for common needs, each as its own npm package. They're installed and registered like the plugin above, with `plugins: [SomePlugin.init({ ... })]`: | Package | What it does | Lesson | | --- | --- | --- | | [`@frontmcp/plugin-cache`](https://frontmcp.dev/reference/plugins/cache) | Caches tool results, keyed by tool, input and caller, in memory or Redis. Tools opt in with `cache` in `@Tool`. | [Caching Results](https://frontmcp.dev/learn/caching-results) | | [`@frontmcp/plugin-remember`](https://frontmcp.dev/reference/plugins/remember) | Encrypted memory that tools read and write through `this.remember`, per session, user or tool. | [Remembering Across Calls](https://frontmcp.dev/learn/remembering-across-calls) | | [`@frontmcp/plugin-approval`](https://frontmcp.dev/reference/plugins/approval) | Requires approval before sensitive tools run. | [Asking for Approval](https://frontmcp.dev/learn/asking-for-approval) | | [`@frontmcp/plugin-feature-flags`](https://frontmcp.dev/reference/plugins/feature-flags) | Hides and blocks tools, resources and prompts behind feature flags from Split.io, LaunchDarkly, Unleash or your own source. | [Turning Features On and Off](https://frontmcp.dev/learn/turning-features-on-and-off) | | [`@frontmcp/plugin-codecall`](https://frontmcp.dev/reference/plugins/codecall) | Puts a large set of tools behind a few meta-tools, so the model searches for tools and runs short scripts that call them. | [Letting the Model Write Code with CodeCall](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall) | | [`@frontmcp/plugin-skilled-openapi`](https://frontmcp.dev/reference/plugins/skilled-openapi) | Serves a REST API described by OpenAPI as a small set of skills instead of one tool per endpoint. | [An API too big to list](https://frontmcp.dev/learn/wrapping-an-openapi-service#an-api-too-big-to-list) | `@frontmcp/plugins` bundles four of them, Cache, CodeCall, Remember and a dashboard; install the others on their own. The [Built-in Plugins and Adapters](https://frontmcp.dev/learn/built-in-plugins-and-adapters) chapter teaches them one lesson at a time, and [Plugins](https://frontmcp.dev/reference/plugins) covers each one, with examples that run on this site. For example, with the Cache plugin: ```ts src/tickets.app.ts import { App } from "@frontmcp/sdk"; import { CachePlugin } from "@frontmcp/plugin-cache"; import { GetTicket, SearchTickets } from "./tools"; @App({ id: "tickets", name: "Tickets", tools: [GetTicket, SearchTickets], plugins: [CachePlugin.init({ type: "memory" })], }) export class TicketsApp {} ``` [Each plugin's page](https://frontmcp.dev/reference/plugins) lists its options, and which tools it applies to. Before writing a plugin of your own, check whether one of these already does the job. ## Recap - A plugin (`@Plugin`) packages behaviour that belongs around every tool rather than in one: providers, tools, resources, prompts, hooks and context extensions. - Hooks are methods on the plugin class, like `@ToolHook.Did("execute")`. The same hooks also run on a tool class, for that tool, and on an app's provider, for that app. - A plugin's providers are private to it. List them in `exports` for the app's tools to use them, or add a `this.` property with `contextExtensions`. - Extend `DynamicPlugin` for a typed `this.get()` and options, and register a configured plugin with `MyPlugin.init({ ... })`. - Register a plugin on an app for that app's tools, or on `@FrontMcp` for every app's. - Every `@Plugin` option, and what each place you register a plugin affects, is in the [`@Plugin` reference](https://frontmcp.dev/reference/sdk/plugin). ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Turn the plugin on The audit plugin is written, but nothing is recorded and clients can't see `audit_log`. Register the plugin on the help desk app. ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { AssignTicket, CloseTicket } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket, AssignTicket] }) export class HelpDeskApp {} ``` **Solution file (alternative to the starter):** ```ts help-desk.app.ts solution import { App } from "@frontmcp/sdk"; import { AssignTicket, CloseTicket } from "./tools"; import { AuditPlugin } from "./audit.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket, AssignTicket], plugins: [AuditPlugin] }) export class HelpDeskApp {} ``` ```ts audit.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk"; @Provider({ name: "AuditLog" }) export class AuditLog { entries: { tool: string; input: Record }[] = []; record(tool: string, input: Record) { this.entries.push({ tool, input }); } } @Tool({ name: "audit_log", description: "Show every tool call made so far", inputSchema: {} }) export class ShowAuditLog extends ToolContext { async execute() { return { entries: [...this.get(AuditLog).entries] }; } } @Plugin({ name: "audit", providers: [AuditLog], tools: [ShowAuditLog] }) export class AuditPlugin extends DynamicPlugin { @ToolHook.Did("execute") async record(ctx: FlowCtxOf<"tools:call-tool">) { const { tool, toolContext } = ctx.state; if (!tool || !toolContext) return; this.get(AuditLog).record(tool.metadata.name, toolContext.input as Record); } } ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "assign_ticket", description: "Assign a support ticket to an agent", inputSchema: { id: z.string(), agent: z.string() }, }) export class AssignTicket extends ToolContext { async execute({ id, agent }: { id: string; agent: string }) { return { id, agent }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts audit.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`audit_log` is listed", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("audit_log"); }); test("the app's own tools are still listed", async ({ mcp }) => { const tools = await mcp.tools.list(); expect(tools).toContainTool("close_ticket"); expect(tools).toContainTool("assign_ticket"); }); test("calls are recorded in order", async ({ mcp }) => { await mcp.tools.call("assign_ticket", { id: "T-1", agent: "nour" }); await mcp.tools.call("close_ticket", { id: "T-1" }); const log = await mcp.tools.call("audit_log", {}); expect(log).toBeSuccessful(); expect(log.json().entries.map((e: { tool: string }) => e.tool)).toEqual(["assign_ticket", "close_ticket"]); }); ``` **Hint:** Plugins go in their own list on `@App`, next to `tools`. **Solution:** `plugins: [AuditPlugin]` turns on everything the plugin declares: the `audit_log` tool joins the app, the `AuditLog` provider is created, and the hook starts recording. Nothing in the tools had to change. ### Challenge: Share the audit log with the app `ticket_history` belongs to the app and reads the plugin's `AuditLog`, but every call fails with `Provider "AuditLog" is not available`. Let the app's tools use the audit log, without moving it out of the plugin. Initial call configuration: ```js { tool: "ticket_history", arguments: { id: "T-1" } } ``` ```ts audit.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, Provider, ToolHook } from "@frontmcp/sdk"; @Provider({ name: "AuditLog" }) export class AuditLog { entries: { tool: string; input: Record }[] = []; record(tool: string, input: Record) { this.entries.push({ tool, input }); } } @Plugin({ name: "audit", providers: [AuditLog] }) export class AuditPlugin extends DynamicPlugin { @ToolHook.Did("execute") async record(ctx: FlowCtxOf<"tools:call-tool">) { const { tool, toolContext } = ctx.state; if (!tool || !toolContext) return; this.get(AuditLog).record(tool.metadata.name, toolContext.input as Record); } } ``` **Solution file (alternative to the starter):** ```ts audit.plugin.ts solution import { DynamicPlugin, FlowCtxOf, Plugin, Provider, ToolHook } from "@frontmcp/sdk"; @Provider({ name: "AuditLog" }) export class AuditLog { entries: { tool: string; input: Record }[] = []; record(tool: string, input: Record) { this.entries.push({ tool, input }); } } @Plugin({ name: "audit", providers: [AuditLog], exports: [AuditLog] }) export class AuditPlugin extends DynamicPlugin { @ToolHook.Did("execute") async record(ctx: FlowCtxOf<"tools:call-tool">) { const { tool, toolContext } = ctx.state; if (!tool || !toolContext) return; this.get(AuditLog).record(tool.metadata.name, toolContext.input as Record); } } ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { AuditLog } from "./audit.plugin"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "ticket_history", description: "List the tool calls that touched one ticket, oldest first", inputSchema: { id: z.string() }, annotations: { readOnlyHint: true }, }) export class TicketHistory extends ToolContext { async execute({ id }: { id: string }) { const entries = this.get(AuditLog).entries.filter((e) => e.input.id === id); return { id, history: entries.map((e) => e.tool) }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket, TicketHistory } from "./tools"; import { AuditPlugin } from "./audit.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket, TicketHistory], plugins: [AuditPlugin] }) export class HelpDeskApp {} ``` **Hidden fixture/test file (runs with the example):** ```ts history.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`ticket_history` works", async ({ mcp }) => { expect(await mcp.tools.call("ticket_history", { id: "T-9" })).toBeSuccessful(); }); test("it sees what the plugin recorded", async ({ mcp }) => { await mcp.tools.call("close_ticket", { id: "T-1" }); await mcp.tools.call("close_ticket", { id: "T-2" }); const result = await mcp.tools.call("ticket_history", { id: "T-1" }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ id: "T-1", history: ["close_ticket"] }); }); ``` **Hint:** Registering `AuditLog` in the app as well would give the app a second, empty log. Look for the plugin option that shares a provider. **Solution:** `exports: [AuditLog]` makes the plugin's own instance visible to the app's tools, so `ticket_history` reads the same log the hook writes to. Adding `AuditLog` to the app's `providers` instead would have made the error go away, but the app would get its own empty copy. ### Challenge: Make the plugin skip reads Every `get_ticket` call ends up in the audit log. Give the plugin a `skipReadOnly` option that leaves out tools marked `readOnlyHint`, and turn it on for the app. ```ts audit.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk"; @Provider({ name: "AuditLog" }) export class AuditLog { entries: { tool: string; input: Record }[] = []; record(tool: string, input: Record) { this.entries.push({ tool, input }); } } @Tool({ name: "audit_log", description: "Show every change made so far", inputSchema: {}, annotations: { readOnlyHint: true } }) export class ShowAuditLog extends ToolContext { async execute() { return { entries: [...this.get(AuditLog).entries] }; } } @Plugin({ name: "audit", providers: [AuditLog], tools: [ShowAuditLog] }) export class AuditPlugin extends DynamicPlugin { @ToolHook.Did("execute") async record(ctx: FlowCtxOf<"tools:call-tool">) { const { tool, toolContext } = ctx.state; if (!tool || !toolContext) return; this.get(AuditLog).record(tool.metadata.name, toolContext.input as Record); } } ``` **Solution file (alternative to the starter):** ```ts audit.plugin.ts solution import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk"; @Provider({ name: "AuditLog" }) export class AuditLog { entries: { tool: string; input: Record }[] = []; record(tool: string, input: Record) { this.entries.push({ tool, input }); } } @Tool({ name: "audit_log", description: "Show every change made so far", inputSchema: {}, annotations: { readOnlyHint: true } }) export class ShowAuditLog extends ToolContext { async execute() { return { entries: [...this.get(AuditLog).entries] }; } } export type AuditOptions = { skipReadOnly?: boolean }; @Plugin({ name: "audit", providers: [AuditLog], tools: [ShowAuditLog] }) export class AuditPlugin extends DynamicPlugin { constructor(private readonly options: AuditOptions = {}) { super(); } @ToolHook.Did("execute") async record(ctx: FlowCtxOf<"tools:call-tool">) { const { tool, toolContext } = ctx.state; if (!tool || !toolContext) return; if (this.options.skipReadOnly && tool.metadata.annotations?.readOnlyHint) return; this.get(AuditLog).record(tool.metadata.name, toolContext.input as Record); } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket, GetTicket } from "./tools"; import { AuditPlugin } from "./audit.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, CloseTicket], plugins: [AuditPlugin] }) export class HelpDeskApp {} ``` **Solution file (alternative to the starter):** ```ts help-desk.app.ts solution import { App } from "@frontmcp/sdk"; import { CloseTicket, GetTicket } from "./tools"; import { AuditPlugin } from "./audit.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, CloseTicket], plugins: [AuditPlugin.init({ skipReadOnly: true })], }) export class HelpDeskApp {} ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts options.test.ts hidden import { test, expect } from "@frontmcp/testing"; const logged = async (mcp: any) => (await mcp.tools.call("audit_log", {})).json().entries.map((e: { tool: string }) => e.tool); test("`get_ticket` isn't recorded", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-1" }); expect(await logged(mcp)).not.toContain("get_ticket"); }); test("`close_ticket` still is", async ({ mcp }) => { await mcp.tools.call("close_ticket", { id: "T-1" }); expect(await logged(mcp)).toEqual(["close_ticket"]); }); ``` **Hint:** Take the options in the constructor, with a default so `plugins: [AuditPlugin]` still works, and change `DynamicPlugin` to your options type. Then register it with `AuditPlugin.init({ ... })`. **Solution:** `init({ skipReadOnly: true })` creates the plugin with those options, and the hook returns early for tools whose `annotations` say they're read-only. The log also stops recording `audit_log` itself, since it's marked read-only too. --- # Hooking into Calls > How FrontMCP runs a tool call as a flow of stages, and how plugin hooks run before and after them to log calls, normalize input, block calls and change results. Which order hooks run in, and what they can change. Source: https://frontmcp.dev/learn/hooking-into-calls [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Every `tools/call` that FrontMCP receives runs through a **flow**: a fixed list of named stages, from finding the tool to formatting its result. A **hook** is a method that runs just before or just after one of those stages, on every call. The [previous lesson](https://frontmcp.dev/learn/extending-with-plugins) used one to fill an audit log. This one shows the stages, what a hook can read and change at each of them, and the order hooks run in. **You will learn** - Which stages a tool call goes through - How `Will`, `Did` and `Around` hooks run around a stage, and what they can read - How to use hooks to log calls, normalize input, block calls and change results - Which order hooks run in ## The stages of a tool call This plugin traces three stages of every call: it records a line just before (`Will`) and just after (`Did`) each one. Call `get_ticket`, then `show_trace`: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts trace.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk"; type CallCtx = FlowCtxOf<"tools:call-tool">; @Provider({ name: "Trace" }) export class Trace { lines: string[] = []; } @Tool({ name: "show_trace", description: "Show the stages traced since the last call to show_trace", inputSchema: {} }) export class ShowTrace extends ToolContext { async execute() { return { trace: this.get(Trace).lines.splice(0) }; } } @Plugin({ name: "trace", providers: [Trace], tools: [ShowTrace] }) export class TracePlugin extends DynamicPlugin { private note(ctx: CallCtx, line: string) { if (ctx.state.tool?.metadata.name !== "show_trace") this.get(Trace).lines.push(line); } @ToolHook.Will("validateInput") async willValidate(ctx: CallCtx) { this.note(ctx, "will validateInput"); } @ToolHook.Did("validateInput") async didValidate(ctx: CallCtx) { this.note(ctx, "did validateInput"); } @ToolHook.Will("execute") async willExecute(ctx: CallCtx) { this.note(ctx, "will execute"); } @ToolHook.Did("execute") async didExecute(ctx: CallCtx) { this.note(ctx, "did execute"); } @ToolHook.Will("finalize") async willFinalize(ctx: CallCtx) { this.note(ctx, "will finalize"); } @ToolHook.Did("finalize") async didFinalize(ctx: CallCtx) { this.note(ctx, "did finalize"); } } ``` ```ts get-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket } from "./get-ticket.tool"; import { TracePlugin } from "./trace.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [TracePlugin] }) export class HelpDeskApp {} ``` ```ts trace.test.ts import { test, expect } from "@frontmcp/testing"; test("a call that works passes through every stage", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-1" }); expect((await mcp.tools.call("show_trace", {})).json().trace).toEqual([ "will validateInput", "did validateInput", "will execute", "did execute", "will finalize", "did finalize", ]); }); test("a call with bad input stops at validateInput, then finalizes", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "ticket one" }); expect((await mcp.tools.call("show_trace", {})).json().trace).toEqual([ "will validateInput", "will finalize", "did finalize", ]); }); ``` The **Tests** tab shows two calls. The first passes every stage, and each stage's `Will` hook runs before it and its `Did` hook after it. The second has an id that doesn't match the pattern, so `validateInput` fails: its `Did` hook doesn't run, `execute` is skipped entirely, and the flow jumps to its closing stages, which run whether the call worked or not. These are the stages of `tools:call-tool`, the flow behind `tools/call`, in order: | Group | Stages | Runs | | --- | --- | --- | | Before | `parseInput`, `ensureRemoteCapabilities`, `findTool`, `checkPublicAccess`, `checkToolAuthorization`, `checkEntryAuthorities`, `createTaskIfRequested`, `createToolCallContext`, `checkToolCredentials`, `acquireQuota`, `acquireSemaphore` | Until one fails | | Execute | `validateInput`, `execute`, `validateOutput` | Until one fails | | Finally | `releaseSemaphore`, `releaseQuota`, `applyUI`, `finalize` | Always | Most hooks need only a few of them: `validateInput` (the arguments are checked against `inputSchema`), `execute` (your tool's `execute()` runs), and `finalize` (the result is put together for the client). The hooks in this lesson are on plugins, so they run for every tool. A tool class can also declare hooks for its own calls, `static` ones included for the stages before its instance exists, like `parseInput`: see [Hook decorators](https://frontmcp.dev/reference/sdk/hooks#hooking-the-stages-before-an-entrys-instance). ## What a hook can read A hook method gets one argument, `ctx`, which describes the call in progress. Type it as `FlowCtxOf<"tools:call-tool">`. What's in `ctx.state` depends on how far the call has got: | Field | What it is | From | | --- | --- | --- | | `ctx.state.input` | The raw `tools/call` params: the tool `name`, the `arguments` as the client sent them, and `_meta` | `parseInput` | | `ctx.state.tool` | The tool being called. `metadata` holds everything from `@Tool({ ... })`, and `owner.id` is its app's `id` | `findTool` | | `ctx.state.toolContext` | This call's tool instance: `input` (checked against the schema once `validateInput` is done), `output` (once `execute` is done) and `get()` for providers | `createToolCallContext` | `@frontmcp/sdk` exports hook decorators for other flows too, with the same `Will` and `Did`: `ListToolsHook` for `tools/list`, `ResourceHook` for `resources/read`, `ListResourcesHook`, `ListResourceTemplatesHook` and `HttpHook`, among others. You'll use `ListToolsHook` below. ## Logging every call A log of every call with how long it took and whether it worked is a common first plugin. It needs code on both sides of `execute`, including when `execute` fails, and a `Did` hook doesn't run for a failed stage. That's what `@ToolHook.Around(stage)` is for. Its method gets a second argument, `next`, which runs the stage: code before `await next()` runs just before the stage, code after it just after, and if the stage fails, `next()` throws: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts call-log.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk"; type CallCtx = FlowCtxOf<"tools:call-tool">; type Entry = { tool: string; outcome: "ok" | "failed"; ms: number }; @Provider({ name: "CallLog" }) export class CallLog { entries: Entry[] = []; } @Tool({ name: "recent_calls", description: "Show recent tool calls, how long each took, and whether it worked", inputSchema: {}, annotations: { readOnlyHint: true }, }) export class RecentCalls extends ToolContext { async execute() { return { calls: [...this.get(CallLog).entries] }; } } @Plugin({ name: "call-log", providers: [CallLog], tools: [RecentCalls] }) export class CallLogPlugin extends DynamicPlugin { @ToolHook.Around("execute") async time(ctx: CallCtx, next: () => Promise) { const startedAt = Date.now(); const record = (outcome: Entry["outcome"]) => this.get(CallLog).entries.push({ tool: ctx.state.tool?.metadata.name ?? "?", outcome, ms: Date.now() - startedAt }); try { await next(); // runs execute() record("ok"); } catch (error) { record("failed"); throw error; // the client still gets the tool's own error } } } ``` ```ts get-ticket.tool.ts 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", 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}.`)); return ticket; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket } from "./get-ticket.tool"; import { CallLogPlugin } from "./call-log.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [CallLogPlugin] }) export class HelpDeskApp {} ``` ```ts call-log.test.ts import { test, expect } from "@frontmcp/testing"; test("calls are logged with their outcome and duration", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-1" }); // works await mcp.tools.call("get_ticket", { id: "T-9" }); // execute() fails await mcp.tools.call("get_ticket", { id: "ticket one" }); // rejected before execute() expect((await mcp.tools.call("recent_calls", {})).json().calls).toEqual([ { tool: "get_ticket", outcome: "ok", ms: expect.any(Number) }, { tool: "get_ticket", outcome: "failed", ms: expect.any(Number) }, ]); }); test("a failed call still gets the tool's error", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-9" }); expect(result.raw._meta?.code).toBe("PUBLIC_ERROR"); expect(result.text()).toBe("There's no ticket T-9."); }); ``` Call `get_ticket` with `T-9`, then `recent_calls`, to see a failure logged. The hook rethrows what `next()` threw, so the client gets the same error it would have without the plugin. Take out `throw error` and call `T-9` again: with the error swallowed, the call ends with no result at all, and FrontMCP answers with `FLOW_EXITED_WITHOUT_OUTPUT`. A call rejected before `execute()` never reaches the stage, so the hook doesn't run for it: the test's third call, with a malformed id, isn't logged. (`recent_calls` is a tool like any other, so it logs itself too: each call to it shows the one before.) ## Normalizing input Models are loose with ids. Asked about "ticket t-2", a model may well send `" t-2 "`, and the schema's pattern rejects it. One hook can clean up ticket ids for every tool, before FrontMCP checks them: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: " t-2 " } } ``` ```ts ticket-ids.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; @Plugin({ name: "ticket-ids", description: "Accepts ticket ids in any case, with stray spaces" }) export class TicketIdsPlugin extends DynamicPlugin { @ToolHook.Will("validateInput") async normalizeTicketId(ctx: FlowCtxOf<"tools:call-tool">) { const args = ctx.state.input?.arguments; if (typeof args?.id === "string") args.id = args.id.trim().toUpperCase(); } } ``` ```ts get-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Invoice total is wrong", status: "closed" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket } from "./get-ticket.tool"; import { TicketIdsPlugin } from "./ticket-ids.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [TicketIdsPlugin] }) export class HelpDeskApp {} ``` ```ts ids.test.ts import { test, expect } from "@frontmcp/testing"; test("\" t-2 \" is read as T-2", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: " t-2 " }); expect(result).toBeSuccessful(); expect(result.json().id).toBe("T-2"); }); test("the schema still rejects ids that aren't ticket ids", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "ticket two" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("INVALID_INPUT"); }); ``` The hook changes `ctx.state.input.arguments`, the arguments as the client sent them, in `Will("validateInput")`: just before they're checked. So the schema still has the last word, and `"ticket two"` is still rejected. By `Will("execute")` it's too late for this: validation is over, a malformed id has already been turned away, and anything you change there skips the schema altogether. For a single tool, you can do the same in its schema with `z.string().trim().toUpperCase()` ([Schemas Are Contracts](https://frontmcp.dev/learn/schemas-are-contracts#what-reaches-execute) covers it). The hook's advantage is reach: it cleans up every tool's `id`, including tools added later or by other plugins. ## Blocking a call During a data migration, the help desk has to stay read-only. A hook in `Will("execute")` runs right before the tool, and can stop it by throwing. The same plugin hides the tools that would change something from `tools/list`, so the model doesn't try them in the first place: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts read-only.plugin.ts active import { DynamicPlugin, FlowCtxOf, ListToolsHook, Plugin, PublicMcpError, ToolHook } from "@frontmcp/sdk"; type ReadOnlyOptions = { enabled: boolean }; @Plugin({ name: "read-only", description: "Turns off every tool that isn't marked read-only" }) export class ReadOnlyPlugin extends DynamicPlugin { constructor(private readonly options: ReadOnlyOptions = { enabled: false }) { super(); } // Refuse calls, even from a client that never listed the tools @ToolHook.Will("execute", { filter: (ctx) => !ctx.state.tool?.metadata.annotations?.readOnlyHint }) async refuse(ctx: FlowCtxOf<"tools:call-tool">) { if (!this.options.enabled) return; throw new PublicMcpError( `The help desk is read-only during maintenance, so ${ctx.state.tool?.metadata.name} is unavailable. Try again after 18:00 UTC.`, ); } // Leave them out of tools/list @ListToolsHook.Did("findTools") async hide(ctx: FlowCtxOf<"tools:list-tools">) { if (!this.options.enabled) return; const tools = ctx.state.tools ?? []; ctx.state.set("tools", tools.filter(({ tool }) => tool.metadata.annotations?.readOnlyHint)); } } ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket, GetTicket } from "./tools"; import { ReadOnlyPlugin } from "./read-only.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, CloseTicket], plugins: [ReadOnlyPlugin.init({ enabled: true })], }) export class HelpDeskApp {} ``` ```ts read-only.test.ts import { test, expect } from "@frontmcp/testing"; test("close_ticket is left out of tools/list", async ({ mcp }) => { expect(await mcp.tools.list()).not.toContainTool("close_ticket"); }); test("calling it anyway is refused with the hook's message", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result).toBeError(); expect(result).toHaveTextContent("read-only during maintenance"); expect(result.raw._meta?.code).toBe("PUBLIC_ERROR"); }); test("read-only tools still work", async ({ mcp }) => { expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeSuccessful(); }); ``` A few things to notice: - **The error reaches the model word for word.** A `PublicMcpError` thrown from a hook arrives as an `isError` result with its message and `_meta.code: "PUBLIC_ERROR"`. Say why the call was refused and what to do instead, because the model will pass it on. Any other error a hook throws fails the call too, but as a `SERVER_ERROR` whose message is hidden in production. - **`filter`** decides per call whether the hook runs at all. It gets the same `ctx`. It's a plain function in the decorator, so it can't see the plugin's options; those are checked inside. - **Hiding isn't blocking.** The `ListToolsHook` only changes what `tools/list` returns. A client that calls `close_ticket` by name still reaches it, which is why the `ToolHook` refuses the call as well. ## Changing the result `Did("execute")` runs after the tool returns and before FrontMCP turns its return value into `content` and `structuredContent`. Assign a new value to `ctx.state.toolContext.output`, and that's what the client gets. Here a plugin keeps customer email addresses out of every result, so they never reach the model: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts redact.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; function redactEmails(value: unknown): unknown { if (Array.isArray(value)) return value.map(redactEmails); if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value).map(([key, v]) => [key, key === "email" ? "[redacted]" : redactEmails(v)]), ); } return value; } @Plugin({ name: "redact", description: "Removes email addresses from tool results" }) export class RedactPlugin extends DynamicPlugin { @ToolHook.Did("execute") async redact(ctx: FlowCtxOf<"tools:call-tool">) { const { toolContext } = ctx.state; if (toolContext) toolContext.output = redactEmails(toolContext.output); } } ``` ```ts get-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", customer: { name: "Dana Levi", email: "dana@acme.com" } }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket } from "./get-ticket.tool"; import { RedactPlugin } from "./redact.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [RedactPlugin] }) export class HelpDeskApp {} ``` ```ts redact.test.ts import { test, expect } from "@frontmcp/testing"; test("the email is redacted in the structured result", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result.raw.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", customer: { name: "Dana Levi", email: "[redacted]" }, }); }); test("and in the text the model reads", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result).not.toHaveTextContent("dana@acme.com"); }); ``` Because the hook runs before the result is formatted, the text block and `structuredContent` both come from the redacted value. The tool doesn't know it happened, and new tools get the same treatment without any change. ### Answering without running the tool A `Will("execute")` hook can also answer the call itself, and the tool never runs. That's how a cache works. `ctx.respond()` takes a complete `tools/call` result, with `content` and, for objects, `structuredContent`: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts cache.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; type CallCtx = FlowCtxOf<"tools:call-tool">; const readOnly = (ctx: CallCtx) => ctx.state.tool?.metadata.annotations?.readOnlyHint === true; @Plugin({ name: "cache", description: "Answers repeated read-only calls from memory" }) export class CachePlugin extends DynamicPlugin { private results = new Map>(); private key(ctx: CallCtx) { return `${ctx.state.tool?.metadata.name}:${JSON.stringify(ctx.state.toolContext?.input)}`; } @ToolHook.Will("execute", { filter: readOnly }) async answerFromCache(ctx: CallCtx) { const cached = this.results.get(this.key(ctx)); if (cached) ctx.respond({ content: [{ type: "text", text: JSON.stringify(cached) }], structuredContent: cached }); } @ToolHook.Did("execute", { filter: readOnly }) async store(ctx: CallCtx) { this.results.set(this.key(ctx), ctx.state.toolContext?.output as Record); } } ``` ```ts get-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { Database } from "./database"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return this.get(Database).loadTicket(id); } } ``` ```ts database.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "Database" }) export class Database { private queries = 0; loadTicket(id: string) { this.queries += 1; return { id, title: "Cannot log in", query: this.queries }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket } from "./get-ticket.tool"; import { Database } from "./database"; import { CachePlugin } from "./cache.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], providers: [Database], plugins: [CachePlugin] }) export class HelpDeskApp {} ``` ```ts cache.test.ts import { test, expect } from "@frontmcp/testing"; test("asking for T-1 twice queries the database once", async ({ mcp }) => { const first = await mcp.tools.call("get_ticket", { id: "T-1" }); const second = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(first.json()).toEqual({ id: "T-1", title: "Cannot log in", query: 1 }); expect(second.json()).toEqual({ id: "T-1", title: "Cannot log in", query: 1 }); }); test("a different ticket is a different entry", async ({ mcp }) => { expect((await mcp.tools.call("get_ticket", { id: "T-2" })).json().query).toBe(2); }); ``` `ctx.respond()` ends the call on the spot: the tool, the `Did("execute")` hooks and the result formatting are all skipped, so the value you pass has to be a finished result. For real use, the official [Cache plugin](https://frontmcp.dev/reference/plugins/cache), `@frontmcp/plugin-cache`, adds expiry, Redis, and keys that include the caller, so one user's cached answer is never served to another. What `respond()` sends, from a hook and from a tool, and where it doesn't work, is in the [`this.respond()` reference](https://frontmcp.dev/reference/sdk/respond). ## The order hooks run in When several hooks share a stage, FrontMCP runs them by `priority`, a number you pass next to `filter`. **Lower numbers run first**, for `Will` and `Did` hooks alike, and the default is `0`. Hooks with the same priority run in the order their plugins are listed in `plugins`: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts plugins.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; import { Trace } from "./trace"; type CallCtx = FlowCtxOf<"tools:call-tool">; // Trace is a CONTEXT provider: one per call, reached through the call's toolContext const trace = (ctx: CallCtx, line: string) => ctx.state.toolContext?.get(Trace).lines.push(line); @Plugin({ name: "first" }) export class FirstPlugin extends DynamicPlugin { @ToolHook.Will("execute") async will(ctx: CallCtx) { trace(ctx, "first: will, priority 0"); } @ToolHook.Did("execute", { priority: 10 }) async did(ctx: CallCtx) { trace(ctx, "first: did, priority 10"); } } @Plugin({ name: "second" }) export class SecondPlugin extends DynamicPlugin { @ToolHook.Will("execute", { priority: -10 }) async will(ctx: CallCtx) { trace(ctx, "second: will, priority -10"); } @ToolHook.Did("execute") async did(ctx: CallCtx) { trace(ctx, "second: did, priority 0"); } // Wraps the stage: its first half runs after the Will hooks, its second half before the Did hooks @ToolHook.Around("execute") async around(ctx: CallCtx, next: () => Promise) { trace(ctx, "second: around, before next()"); await next(); trace(ctx, "second: around, after next()"); } } ``` ```ts trace.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; @Provider({ name: "Trace", scope: ProviderScope.CONTEXT }) export class Trace { lines: string[] = []; } ``` ```ts get-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { Trace } from "./trace"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { this.get(Trace).lines.push("get_ticket runs"); return { id, trace: this.get(Trace).lines }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket } from "./get-ticket.tool"; import { Trace } from "./trace"; import { FirstPlugin, SecondPlugin } from "./plugins"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], providers: [Trace], plugins: [FirstPlugin, SecondPlugin], }) export class HelpDeskApp {} ``` ```ts order.test.ts import { test, expect } from "@frontmcp/testing"; test("hooks run by priority, lowest first", async ({ mcp }) => { const trace = (await mcp.tools.call("get_ticket", { id: "T-1" })).json().trace; expect(trace).toEqual([ "second: will, priority -10", "first: will, priority 0", "second: around, before next()", "get_ticket runs", "second: around, after next()", "second: did, priority 0", "first: did, priority 10", ]); }); ``` The result shows the whole call: the `Will` hooks from lowest priority to highest, the first half of the `Around` hook, the tool, the second half, then the `Did` hooks, again from lowest to highest. (The `Did` lines show up in the result because they're added to the same array after the tool returns it and before it's formatted.) Give a hook a priority when its order matters: a check that must see the cleaned-up input needs a higher number than the hook that cleans it up. The last challenge below is about exactly that. ## Recap - A tool call runs through the `tools:call-tool` flow: stages before, `validateInput` → `execute` → `validateOutput`, then closing stages that always run. - `@ToolHook.Will(stage)` runs before a stage and `@ToolHook.Did(stage)` after it. `Did` hooks only run if the stage worked; `Did("finalize")` runs for every call. `@ToolHook.Around(stage)` wraps the stage: `await next()` runs it, and throws if it fails. - `ctx.state.input.arguments` is what the client sent, `ctx.state.tool` is the tool, and `ctx.state.toolContext` has the validated `input` and, after `execute`, the `output`. - Time and log calls with `Around("execute")`, normalize arguments in `Will("validateInput")`, refuse calls by throwing a `PublicMcpError` in `Will("execute")`, change results in `Did("execute")`, and answer from a cache with `ctx.respond()`. - Hooks on a stage run by `priority`, lowest first, for `Will` and `Did` alike. `filter` skips a hook for some calls. - Every hook decorator, flow and stage, and the full run order, are in the [hook decorators reference](https://frontmcp.dev/reference/sdk/hooks). ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Accept lowercase ticket ids This plugin is meant to accept ids like `" t-2 "`, but they're still rejected with `INVALID_INPUT`. Fix the hook so they reach the tool as `T-2`, while ids that aren't ticket ids are still rejected. Initial call configuration: ```js { tool: "get_ticket", arguments: { id: " t-2 " } } ``` ```ts ticket-ids.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; @Plugin({ name: "ticket-ids" }) export class TicketIdsPlugin extends DynamicPlugin { @ToolHook.Will("execute") async normalizeTicketId(ctx: FlowCtxOf<"tools:call-tool">) { const input = ctx.state.toolContext?.input as { id?: unknown } | undefined; if (typeof input?.id === "string") input.id = input.id.trim().toUpperCase(); } } ``` **Solution file (alternative to the starter):** ```ts ticket-ids.plugin.ts solution import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; @Plugin({ name: "ticket-ids" }) export class TicketIdsPlugin extends DynamicPlugin { @ToolHook.Will("validateInput") async normalizeTicketId(ctx: FlowCtxOf<"tools:call-tool">) { const args = ctx.state.input?.arguments; if (typeof args?.id === "string") args.id = args.id.trim().toUpperCase(); } } ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const ticketId = z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"); @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: ticketId } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Invoice total is wrong" }; } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: ticketId } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket, GetTicket } from "./tools"; import { TicketIdsPlugin } from "./ticket-ids.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, CloseTicket], plugins: [TicketIdsPlugin] }) export class HelpDeskApp {} ``` **Hidden fixture/test file (runs with the example):** ```ts ids.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`get_ticket` reads \" t-2 \" as T-2", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: " t-2 " }); expect(result).toBeSuccessful(); expect(result.json().id).toBe("T-2"); }); test("it works for every tool, like `close_ticket`", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "t-4" }); expect(result).toBeSuccessful(); expect(result.json().id).toBe("T-4"); }); test("\"ticket two\" is still rejected", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "ticket two" }); expect(result.raw._meta?.code).toBe("INVALID_INPUT"); }); ``` **Hint:** Look at the stage table. Which stage rejects `" t-2 "`, and does it run before or after `execute`? **Solution:** `validateInput` runs before `execute`, so a hook in `Will("execute")` never sees an id the schema rejected. In `Will("validateInput")` the arguments haven't been checked yet, so the hook edits `ctx.state.input.arguments`, the raw arguments, and the schema then checks the cleaned-up value. ### Challenge: Freeze changes over the weekend Add a hook to `FreezePlugin` that refuses every tool not marked `readOnlyHint`, with a `PublicMcpError` whose message mentions the freeze. The refused tool must not run. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts freeze.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, PublicMcpError, ToolHook } from "@frontmcp/sdk"; @Plugin({ name: "freeze", description: "Refuses changes during the weekend freeze" }) export class FreezePlugin extends DynamicPlugin {} ``` **Solution file (alternative to the starter):** ```ts freeze.plugin.ts solution import { DynamicPlugin, FlowCtxOf, Plugin, PublicMcpError, ToolHook } from "@frontmcp/sdk"; @Plugin({ name: "freeze", description: "Refuses changes during the weekend freeze" }) export class FreezePlugin extends DynamicPlugin { @ToolHook.Will("execute", { filter: (ctx) => !ctx.state.tool?.metadata.annotations?.readOnlyHint }) async refuse(ctx: FlowCtxOf<"tools:call-tool">) { throw new PublicMcpError( `Changes are paused for the weekend freeze, so ${ctx.state.tool?.metadata.name} is unavailable until Monday.`, ); } } ``` ```ts tools.ts import { Provider, Tool, ToolContext, z } from "@frontmcp/sdk"; @Provider({ name: "TicketStore" }) export class TicketStore { status: Record = { "T-1": "open", "T-2": "open" }; } @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: this.get(TicketStore).status[id] }; } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { this.get(TicketStore).status[id] = "closed"; return { id, status: "closed" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket, GetTicket, TicketStore } from "./tools"; import { FreezePlugin } from "./freeze.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, CloseTicket], providers: [TicketStore], plugins: [FreezePlugin], }) export class HelpDeskApp {} ``` **Hidden fixture/test file (runs with the example):** ```ts freeze.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`close_ticket` is refused with a message about the freeze", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result).toBeError(); expect(result.text()).toMatch(/freeze/i); expect(result.raw._meta?.code).toBe("PUBLIC_ERROR"); }); test("the ticket stays open, because the tool never ran", async ({ mcp }) => { await mcp.tools.call("close_ticket", { id: "T-2" }); expect((await mcp.tools.call("get_ticket", { id: "T-2" })).json().status).toBe("open"); }); test("`get_ticket` still works", async ({ mcp }) => { expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeSuccessful(); }); ``` **Hint:** `@ToolHook.Will("execute", { filter })` runs right before a tool, only for calls where `filter` returns `true`. Throw from the hook to stop the call. **Solution:** The hook runs before `execute()`, so throwing there stops the call before `close_ticket` can change anything, and the `filter` leaves read-only tools alone. Because the error is a `PublicMcpError` thrown from a hook, the model gets the message as written, with the code `PUBLIC_ERROR`. A good message says why the call was refused and when to try again. ### Challenge: Redact every email Customer emails must never reach the model, and `list_tickets` returns several customers. Add a hook to `RedactPlugin` that replaces the value of every `email` field in a tool's result, however deeply nested, with `"[redacted]"`. Initial call configuration: ```js { tool: "list_tickets", arguments: {} } ``` ```ts redact.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; @Plugin({ name: "redact", description: "Removes email addresses from tool results" }) export class RedactPlugin extends DynamicPlugin {} ``` **Solution file (alternative to the starter):** ```ts redact.plugin.ts solution import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; function redactEmails(value: unknown): unknown { if (Array.isArray(value)) return value.map(redactEmails); if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value).map(([key, v]) => [key, key === "email" ? "[redacted]" : redactEmails(v)]), ); } return value; } @Plugin({ name: "redact", description: "Removes email addresses from tool results" }) export class RedactPlugin extends DynamicPlugin { @ToolHook.Did("execute") async redact(ctx: FlowCtxOf<"tools:call-tool">) { const { toolContext } = ctx.state; if (toolContext) toolContext.output = redactEmails(toolContext.output); } } ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in", customer: { name: "Dana Levi", email: "dana@acme.com" } }, { id: "T-2", title: "Invoice total is wrong", customer: { name: "Omar Aziz", email: "omar@globex.com" } }, ]; @Tool({ name: "list_tickets", description: "List every support ticket with its customer", inputSchema: {} }) export class ListTickets extends ToolContext { async execute() { return { tickets }; } } @Tool({ name: "get_customer", description: "Get the customer of one ticket", inputSchema: { id: z.string() } }) export class GetCustomer extends ToolContext { async execute({ id }: { id: string }) { return tickets.find((t) => t.id === id)?.customer ?? { name: "unknown" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { GetCustomer, ListTickets } from "./tools"; import { RedactPlugin } from "./redact.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [ListTickets, GetCustomer], plugins: [RedactPlugin] }) export class HelpDeskApp {} ``` **Hidden fixture/test file (runs with the example):** ```ts redact.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("no email address appears in `list_tickets`", async ({ mcp }) => { const result = await mcp.tools.call("list_tickets", {}); expect(JSON.stringify(result.raw)).not.toContain("@"); }); test("each customer's `email` is \"[redacted]\"", async ({ mcp }) => { const result = await mcp.tools.call("list_tickets", {}); expect(result.json().tickets.map((t: any) => t.customer.email)).toEqual(["[redacted]", "[redacted]"]); }); test("`get_customer` is redacted too, and names are kept", async ({ mcp }) => { const result = await mcp.tools.call("get_customer", { id: "T-2" }); expect(result.json()).toEqual({ name: "Omar Aziz", email: "[redacted]" }); }); ``` **Hint:** `Did("execute")` sees the tool's return value as `ctx.state.toolContext.output`. Build a redacted copy and assign it back; a small recursive function handles arrays and nested objects. **Solution:** The hook replaces `output` after every tool returns and before FrontMCP formats it, so both the text block and `structuredContent` are built from the redacted copy. Returning a copy instead of editing in place also keeps the tool's own data intact: `tickets` still has the real addresses for the next call. ### Challenge: Clean up before checking the lock Ticket T-7 is locked for a legal hold, and `LockPlugin` refuses any call for it. But a model that sends `" t-7 "` gets through and closes it. Change the order of the two hooks so the lock check sees the cleaned-up id. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: " t-7 " } } ``` ```ts lock.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, PublicMcpError, ToolHook } from "@frontmcp/sdk"; type CallCtx = FlowCtxOf<"tools:call-tool">; const locked = ["T-7"]; @Plugin({ name: "lock", description: "Normalizes ticket ids and refuses calls for locked tickets" }) export class LockPlugin extends DynamicPlugin { @ToolHook.Will("validateInput") async checkLock(ctx: CallCtx) { const id = ctx.state.input?.arguments?.id; if (typeof id === "string" && locked.includes(id)) { throw new PublicMcpError(`Ticket ${id} is locked for a legal hold and can't be changed.`); } } @ToolHook.Will("validateInput", { priority: 10 }) async normalizeTicketId(ctx: CallCtx) { const args = ctx.state.input?.arguments; if (typeof args?.id === "string") args.id = args.id.trim().toUpperCase(); } } ``` **Solution file (alternative to the starter):** ```ts lock.plugin.ts solution import { DynamicPlugin, FlowCtxOf, Plugin, PublicMcpError, ToolHook } from "@frontmcp/sdk"; type CallCtx = FlowCtxOf<"tools:call-tool">; const locked = ["T-7"]; @Plugin({ name: "lock", description: "Normalizes ticket ids and refuses calls for locked tickets" }) export class LockPlugin extends DynamicPlugin { @ToolHook.Will("validateInput", { priority: 10 }) async checkLock(ctx: CallCtx) { const id = ctx.state.input?.arguments?.id; if (typeof id === "string" && locked.includes(id)) { throw new PublicMcpError(`Ticket ${id} is locked for a legal hold and can't be changed.`); } } @ToolHook.Will("validateInput") async normalizeTicketId(ctx: CallCtx) { const args = ctx.state.input?.arguments; if (typeof args?.id === "string") args.id = args.id.trim().toUpperCase(); } } ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket } from "./tools"; import { LockPlugin } from "./lock.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket], plugins: [LockPlugin] }) export class HelpDeskApp {} ``` **Hidden fixture/test file (runs with the example):** ```ts lock.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("\" t-7 \" is refused as locked", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: " t-7 " }); expect(result).toBeError(); expect(result).toHaveTextContent("T-7 is locked"); }); test("\"T-7\" is refused as locked", async ({ mcp }) => { expect(await mcp.tools.call("close_ticket", { id: "T-7" })).toHaveTextContent("locked"); }); test("other tickets still close, in any case", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: " t-1 " }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ id: "T-1", status: "closed" }); }); ``` **Hint:** Both hooks are on `Will("validateInput")`, so `priority` decides. Which one runs first now? **Solution:** Lower priorities run first. With `normalizeTicketId` at the default `0` and `checkLock` at `10`, the id is cleaned up before the lock check reads it, so `" t-7 "` is refused like `"T-7"`. Moving the methods around in the class wouldn't have helped: the order in the class only breaks ties between equal priorities. --- # Securing a Server > Who may connect to a FrontMCP server, what each caller may do, and how often and how long a tool may run. Authentication, authorization, rate limits and timeouts. Source: https://frontmcp.dev/learn/securing-a-server [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Every server so far in this course has let anyone call any tool, as often as they liked, for as long as the tool took. That's fine on your laptop. Once a server is reachable over a network, you need answers to three questions: who is calling, what may they do, and how much of it. This chapter answers each one with FrontMCP. **In this chapter** - [How clients prove who they are, and which auth mode to choose](https://frontmcp.dev/learn/authenticating-clients) - [How to decide who can call which tool, and refuse the rest](https://frontmcp.dev/learn/authorizing-calls) - [How to limit how often a tool runs, how many run at once, and for how long](https://frontmcp.dev/learn/limiting-calls) ## Authenticating clients A client proves who it is with a credential in the `Authorization` header of every request, and the server's `auth` mode decides which credentials count. With no `auth` at all, a server is public: every request gets in, as an anonymous caller. Initial call configuration: ```js { tool: "whoami", arguments: {} } ``` ```ts whoami.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "whoami", description: "Say who the server thinks is calling, and with which scopes.", inputSchema: {}, annotations: { readOnlyHint: true }, }) export class WhoAmI extends ToolContext { async execute() { return { caller: this.auth.user.sub, scopes: this.auth.scopes }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts whoami.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("each call is a new anonymous caller", async ({ mcp }) => { const first = (await mcp.tools.call("whoami", {})).json(); const second = (await mcp.tools.call("whoami", {})).json(); expect(first).toMatchObject({ caller: expect.stringMatching(/^anon:/), scopes: ["anonymous"] }); expect(second.caller).not.toBe(first.caller); }); ``` Call it again and the id changes: under MCP 2026-07-28, each anonymous request is a new caller. One line of configuration requires a shared key instead, and other modes accept tokens from your identity provider or sign users in themselves: ```ts auth: { mode: "static", tokens: [process.env.DESK_API_KEY!] }, ``` ### [Authenticating Clients](https://frontmcp.dev/learn/authenticating-clients) Learn where credentials travel, how the `public`, `static`, `transparent`, `local` and `remote` modes differ, what a client gets back when it isn't let in, and how to choose. ## Deciding who can call what Knowing who is calling isn't the same as deciding what they may do. A tool can read the caller from `this.auth` and refuse, with a message the model can pass on to the user. Here only callers with the `tickets:write` scope may close tickets, and an anonymous caller doesn't have it: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket. Only signed-in support agents can close tickets.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { if (!this.auth.hasScope("tickets:write")) { this.fail(new PublicMcpError("Only signed-in support agents can close tickets. Tell the user to sign in as an agent.", "FORBIDDEN")); } return { id, closed: true }; } } ``` A tool can also declare who may use it with `authorities`. FrontMCP then refuses other callers before `execute()` runs, and leaves the tool out of their `tools/list`. ### [Deciding Who Can Call What](https://frontmcp.dev/learn/authorizing-calls) Learn what `this.auth` holds in each auth mode, how to refuse a call well, how to declare roles and permissions with `authorities`, and why `visibility: "hidden"` doesn't protect a tool. ## Limiting and timing out calls A model can call a tool in a loop. `rateLimit`, `concurrency` and `timeout` on `@Tool` cap how often it runs, how many calls run at once, and how long one may take. This export runs at most twice an hour: it ran once when the example started, so press **Call** twice more. Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts main.ts import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "export_tickets", description: "Export every support ticket as CSV. At most 2 exports an hour.", inputSchema: {}, annotations: { readOnlyHint: true }, rateLimit: { maxRequests: 2, windowMs: 60 * 60_000 }, }) class ExportTickets extends ToolContext { async execute() { return { csv: "id,title,status\nT-1,Cannot log in,open\nT-2,Invoice total is wrong,closed" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` **Hidden fixture/test file (runs with the example):** ```ts limit.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("the third export in an hour is refused", async ({ mcp }) => { await mcp.tools.call("export_tickets", {}); await mcp.tools.call("export_tickets", {}); const third = await mcp.tools.call("export_tickets", {}); expect(third.raw._meta?.code).toBe("RATE_LIMIT_EXCEEDED"); expect(third.text()).toMatch(/Retry after \d+ seconds/); }); ``` The third call is refused with `RATE_LIMIT_EXCEEDED` and says when to retry. ### [Limiting and Timing Out Calls](https://frontmcp.dev/learn/limiting-calls) Learn what callers get back when a limit trips, whose calls a limit counts, how to stop a tool's runs overlapping, what happens when a call runs out of time, and how to choose limits for expensive and destructive tools. ## What's next? Start the chapter with [Authenticating Clients](https://frontmcp.dev/learn/authenticating-clients). After it, [Doing Work in the Background](https://frontmcp.dev/learn/background-work) covers work that's too slow, too flaky or too long for a single tool call. --- # Authenticating Clients > How MCP clients prove who they are to a FrontMCP server: the five auth modes, how each maps to the MCP authorization spec's OAuth 2.1 roles, and how to choose. Source: https://frontmcp.dev/learn/authenticating-clients [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Authentication answers one question: who is calling? An MCP client answers it with a credential in the `Authorization` header of every request, and FrontMCP checks that credential before any tool runs. Which credentials count, and what happens to a request without one, depend on the server's `auth` mode. FrontMCP has five: `public`, `static`, `transparent`, `local` and `remote`. **You will learn** - Who is calling when a request carries no credentials - Where credentials travel, and why they never go in tool arguments - How to protect a server with a shared key (`static`) - How to accept tokens from your identity provider (`transparent`) - What a client gets back when it isn't let in, and how to choose a mode ## A server anyone can call Every server so far in this course has had no `auth` option, which means `mode: "public"`: every request is let in. Here the mode is spelled out, and `whoami` reports who FrontMCP thinks is calling: Initial call configuration: ```js { tool: "whoami", arguments: {} } ``` ```ts whoami.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "whoami", description: "Say who the server thinks is calling, and with which scopes.", inputSchema: {}, annotations: { readOnlyHint: true }, }) export class WhoAmI extends ToolContext { async execute() { return { caller: this.auth.user.sub, scopes: this.auth.scopes }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { WhoAmI } from "./whoami.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [WhoAmI] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "public" }, }) export default class Server {} ``` ```ts whoami.test.ts import { test, expect } from "@frontmcp/testing"; test("every request is a new anonymous caller", async ({ mcp }) => { const first = (await mcp.tools.call("whoami", {})).json(); const second = (await mcp.tools.call("whoami", {})).json(); expect(first.caller).toMatch(/^anon:/); expect(second.caller).toMatch(/^anon:/); expect(second.caller).not.toBe(first.caller); expect(first.scopes).toEqual(["anonymous"]); }); ``` The caller is `anon:` followed by a random id, with one scope, `anonymous`. `this.auth` is where a tool finds out who is calling; the [next lesson](https://frontmcp.dev/learn/authorizing-calls) covers what's in it. Now open the **Tests** tab. Call `whoami` twice and you get two different ids. Under MCP 2026-07-28 there are no sessions: each request is authenticated on its own, and a request without credentials is a new stranger every time. Your server can't tell that two anonymous calls came from the same client. That matters for anything that counts per caller: the [rate limits](https://frontmcp.dev/learn/limiting-calls) later in this chapter count all anonymous callers together, as one. It matters for anything kept per caller too: by default, the [Cache plugin](https://frontmcp.dev/learn/caching-results#turning-the-cache-on) never answers an anonymous caller from its cache. Public mode is right on your laptop and for a server that only exposes public data. On a server that can reach your tickets, anyone who finds the URL can call every tool. ## Where credentials travel A client sends its credential in the HTTP `Authorization` header, on every request, including `tools/list`: ```text POST / HTTP/1.1 Host: desk.example.com Authorization: Bearer 9c1f4e7a… Content-Type: application/json MCP-Protocol-Version: 2026-07-28 Mcp-Method: tools/call Mcp-Name: close_ticket {"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"close_ticket","arguments":{"id":"T-1"}}} ``` The header is set by the client application, from what the user configured or signed in with. The model never sees it and can't change it. Tool arguments are the opposite: the model writes them, and it can write anything. So a tool should never learn who is calling from an argument like `agent` or `userId`. It reads the identity FrontMCP established from the header. The first challenge below fixes a tool that gets this wrong. > **Note** > > The Playground's client doesn't send an `Authorization` header, so a server that requires one refuses every request the Playground makes, including the `tools/list` it needs to start. The examples below that need credentials are plain code, with the responses FrontMCP sends. Run them in your own project to try them. ## Requiring a shared key: static mode The smallest step up from public is one secret that every legitimate client sends. Many MCP hosts have a setting for exactly this, often labelled "API key" or "access token", that adds a fixed `Authorization: Bearer …` header to every request. `mode: "static"` checks it: ```ts main.ts @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "static", tokens: [process.env.DESK_API_KEY!] }, }) export default class Server {} ``` A request without the header, or with a key that isn't in `tokens`, doesn't reach any tool: ```text HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="mcp" Content-Type: application/json; charset=utf-8 {"error":"Unauthorized"} ``` A wrong key gets the same status, with the reason in the challenge: ```text WWW-Authenticate: Bearer realm="mcp", error="invalid_token", error_description="The access token is invalid" ``` This is an HTTP response, not a JSON-RPC error or a tool result. The model never sees it; the client does, and the fix is in the client's settings. A request with the right key gets through, and its caller is `static:` followed by 12 hex characters derived from the key, with the scope `static`. The key itself never reaches your tools: it's in neither `this.auth` nor `this.context.authInfo`. | Option | Default | What it does | | --- | --- | --- | | `tokens` | required | The accepted keys. Compared in constant time. | | `header` | `"authorization"` | The request header that carries the key, like `"x-api-key"`. | | `scheme` | `"Bearer"` | The prefix before the key, matched without regard to case. `""` for a header that holds the bare key. | | `scopes` | `["static"]` | The scopes an accepted request gets. | | `realm` | `"mcp"` | The realm in the `WWW-Authenticate` challenge. | > **Pitfall: A shared key identifies a key, not a person** > > Everyone with the same key is the same caller: one `static:…` id, the same scopes. There's no way to tell users apart or to revoke one of them. To rotate a key, deploy with both the old and the new key in `tokens`; each gets its own id. Then remove the old one. When you need to know which person is calling, use tokens from an identity provider. ## Accepting tokens from your identity provider If your users already sign in with an identity provider, like Auth0, Okta or Keycloak, the client can get an access token (a signed JWT) from it and send that. `mode: "transparent"` checks those tokens and doesn't issue any of its own: ```ts main.ts @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "transparent", provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", requiredScopes: ["tickets:read"], }, }) export default class Server {} ``` For each request, FrontMCP checks that the token is signed by one of the provider's keys, that its issuer (`iss`) is `provider`, that it hasn't expired, and, if it names an audience (`aud`), that the audience is `expectedAudience`. The caller is then the token's `sub`, its scopes come from the token's `scope` claim (or `scp`, which some providers use instead), and the rest of its claims are available to your tools. A request that fails gets a 401 or 403 that says why: | Request | Status | `WWW-Authenticate` | | --- | --- | --- | | No token | `401` | `Bearer resource_metadata="https://desk.example.com/.well-known/oauth-protected-resource"` | | Not a JWT | `401` | The same, plus `error="invalid_token", error_description="Token is not a valid JWT"` | | Expired, wrong issuer, or a signature the provider's keys don't match | `401` | The same, plus `error="invalid_token", error_description="no_provider_verified (kid=…)"` | | No `sub`, `client_id` or `azp`: it doesn't say who is calling | `401` | The same, plus `error="invalid_token"` and a description naming the missing claims | | Issued for another audience | `401` | The same, plus `error="invalid_token"` and a description naming both audiences | | Missing a scope in `requiredScopes` | `403` | The same, plus `error="insufficient_scope"` and `scope="tickets:read"` | The URLs in these headers start with the server's address. FrontMCP's Node server builds it from `http://` and the request's `Host` header, so behind a proxy that terminates TLS they say `http://`; set `FRONTMCP_PUBLIC_URL=https://desk.example.com` to get the addresses above. [The server's public address](https://frontmcp.dev/reference/auth/modes#the-servers-public-address) has the details. `resource_metadata` points at a document FrontMCP serves, which tells an MCP client where to get a token. That's how a client that has never seen your server knows to start a sign-in instead of just failing. > **Note** > > FrontMCP finds the provider's signing keys by itself. It tries `/.well-known/jwks.json`, then the `jwks_uri` that the provider lists in its `/.well-known/oauth-authorization-server` or OpenID `/.well-known/openid-configuration` metadata. If your provider keeps its keys somewhere else, set `providerConfig.jwksUri` to its JWKS URL, or write the keys into your config as `providerConfig.jwks`. A server that finds no keys refuses every token with `no_provider_verified`. [Tokens and sessions](https://frontmcp.dev/reference/auth/tokens#where-the-keys-come-from) shows each request FrontMCP makes. ### Letting anonymous callers in, with less A token-only server shuts out everyone who hasn't signed in. `allowAnonymous: true` lets requests without a token in, as anonymous callers with the scopes in `anonymousScopes`. Requests that do carry a token are still checked, and a bad token is still refused. Because an anonymous request needs no header, this one runs in the Playground: Initial call configuration: ```js { tool: "whoami", arguments: {} } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { WhoAmI } from "./whoami.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [WhoAmI] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "transparent", provider: "https://auth.example.com", allowAnonymous: true, anonymousScopes: ["tickets:read"], }, }) export default class Server {} ``` ```ts whoami.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "whoami", description: "Say who the server thinks is calling, and with which scopes.", inputSchema: {}, annotations: { readOnlyHint: true }, }) export class WhoAmI extends ToolContext { async execute() { return { caller: this.auth.user.sub, scopes: this.auth.scopes }; } } ``` The caller is still `anon:…`, now with the scope `tickets:read`. A signed-in support agent would arrive with the scopes in their token, like `tickets:read tickets:write`. Letting each tool decide what those scopes allow is the subject of the [next lesson](https://frontmcp.dev/learn/authorizing-calls). > **Note** > > `anonymousScopes` works in `public` mode too, in place of the scope `anonymous`. And every mode accepts `publicAccess`, which limits what anonymous callers may use. With `publicAccess: { tools: ["search_tickets"] }`, every other tool is left out of an anonymous caller's `tools/list` and refused when called by name; `prompts` does the same for prompts, and anonymous calls are limited to `rateLimit` a minute per IP address, 60 by default. Signed-in callers aren't affected, and `publicAccess` doesn't let anyone in: a `static` server still refuses requests without a key. [Auth modes](https://frontmcp.dev/reference/auth/modes#limiting-what-anonymous-callers-can-use) shows both. ## Letting FrontMCP run the sign-in `transparent` assumes someone else issues the tokens. In the other two modes, FrontMCP is itself an OAuth 2.1 authorization server: MCP clients register with it, send the user to sign in, and get tokens FrontMCP signed. - **`local`**: FrontMCP shows its own sign-in page and issues tokens for the users who complete it. For a server with no identity provider behind it. - **`remote`**: FrontMCP sends the user to an upstream provider (`provider`, with your `clientId` and `clientSecret` there) to sign in, then issues its own token for that identity. For providers that MCP clients can't register with directly, or when you want FrontMCP's consent screen between the user and your tools. ```ts main.ts @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "remote", provider: "https://auth.example.com", clientId: process.env.DESK_CLIENT_ID!, clientSecret: process.env.DESK_CLIENT_SECRET!, }, }) export default class Server {} ``` To a client without a token, both answer like `transparent`: a `401` with `resource_metadata`, except that the document it points to names FrontMCP itself as the place to get a token. The tokens they issue are kept in memory by default (`tokenStorage: "memory"`), so they're lost on restart and not shared between instances; `tokenStorage` also takes `{ redis }` or `{ sqlite }`. Their sign-in pages, consent screens and storage have many options of their own, beyond this lesson: see [Local auth](https://frontmcp.dev/reference/auth/local), [Custom login UI](https://frontmcp.dev/reference/auth/login-ui), [Remote and proxied auth](https://frontmcp.dev/reference/auth/remote), and [Client ID metadata](https://frontmcp.dev/reference/auth/cimd) for clients that identify themselves by a URL. ## How the modes map to MCP authorization In the terms of the [MCP authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization) (revision 2026-07-28, the newest of the [revisions FrontMCP speaks](https://frontmcp.dev/reference/server/protocol-versions#supported-revisions)), a FrontMCP server in `transparent` mode is an OAuth 2.1 resource server only: your identity provider is the authorization server, and FrontMCP verifies the JWTs it issues. In `local` and `remote` mode, FrontMCP is both the resource server and the authorization server: it publishes authorization server metadata ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)), registers clients ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)) or accepts [Client ID Metadata Documents](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00), advertises PKCE ([RFC 7636](https://www.rfc-editor.org/rfc/rfc7636)) with `S256`, and issues the tokens it then checks. In all three, a request without a token gets a `401` that points to the server's Protected Resource Metadata ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)), unless `transparent` mode's `allowAnonymous` lets it in. `public` and `static` mode don't use OAuth. The specification [lets the authorization server be the MCP server itself or a separate service](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#roles). That choice is what separates `transparent` from `local` and `remote`: | Mode | Role in MCP authorization | What it serves | PKCE | How it checks tokens | Details | | --- | --- | --- | --- | --- | --- | | `public` | None: every request gets in | Protected Resource Metadata without `authorization_servers`. Authorization server metadata is `404` | None | No token needed. A bearer JWT must be one this server signed | [What the server advertises](https://frontmcp.dev/reference/auth/modes#what-the-server-advertises), [requests without credentials](https://frontmcp.dev/reference/auth/modes#what-a-request-without-credentials-gets) | | `static` | None: a shared key, not OAuth | The same as `public`. Its `401` has no `resource_metadata`, since a client can't get a key by itself | None | Compares the key with `tokens`, in constant time | [Static keys](https://frontmcp.dev/reference/auth/tokens#static-keys), [requests without credentials](https://frontmcp.dev/reference/auth/modes#what-a-request-without-credentials-gets) | | `transparent` | Resource server only. Your identity provider is the authorization server | Protected Resource Metadata, naming this server. `/.well-known/oauth-authorization-server` redirects to your provider's. Registration and CIMD are up to your provider | At your provider: FrontMCP serves no OAuth endpoints | Signature against your provider's keys, `iss`, `exp`, `nbf`, `requiredScopes`, and `aud` against `expectedAudience`. A token without `aud` passes unless `requireAudience` is `true` | [What the server advertises](https://frontmcp.dev/reference/auth/modes#what-the-server-advertises), [What's checked](https://frontmcp.dev/reference/auth/tokens#whats-checked), [CIMD caveats](https://frontmcp.dev/reference/auth/cimd#caveats) | | `local` | Resource server and authorization server. Users sign in on FrontMCP's page | Protected Resource Metadata, authorization server metadata, dynamic client registration (off in production unless `dcr.enabled`), and CIMD | Required, `S256` only: `plain` is refused | Its own HS256 JWTs: signature, `exp`, `iss`, and `aud`, which must be this server's URL or one of `expectedAudience`. An authorization request's `resource` ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707)) must be this server's URL | [Endpoints](https://frontmcp.dev/reference/auth/local#endpoints), [`/oauth/authorize` parameters](https://frontmcp.dev/reference/auth/local#oauthauthorize-parameters), [Tokens](https://frontmcp.dev/reference/auth/local#tokens) | | `remote` | Resource server and authorization server. Users sign in at your provider, through FrontMCP | The same as `local`, with registration outside production only, for loopback redirect URIs | `S256`, from the client. FrontMCP uses PKCE of its own with your provider | The same as `local`. FrontMCP never asks your provider to check its tokens | [What FrontMCP serves](https://frontmcp.dev/reference/auth/remote#what-frontmcp-serves), [How a sign-in works](https://frontmcp.dev/reference/auth/remote#how-a-sign-in-works) | The specification [requires an MCP server to accept only tokens issued for it](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#token-handling). Tokens from `local` and `remote` mode name the server's address in `aud`, and the server refuses a token whose `aud` is another address, unless `expectedAudience` lists it. In `transparent` mode, set `expectedAudience` to your server's public URL, and `requireAudience: true` so that a token with no `aud` is refused too: by default it's accepted. [Auth in production](https://frontmcp.dev/reference/auth/production#token-checks) lists the checks to tighten. MCP 2026-07-28 [deprecates dynamic client registration](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#dynamic-client-registration) in favour of Client ID Metadata Documents. In production, registration is off in `remote` mode, and in `local` mode unless you set `dcr.enabled`, so a client your server has never seen signs in with a metadata URL as its `client_id`: see [Client ID metadata (CIMD)](https://frontmcp.dev/reference/auth/cimd#cimd-registration-and-requireregisteredclients). The Playground's client can't sign in, so the tests play a client that has no token yet. They start a server in each mode, send the request a client sends first, and read the documents the `401` points to: Initial call configuration: ```js { tool: "whoami", arguments: {} } ``` ```ts discovery.test.ts active import { test, expect } from "@frontmcp/testing"; import { listTools, request } from "./client"; const transparent = { mode: "transparent", provider: "https://auth.example.com", expectedAudience: "https://desk.example.com" }; const local = { mode: "local" }; const remote = { mode: "remote", provider: "https://auth.example.com", clientId: "help-desk-mcp" }; const withoutOAuth = [{ mode: "public" }, { mode: "static", tokens: ["desk-key-1"] }]; test("a request without a token is refused, and pointed at the Protected Resource Metadata", async () => { for (const auth of [transparent, local]) { const response = await request(auth, "/", listTools); expect(response.status).toBe(401); expect(response.headers.get("www-authenticate")).toContain('resource_metadata="https://desk.example.com/.well-known/oauth-protected-resource"'); } }); test("transparent: a resource server that sends clients to the provider's metadata", async () => { const metadata = await (await request(transparent, "/.well-known/oauth-protected-resource")).json(); expect(metadata).toMatchObject({ resource: "https://desk.example.com", authorization_servers: ["https://desk.example.com"] }); const response = await request(transparent, "/.well-known/oauth-authorization-server"); expect(response.status).toBe(302); expect(response.headers.get("location")).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); }); test("local and remote: the authorization server too, with PKCE, registration and CIMD", async () => { for (const auth of [local, remote]) { const response = await request(auth, "/.well-known/oauth-authorization-server"); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ issuer: "https://desk.example.com", registration_endpoint: "https://desk.example.com/oauth/register", code_challenge_methods_supported: ["S256"], client_id_metadata_document_supported: true, }); } }); test("public and static: no authorization server", async () => { for (const auth of withoutOAuth) { const metadata = await (await request(auth, "/.well-known/oauth-protected-resource")).json(); expect(metadata).not.toHaveProperty("authorization_servers"); expect((await request(auth, "/.well-known/oauth-authorization-server")).status).toBe(404); } }); ``` ```ts client.ts import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { WhoAmI } from "./whoami.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [WhoAmI] }) class HelpDeskApp {} /** A tools/list request from an MCP 2026-07-28 client, with no Authorization header. */ export const listTools = { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }), }; /** Sends one request to a new server with this auth. */ export async function request(auth: object, path: string, init?: RequestInit) { const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth }); return handler(new Request(`https://desk.example.com${path}`, init)); } ``` ```ts whoami.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "whoami", description: "Say who the server thinks is calling, and with which scopes.", inputSchema: {}, annotations: { readOnlyHint: true }, }) export class WhoAmI extends ToolContext { async execute() { return { caller: this.auth.user.sub, scopes: this.auth.scopes }; } } ``` Under `createFetchHandler()`, the URLs in these documents start with the address the request was sent to, here `https://desk.example.com`. On FrontMCP's Node server, set `FRONTMCP_PUBLIC_URL` so they name your public address: see [The server's public address](https://frontmcp.dev/reference/auth/modes#the-servers-public-address). ## Choosing a mode | Mode | Who gets in | Who your tools see | Use it when | | --- | --- | --- | --- | | `public` | Everyone | `anon:…`, new on every request, scope `anonymous` | Local development, or only public data | | `static` | Clients that send one of your keys | `static:…`, one id per key | You configure every client yourself: an internal agent, a host's "API key" setting | | `transparent` | Clients with a valid token from your identity provider | The token's `sub`, scopes and claims | Your users already sign in with a provider that issues JWTs | | `local` | Users who sign in on FrontMCP's own page | The signed-in user | There's no identity provider, and each user should have an identity | | `remote` | Users who sign in with an upstream provider, through FrontMCP | The signed-in user | Your provider doesn't work with MCP clients directly, or you want FrontMCP's consent screen | Start with the simplest mode that tells you what your tools need to know. If a tool only needs to know "one of ours", a shared key is enough. If it needs to know which agent is closing a ticket, it needs tokens. ## Recap - Without `auth`, a server is `public`: every request gets in as `anon:…`, with a new id each time under 2026-07-28. - Credentials travel in the `Authorization` header of every request, set by the client. Never take identity from tool arguments. - `static` checks a shared key from `tokens`. Every caller with the same key is the same `static:…` caller. - `transparent` verifies JWTs from `provider`: signature, issuer, expiry and audience, plus `requiredScopes`. `allowAnonymous` lets requests without a token in with `anonymousScopes`. - A refused request gets `401` (or `403` for missing scopes) with a `WWW-Authenticate` header, before any tool runs. The model never sees it. - `local` and `remote` make FrontMCP an OAuth server that signs users in, on its own page or through an upstream provider. - In MCP authorization terms, `transparent` makes FrontMCP a resource server only; `local` and `remote` make it the authorization server too, with its own metadata, registration or CIMD, and PKCE. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Take the author from the credentials `add_note` records who wrote a note from an `author` argument, so the model can sign a note with any name it likes: the call below signs it "Nour". Remove the argument, and record the caller FrontMCP authenticated instead. In the Playground, that's an `anon:` id. Initial call configuration: ```js { tool: "add_note", arguments: { ticket: "T-1", text: "Called the customer back", author: "Nour" } } ``` ```ts add-note.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const notes: { ticket: string; text: string; author: string }[] = []; @Tool({ name: "add_note", description: "Add an internal note to a support ticket.", inputSchema: { ticket: z.string().describe("Ticket id, like T-1"), text: z.string().min(1), author: z.string().describe("Who is writing the note"), }, }) export class AddNote extends ToolContext { async execute({ ticket, text, author }: { ticket: string; text: string; author: string }) { const note = { ticket, text, author }; notes.push(note); return { note }; } } ``` **Solution file (alternative to the starter):** ```ts add-note.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; const notes: { ticket: string; text: string; author: string }[] = []; @Tool({ name: "add_note", description: "Add an internal note to a support ticket. The note is signed with the caller's identity.", inputSchema: { ticket: z.string().describe("Ticket id, like T-1"), text: z.string().min(1), }, }) export class AddNote extends ToolContext { async execute({ ticket, text }: { ticket: string; text: string }) { const note = { ticket, text, author: this.auth.user.sub }; notes.push(note); return { note }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts add-note.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`add_note` has no `author` argument", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "add_note"); expect(tool?.inputSchema.properties ?? {}).not.toHaveProperty("author"); }); test("the note is signed with the caller's identity", async ({ mcp }) => { const result = await mcp.tools.call("add_note", { ticket: "T-1", text: "Called the customer back" }); expect(result).toBeSuccessful(); expect(result.json().note.author).toMatch(/^anon:/); }); test("a name in the arguments doesn't change the author", async ({ mcp }) => { const result = await mcp.tools.call("add_note", { ticket: "T-1", text: "Refund approved", author: "Nour" }); expect(result).toBeSuccessful(); expect(result.json().note.author).not.toBe("Nour"); }); ``` **Hint:** `this.auth.user.sub` is the caller FrontMCP authenticated from the request's header. **Solution:** With `author` gone from `inputSchema`, the model has no way to name an author, and FrontMCP drops an `author` argument if one is sent anyway. The author now comes from `this.auth.user.sub`: `anon:…` here, `static:…` with a shared key, or the token's `sub` behind an identity provider. Only the credentials in the header decide it. ### Challenge: Let anonymous callers read The help desk wants signed-in agents to use tokens from `https://auth.example.com`, and everyone else to be able to search tickets without signing in. `search_tickets` already requires the `tickets:read` scope, and `close_ticket` requires `tickets:write`. Don't change the tools: change the server's `auth` so that requests without a token get in with `tickets:read`, and nothing more. Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { CloseTicket, SearchTickets } from "./tickets.tools"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, CloseTicket] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "public" }, }) export default class Server {} ``` **Solution file (alternative to the starter):** ```ts main.ts solution import { App, FrontMcp } from "@frontmcp/sdk"; import { CloseTicket, SearchTickets } from "./tickets.tools"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, CloseTicket] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "transparent", provider: "https://auth.example.com", allowAnonymous: true, anonymousScopes: ["tickets:read"], }, }) export default class Server {} ``` ```ts tickets.tools.ts 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: "open" }, { id: "T-3", title: "Login link expired", status: "open" }, ]; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title. Needs the tickets:read scope.", inputSchema: { query: z.string().min(1) }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { if (!this.auth.hasScope("tickets:read")) { this.fail(new PublicMcpError("Searching tickets needs the tickets:read scope.", "FORBIDDEN")); } const q = query.toLowerCase(); return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(q)) }; } } @Tool({ name: "close_ticket", description: "Close a support ticket. Needs the tickets:write scope, which signed-in agents have.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { if (!this.auth.hasScope("tickets:write")) { this.fail(new PublicMcpError("Closing tickets needs the tickets:write scope. Ask the user to sign in as an agent.", "FORBIDDEN")); } const ticket = tickets.find((t) => t.id === id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); ticket.status = "closed"; return { id, closed: true }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts auth.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("anyone can search without a token", async ({ mcp }) => { const result = await mcp.tools.call("search_tickets", { query: "log" }); expect(result).toBeSuccessful(); expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-1", "T-3"]); }); test("anonymous callers still can't close tickets", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("FORBIDDEN"); }); ``` **Hint:** Public mode can give anonymous callers `tickets:read` with `anonymousScopes`, but it refuses the agents' tokens: it only accepts tokens it issued itself. The mode that checks tokens from a provider can also let requests without a token in, with the scopes you choose. **Solution:** `mode: "transparent"` with `provider` checks agents' tokens against `https://auth.example.com`. `allowAnonymous: true` lets requests without a token in, and `anonymousScopes: ["tickets:read"]` gives them exactly the scope `search_tickets` needs and not the one `close_ticket` needs. Public mode with `anonymousScopes: ["tickets:read"]` passes these checks too, but it refuses every JWT it didn't sign, so the agents couldn't use their tokens. --- # Deciding Who Can Call What > How a FrontMCP tool finds out who is calling, refuses calls it shouldn't make with an error the model can act on, and declares who may use it with authorities. Source: https://frontmcp.dev/learn/authorizing-calls [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 [Authentication](https://frontmcp.dev/learn/authenticating-clients) tells your server who is calling. Authorization decides what they may do: anyone may search tickets, only support agents may close them, only admins may delete them. FrontMCP gives you two places to decide. A tool can check the caller inside `execute()`, and a tool can declare who may use it with `authorities`, so FrontMCP refuses the call, and hides the tool, before your code runs. **You will learn** - How to read who is calling inside `execute()` - How to refuse a call with an error the model can act on - How to declare who may call a tool with `authorities` - How to hide tools from callers who can't use them - Why `visibility: "hidden"` doesn't protect a tool ## A tool anyone can call This `close_ticket` does its job. But in the Playground you're an anonymous caller, and it closed the ticket anyway: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }], ]); @Tool({ name: "close_ticket", description: "Close a support ticket.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); ticket.status = "closed"; return { id, closed: true }; } } ``` Putting the server behind [a shared key or an identity provider](https://frontmcp.dev/learn/authenticating-clients) keeps strangers out, but everyone who gets in can still call every tool. A customer who signs in to check on their own ticket could close anyone's. The tool has to know who is calling, and decide. ## Reading who's calling Inside `execute()`, `this.auth` describes the caller FrontMCP authenticated for this request: Initial call configuration: ```js { tool: "whoami", arguments: {} } ``` ```ts whoami.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "whoami", description: "Say who the server thinks is calling.", inputSchema: {}, annotations: { readOnlyHint: true }, }) export class WhoAmI extends ToolContext { async execute() { const { user, isAnonymous, scopes, roles } = this.auth; return { caller: user.sub, anonymous: isAnonymous, scopes, roles }; } } ``` ```ts whoami.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { WhoAmI } from "./whoami.tool"; test("the Playground's caller is anonymous", async ({ mcp }) => { const me = (await mcp.tools.call("whoami", {})).json(); expect(me).toMatchObject({ caller: expect.stringMatching(/^anon:/), anonymous: true, scopes: ["anonymous"], roles: [] }); }); test("a signed-in agent has their id and roles", async () => { // create() runs the tool in-process, as the user you name, with the claims you give it. const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [WhoAmI] }); const result = await server.callTool("whoami", {}, { authContext: { user: { sub: "nour", roles: ["agent"] } } }); await server.dispose(); expect(result.structuredContent).toMatchObject({ caller: "nour", anonymous: false, roles: ["agent"] }); }); ``` What each field holds depends on the server's [auth mode](https://frontmcp.dev/learn/authenticating-clients#choosing-a-mode): | Field | `public` | `static` | `transparent`, with a token | | --- | --- | --- | --- | | `user.sub` | `anon:` and a new id per request | `static:` and 12 hex characters | The token's `sub` | | `isAnonymous` | `true` | `false` | `false` | | `scopes` | The mode's `anonymousScopes`, `["anonymous"]` by default | The mode's `scopes`, `["static"]` by default | The token's `scope` claim, split on spaces, or its `scp` claim | | `roles` | `[]` | `[]` | The token's `roles` claim | | `claims` | `{ sub, iss: "public", name: "Anonymous", scope }` | `{ sub, iss: "static", name: "Static token", scope }` | Every claim in the token, such as `name` or `roles` | `hasScope()`, `hasRole()` and `hasPermission()` check those lists for you. `claims` holds what the token said, and nothing else: FrontMCP verified the token's signature, but a `roles` claim means what your identity provider meant by it. If your provider keeps roles in another claim, the `claimsMapping` [further down](https://frontmcp.dev/learn/authorizing-calls#declaring-who-may-call-a-tool) tells `this.auth` where to look. `this.auth` is built from `this.context.authInfo`, the raw result of authentication: `clientId`, `scopes`, the token's claims in `user`, and `token`, the token itself. It's the same for every client, whatever MCP version it speaks. (Before 1.8.7, a client that kept a session, which means every version before 2026-07-28, saw `this.auth.scopes` empty and `hasScope()` always `false`, and a tool had to read `this.context.authInfo.scopes`.) ## Refusing a call the model can act on When the caller isn't allowed to do something, stop with `this.fail()` and a `PublicMcpError` whose message tells the model what happened and what to do instead. Support agents sign in with tokens that carry the `tickets:write` scope; nobody else has it: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }], ]); @Tool({ name: "close_ticket", description: "Close a support ticket. Only signed-in support agents can close tickets.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { if (!this.auth.hasScope("tickets:write")) { this.fail( new PublicMcpError( "Only signed-in support agents can close tickets. Tell the user to sign in as an agent. Don't retry this call until they have.", "FORBIDDEN", ), ); } const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); ticket.status = "closed"; return { id, closed: true }; } } ``` The result is an ordinary tool error, with `isError: true`. Three details make it useful: 1. **The message is written for the model.** It says what the rule is and what to do next. "Forbidden" alone would leave the model guessing, and likely retrying. 2. **`PublicMcpError` keeps it readable in production.** The message reaches the model word for word. A plain `Error` would become "Internal FrontMCP error" with an error ID. 3. **The second argument is a code.** It arrives as `_meta.code: "FORBIDDEN"`, so a client or a test can tell a refusal from other failures without parsing the text. (There's also a third argument, an HTTP status, but a tool error always goes out with HTTP `200`.) Check before you change anything, so a refused call has no effects. And put the rule in the description too ("Only signed-in support agents can close tickets"), so a model can tell the user before it tries. ## Declaring who may call a tool A check in `execute()` only runs once the tool is called. The tool is still listed for everyone, and in a server with many tools it's easy to forget the check in one of them. `authorities` puts the rule on the tool itself. FrontMCP checks it before `execute()` runs, and leaves the tool out of `tools/list` for callers who wouldn't pass. Rules are usually named once, as profiles, in `@FrontMcp`: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { CloseTicket, DeleteTicket, SearchTickets } from "./tickets.tools"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, CloseTicket, DeleteTicket] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], authorities: { // Where to find the caller's roles: the token's `roles` claim. claimsMapping: { roles: "roles" }, profiles: { agent: { roles: { any: ["agent", "admin"] } }, admin: { roles: { any: ["admin"] } }, }, }, }; @FrontMcp(config) export default class Server {} ``` ```ts tickets.tools.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }], ]); @Tool({ name: "search_tickets", description: "Search support tickets by words in their title.", inputSchema: { query: z.string().min(1) }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { const q = query.toLowerCase(); return { tickets: [...tickets.values()].filter((t) => t.title.toLowerCase().includes(q)) }; } } @Tool({ name: "close_ticket", description: "Close a support ticket.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, authorities: "agent", }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); ticket.status = "closed"; return { id, closed: true }; } } @Tool({ name: "delete_ticket", description: "Delete a support ticket for good.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, authorities: "admin", }) export class DeleteTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, deleted: tickets.delete(id) }; } } ``` ```ts authorities.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; test("an anonymous caller only sees search_tickets", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t) => t.name); expect(names).toEqual(["search_tickets"]); }); test("calling close_ticket by name is refused before execute() runs", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("AUTHORITY_DENIED"); const found = await mcp.tools.call("search_tickets", { query: "log in" }); expect(found.json().tickets[0]).toMatchObject({ id: "T-1", status: "open" }); }); test("an agent sees close_ticket and can call it", async () => { // createDirect() runs the server in-process, as the user you name. const server = await FrontMcpInstance.createDirect(config); const asNour = { authContext: { user: { sub: "nour", roles: ["agent"] } } }; const { tools } = await server.listTools(asNour); const result = await server.callTool("close_ticket", { id: "T-2" }, asNour); await server.dispose(); expect(tools.map((t) => t.name).sort()).toEqual(["close_ticket", "search_tickets"]); expect(result.structuredContent).toEqual({ id: "T-2", closed: true }); }); ``` Open the **Capabilities** tab: an anonymous caller has no roles, so only `search_tickets` is listed. Calling `close_ticket` by name is refused anyway. The last test calls the server in-process as the agent `nour`, whose claims include `roles: ["agent"]`: `close_ticket` appears and works, and `delete_ticket` stays hidden. [Running FrontMCP Anywhere](https://frontmcp.dev/learn/running-frontmcp-anywhere) covers `createDirect()`. The pieces: - **`authorities` on a tool** names the rule: a profile like `"agent"`, a list of profiles that must all pass, like `["agent", "billing"]`, or a rule written in place. - **`profiles` in `@FrontMcp({ authorities })`** names reusable rules. `roles: { any: [...] }` passes if the caller has at least one of the roles; `all` requires every one. - **`claimsMapping`** tells FrontMCP where the caller's roles are in the token's claims. `roles: "roles"` reads the `roles` claim, and a path like `"realm_access.roles"` reads a nested one. `permissions` works the same way, and `this.auth.roles` follows the same mapping. When roles have to be worked out rather than read, `claimsResolver` takes a function instead, which gets `this.context.authInfo` and returns `{ roles, permissions, claims }`; rules and `this.auth` both use what it returns. A tool with `authorities` on a server without an `authorities` option doesn't start: FrontMCP refuses, rather than leave the tool open by accident. Rules can check more than roles: | Rule | Passes when | | --- | --- | | `{ roles: { any: ["agent"] } }` | The caller has at least one of the roles. `all` requires all of them. | | `{ permissions: { all: ["tickets:export"] } }` | The caller has every permission. `any` requires one. | | `{ attributes: { conditions: [{ path: "input.id", op: "startsWith", value: "T-" }] } }` | Each condition holds. Paths start with `user.`, `claims.`, `input.` (the call's arguments) or `env.` (the server's environment variables). | | `{ guards: [(ctx) => ...] }` | Every function returns `true`. Anything else refuses, `undefined` included, and a string becomes the reason. | | `{ anyOf: [ruleA, ruleB] }` | At least one of the rules passes. `allOf` and `not` also exist. | [Authorities](https://frontmcp.dev/reference/auth/authorities) has every rule form and operator, and the settings that look like they protect a tool but don't. > **Pitfall: An authorities refusal is written for developers** > > When `authorities` refuses a call, the result has `_meta.code: "AUTHORITY_DENIED"`, so a client or a test can tell it from other failures. Its text, in production too, is the rule that failed: `Access denied to Tool "help-desk:close_ticket": profile:agent: roles.any: user has none of 'agent', 'admin'`. That tells the model the call was refused, but not what to do next, and it tells the caller which roles would have let them in. Since the tool isn't in the caller's `tools/list`, a model rarely calls it. When a caller can see a tool but may not be allowed to use it on some tickets, check in `execute()` and fail with a message, as in the previous section. You can't add a friendlier message to an `authorities` refusal from `execute()`: for a refused call, `execute()` never runs. ### Scopes as permissions Roles are one way to describe callers. Scopes are another: a token for a support agent carries `tickets:write`, and an anonymous caller in [transparent mode](https://frontmcp.dev/learn/authenticating-clients#letting-anonymous-callers-in-with-less) gets the `anonymousScopes` you set. Point `claimsMapping.permissions` at the `scope` claim, and FrontMCP splits it on spaces into permissions that rules can require: ```ts main.ts @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "transparent", provider: "https://auth.example.com", allowAnonymous: true, anonymousScopes: ["tickets:read"], }, authorities: { claimsMapping: { roles: "roles", permissions: "scope" }, profiles: { reader: { permissions: { all: ["tickets:read"] } } }, }, }) export default class Server {} ``` The last challenge on this page uses this setup. > **Note** > > A rule that reads `input.` can only be checked when there's a call. `tools/list` has no arguments to check it against, so FrontMCP counts the rule as failed there, and the tool disappears from `tools/list` for every caller, including the ones who could call it. ## Hiding a tool isn't protecting it `visibility: "hidden"` looks like a way to keep a tool away from callers. It isn't: ```ts purge.tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "purge_closed_tickets", description: "Delete every closed ticket.", inputSchema: {}, annotations: { destructiveHint: true }, visibility: "hidden", }) export class PurgeClosedTickets extends ToolContext { async execute() { return { purged: 12 }; } } @Tool({ name: "rebuild_search_index", description: "Rebuild the ticket search index.", inputSchema: {}, visibility: "internal", }) export class RebuildSearchIndex extends ToolContext { async execute() { return { rebuilt: true }; } } @Tool({ name: "get_ticket", description: "Get one ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in" }; } } ``` ```ts visibility.test.ts import { test, expect } from "@frontmcp/testing"; test("neither tool is listed", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t) => t.name); expect(names).toEqual(["get_ticket"]); }); test("a hidden tool still runs for anyone who knows its name", async ({ mcp }) => { const result = await mcp.tools.call("purge_closed_tickets", {}); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ purged: 12 }); }); test("an internal tool can't be called by clients", async ({ mcp }) => { const result = await mcp.tools.call("rebuild_search_index", {}); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("TOOL_NOT_FOUND"); }); ``` Both tools are missing from the Capabilities tab, but the **Tests** tab shows the difference. Anyone who knows the name of a hidden tool can call it, and names aren't secrets: they're in your code, your docs and old conversations. Hidden is for tools the model doesn't need to see, such as one your own client code calls by name; it keeps them out of the list, nothing more. An internal tool isn't reachable by clients at all; a call gets `Tool "rebuild_search_index" not found`. | | In `tools/list` | Callable by clients | | --- | --- | --- | | No option (`visibility: "public"`) | Yes | Yes | | `visibility: "hidden"` | No, for everyone | Yes, by anyone who knows the name | | `visibility: "internal"` | No | No | | `authorities: …` | Only for callers the rule lets through | Only by callers the rule lets through | To keep a tool from a caller, use `authorities` or a check in `execute()`. Visibility only decides what a list shows. ## Recap - `this.auth` holds the caller: `user.sub`, `isAnonymous`, `scopes`, `roles` and the token's `claims`, with checks like `hasScope()` and `hasRole()`. - To refuse a call, `this.fail(new PublicMcpError(message, "FORBIDDEN"))` before changing anything, with a message that tells the model what to do next. - `authorities` on a tool, with profiles and a `claimsMapping` in `@FrontMcp({ authorities })`, refuses calls before `execute()` and hides the tool from callers who can't use it. - An `authorities` refusal has the code `AUTHORITY_DENIED` and a message written for developers, so use `execute()` checks where the model needs an explanation. - `visibility: "hidden"` only hides a tool from `tools/list`; anyone can still call it. `internal` tools can't be called by clients. - Every property and method of `this.auth`, per auth mode and entry point, is in the [`this.auth` reference](https://frontmcp.dev/reference/sdk/auth). ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Refuse anonymous callers `reopen_ticket` reopens a closed ticket for anyone. Make it refuse anonymous callers with a tool error whose code is `FORBIDDEN` and whose message tells the model to ask the user to sign in. A refused call must leave the ticket closed. Initial call configuration: ```js { tool: "reopen_ticket", arguments: { id: "T-2" } } ``` ```ts tickets.tools.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }], ]); @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 }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); return ticket; } } @Tool({ name: "reopen_ticket", description: "Reopen a closed support ticket.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class ReopenTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); ticket.status = "open"; return { id, reopened: true }; } } ``` **Solution file (alternative to the starter):** ```ts tickets.tools.ts solution import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }], ]); @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 }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); return ticket; } } @Tool({ name: "reopen_ticket", description: "Reopen a closed support ticket. Only signed-in users can reopen tickets.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class ReopenTicket extends ToolContext { async execute({ id }: { id: string }) { if (this.auth.isAnonymous) { this.fail(new PublicMcpError("Only signed-in users can reopen tickets. Ask the user to sign in, then try again.", "FORBIDDEN")); } const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); ticket.status = "open"; return { id, reopened: true }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts reopen.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("an anonymous call is refused with code `FORBIDDEN`", async ({ mcp }) => { const result = await mcp.tools.call("reopen_ticket", { id: "T-2" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("FORBIDDEN"); }); test("the message tells the model to have the user sign in", async ({ mcp }) => { const result = await mcp.tools.call("reopen_ticket", { id: "T-2" }); expect(result.text()).toMatch(/sign in/i); }); test("a refused call leaves the ticket closed", async ({ mcp }) => { await mcp.tools.call("reopen_ticket", { id: "T-2" }); const ticket = await mcp.tools.call("get_ticket", { id: "T-2" }); expect(ticket.json().status).toBe("closed"); }); ``` **Hint:** `this.auth.isAnonymous` is `true` for a caller without credentials. Check it before you touch the ticket. **Solution:** The check comes first, so a refused call changes nothing, and `this.fail()` with a `PublicMcpError` sends the message word for word with `_meta.code: "FORBIDDEN"`. The description now states the rule too, so a model can mention it before calling. With a shared key or a token, `isAnonymous` would be `false`, and the call would go through. ### Challenge: Really keep the delete tool from callers Someone hid `delete_ticket` with `visibility: "hidden"`, but anyone who knows its name can still delete tickets. The server already has an `admin` profile. Make `delete_ticket` both invisible to and uncallable by anyone who isn't an admin. Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts delete-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./store"; @Tool({ name: "delete_ticket", description: "Delete a support ticket for good.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, visibility: "hidden", }) export class DeleteTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, deleted: tickets.delete(id) }; } } ``` **Solution file (alternative to the starter):** ```ts delete-ticket.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./store"; @Tool({ name: "delete_ticket", description: "Delete a support ticket for good. Admins only.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, authorities: "admin", }) export class DeleteTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, deleted: tickets.delete(id) }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { DeleteTicket } from "./delete-ticket.tool"; import { GetTicket } from "./get-ticket.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, DeleteTicket] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], authorities: { claimsMapping: { roles: "roles" }, profiles: { admin: { roles: { any: ["admin"] } } }, }, }) export default class Server {} ``` ```ts get-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./store"; @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 }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); return ticket; } } ``` ```ts store.ts export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }], ]); ``` **Hidden fixture/test file (runs with the example):** ```ts delete.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`delete_ticket` isn't listed for an anonymous caller", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t) => t.name); expect(names).not.toContain("delete_ticket"); }); test("calling `delete_ticket` by name is refused", async ({ mcp }) => { const result = await mcp.tools.call("delete_ticket", { id: "T-1" }); expect(result).toBeError(); }); test("the ticket is still there afterwards", async ({ mcp }) => { await mcp.tools.call("delete_ticket", { id: "T-1" }); expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeSuccessful(); }); ``` **Hint:** `visibility` only changes what `tools/list` shows. The option that checks the caller on every call names a profile. **Solution:** `authorities: "admin"` makes FrontMCP check the `admin` profile on every call, before `execute()` runs, and leave the tool out of `tools/list` for callers who fail it. `visibility: "hidden"` has to go too: with it, the tool would be hidden even from admins. The description says "Admins only", for the admins who do see it. ### Challenge: Require a scope to export Anonymous callers on this server get the scope `tickets:read`, and `main.ts` turns scopes into permissions. `search_tickets` requires `tickets:read`. Make `export_tickets` require the permission `tickets:export`, so that anonymous callers can neither see nor call it. Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts tickets.tools.ts active 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: "search_tickets", description: "Search support tickets by words in their title.", inputSchema: { query: z.string().min(1) }, authorities: "reader", }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { const q = query.toLowerCase(); return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(q)) }; } } @Tool({ name: "export_tickets", description: "Export every support ticket, with customer details, as CSV.", inputSchema: {}, }) export class ExportTickets extends ToolContext { async execute() { return { csv: ["id,title,status", ...tickets.map((t) => `${t.id},${t.title},${t.status}`)].join("\n") }; } } ``` **Solution file (alternative to the starter):** ```ts tickets.tools.ts solution 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: "search_tickets", description: "Search support tickets by words in their title.", inputSchema: { query: z.string().min(1) }, authorities: "reader", }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { const q = query.toLowerCase(); return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(q)) }; } } @Tool({ name: "export_tickets", description: "Export every support ticket, with customer details, as CSV. Needs the tickets:export scope.", inputSchema: {}, authorities: { permissions: { all: ["tickets:export"] } }, }) export class ExportTickets extends ToolContext { async execute() { return { csv: ["id,title,status", ...tickets.map((t) => `${t.id},${t.title},${t.status}`)].join("\n") }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { ExportTickets, SearchTickets } from "./tickets.tools"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, ExportTickets] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "transparent", provider: "https://auth.example.com", allowAnonymous: true, anonymousScopes: ["tickets:read"], }, authorities: { claimsMapping: { roles: "roles", permissions: "scope" }, profiles: { reader: { permissions: { all: ["tickets:read"] } } }, }, }) export default class Server {} ``` **Hidden fixture/test file (runs with the example):** ```ts export.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("anonymous callers don't see `export_tickets`", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t) => t.name); expect(names).not.toContain("export_tickets"); }); test("calling `export_tickets` by name is refused", async ({ mcp }) => { expect(await mcp.tools.call("export_tickets", {})).toBeError(); }); test("anonymous callers can still search", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t) => t.name); expect(names).toContain("search_tickets"); const result = await mcp.tools.call("search_tickets", { query: "log" }); expect(result).toBeSuccessful(); expect(result.json().tickets).toHaveLength(2); }); ``` **Hint:** `search_tickets` uses a profile from `main.ts`. For one tool, you can also write the rule in place: `{ permissions: { all: [...] } }`. **Solution:** `authorities: { permissions: { all: ["tickets:export"] } }` requires the permission that `main.ts` builds from a `tickets:export` scope. Anonymous callers only have `tickets:read`, so FrontMCP leaves the tool out of their `tools/list` and refuses it by name. An agent whose token has `scope: "tickets:read tickets:export"` sees and calls both tools. If more tools need the same rule, name it as a profile next to `reader`. --- # Limiting and Timing Out Calls > How to cap how often a FrontMCP tool runs, how many copies run at once, and how long a call may take, with rateLimit, concurrency and timeout, and what callers get back when a limit trips. Source: https://frontmcp.dev/learn/limiting-calls [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A model can call a tool as often as it likes, and nothing in MCP stops it from calling one in a loop. For most tools that's fine. For an export that reads the whole database, a merge that must never run twice at once, or a lookup that can hang on a slow service, it isn't. Three of FrontMCP's `@Tool` options set limits: `rateLimit` caps how often a tool runs, `concurrency` caps how many calls run at once, and `timeout` caps how long one call may take. **You will learn** - How to cap how often a tool runs, and what a caller gets back when a limit trips - Whose calls a limit counts, and what anonymous callers share - How to stop two runs of a tool overlapping - What a timeout stops, and how to let the work stop with it - How to choose limits for expensive and destructive tools ## Capping how often a tool runs Exporting tickets reads every ticket in the database. The help desk wants at most two exports an hour, so the tool sets `rateLimit`. Open the **Tests** tab: Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts export-tickets.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; import { tickets } from "./store"; @Tool({ name: "export_tickets", description: "Export every support ticket as CSV. Reads the whole ticket database, so at most 2 exports an hour.", inputSchema: {}, annotations: { readOnlyHint: true }, rateLimit: { maxRequests: 2, windowMs: 60 * 60_000 }, }) export class ExportTickets extends ToolContext { async execute() { const rows = tickets.map((t) => `${t.id},${t.title},${t.status}`); return { csv: ["id,title,status", ...rows].join("\n") }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { ExportTickets } from "./export-tickets.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts store.ts export 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" }, ]; ``` ```ts limit.test.ts import { test, expect } from "@frontmcp/testing"; test("the third export in an hour is refused", async ({ mcp }) => { expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful(); expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful(); const third = await mcp.tools.call("export_tickets", {}); expect(third).toBeError(); expect(third.raw._meta?.code).toBe("RATE_LIMIT_EXCEEDED"); expect(third.text()).toMatch(/^Rate limit exceeded\. Retry after \d+ seconds$/); }); ``` The export above ran once when the example started. Press **Call** in the Call tab twice more: the second call works, the third is refused. `rateLimit` takes: | Field | Default | Meaning | | --- | --- | --- | | `maxRequests` | required | How many calls a window allows. | | `windowMs` | `60_000` | The window's length, in milliseconds. | | `partitionBy` | `"global"` | Whose calls share a count. See [below](https://frontmcp.dev/learn/limiting-calls#whose-calls-a-limit-counts). | ## What the caller gets back A call over the limit doesn't reach `execute()`. The client gets an ordinary tool error: ```json { "content": [{ "type": "text", "text": "Rate limit exceeded. Retry after 2274 seconds" }], "isError": true, "_meta": { "errorId": "err_…", "code": "RATE_LIMIT_EXCEEDED" } } ``` - **The retry hint is in the text, for the model.** The HTTP status is still `200` and there's no `Retry-After` header, so the model is the one that reads when to try again. The message survives in production too. - **The wait is until the window ends.** Windows line up with the clock, not with the first call: an hour-long window runs from one full hour (in UTC) to the next, and a minute-long one from one full minute to the next. The number is the time left in the current window, so it can be anything from a second up to the whole `windowMs`. Refused at 10:40 UTC by an hourly limit, a caller is told to retry in about 1200 seconds. Say the limit in the description as well, as `export_tickets` does ("at most 2 exports an hour"). A model that knows the rule can plan around it, or tell the user, instead of finding out from an error. ## Whose calls a limit counts `partitionBy` decides which calls share a count: | `partitionBy` | One count per | Under MCP 2026-07-28 | | --- | --- | --- | | `"global"` | The whole server | Every caller shares one limit. | | `"userId"` | Signed-in caller, by `authInfo.clientId` | Each caller with a key or token has a count of their own. All anonymous callers share one. | | `"ip"` | Client IP address | Works on a real server. The Playground has no IP, so there it behaves like `"userId"`. | | `"session"` | MCP session | There are no sessions, so it behaves like `"userId"`. | | `(ctx) => string` | Whatever key you return | `ctx` has `sessionId`, plus `userId` for a signed-in caller and `clientIp` when there is one. | `"userId"` is the natural choice for "two exports per person", and for signed-in callers it does exactly that. Anonymous callers have no user id, so they all share one count: Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts export-tickets.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "export_tickets", description: "Export every support ticket as CSV. At most 2 exports an hour per signed-in caller, and 2 an hour for all anonymous callers together.", inputSchema: {}, annotations: { readOnlyHint: true }, rateLimit: { maxRequests: 2, windowMs: 60 * 60_000, partitionBy: "userId" }, }) export class ExportTickets extends ToolContext { async execute() { return { csv: "id,title,status\nT-1,Cannot log in,open" }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { ExportTickets } from "./export-tickets.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts limit.test.ts import { test, expect } from "@frontmcp/testing"; test("anonymous callers share one count", async ({ mcp }) => { expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful(); expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful(); const third = await mcp.tools.call("export_tickets", {}); expect(third.raw._meta?.code).toBe("RATE_LIMIT_EXCEEDED"); }); ``` Each of these calls arrives as a [new anonymous caller](https://frontmcp.dev/learn/authenticating-clients#a-server-anyone-can-call) with a new id, but FrontMCP doesn't count anonymous callers by those ids, so the third one is refused like the third call from a single person. That's the safe choice: nobody gets around a limit by not signing in. It also means one busy anonymous client can use up the exports for every other anonymous client. If that matters, have callers sign in, so each gets a count of their own, or, on a real server, count anonymous callers by address with a function: `partitionBy: (ctx) => ctx.userId ?? ctx.clientIp ?? "anonymous"`. Counts are kept in the server's memory unless you say otherwise, so each instance of a server counts on its own: two instances behind a load balancer let through four exports an hour, not two. With `throttle.storage` on one Redis, the instances share a count: five exports sent to two instances in turn let two through. [Running Several Instances](https://frontmcp.dev/learn/running-several-instances#what-else-lives-in-one-instances-memory) sets that up. If that Redis can't be reached, a production server doesn't start, unless `throttle.storage` has `fallback: "memory"`, which starts it with each instance counting on its own. When Redis goes away while the server runs, a call to a tool with a limit fails with `GUARD_STORAGE_UNAVAILABLE`, or, with the fallback, is counted in the instance's memory until Redis answers again: [the guard reference](https://frontmcp.dev/reference/sdk/guard#calls-fail-with-guard_storage_unavailable-while-the-server-runs) shows what a caller gets, and [what happens at startup](https://frontmcp.dev/reference/sdk/guard#the-server-doesnt-start-throttlestorage-redis-is-unavailable). ## One at a time Merging two customer records moves all their tickets from one record to the other. Two merges running at once can move the same tickets twice. `concurrency` caps how many calls of a tool run at the same moment: Initial call configuration: ```js { tool: "merge_customers", arguments: { from: "C-7", into: "C-2" } } ``` ```ts merge-customers.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; export const merges: string[] = []; @Tool({ name: "merge_customers", description: "Merge one customer record into another, moving all its tickets. One merge runs at a time; if another is running, wait a minute and try again.", inputSchema: { from: z.string().describe("Customer to merge away, like C-7"), into: z.string().describe("Customer to keep") }, annotations: { destructiveHint: true }, concurrency: { maxConcurrent: 1 }, }) export class MergeCustomers extends ToolContext { async execute({ from, into }: { from: string; into: string }) { await new Promise((resolve) => setTimeout(resolve, 300)); // moving tickets takes a while merges.push(`${from} → ${into}`); return { merged: from, into }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { MergeCustomers } from "./merge-customers.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [MergeCustomers] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts merge.test.ts import { test, expect } from "@frontmcp/testing"; test("a second merge while one is running is refused", async ({ mcp }) => { const [first, second] = await Promise.all([ mcp.tools.call("merge_customers", { from: "C-7", into: "C-2" }), mcp.tools.call("merge_customers", { from: "C-8", into: "C-2" }), ]); expect(first).toBeSuccessful(); expect(second).toBeError(); expect(second.raw._meta?.code).toBe("CONCURRENCY_LIMIT"); expect(second.text()).toBe('Concurrency limit reached for "merge_customers" (max: 1)'); }); test("once it's done, the next merge runs", async ({ mcp }) => { expect(await mcp.tools.call("merge_customers", { from: "C-8", into: "C-2" })).toBeSuccessful(); }); ``` The slot is taken when a call starts and given back when its `execute()` ends, whether it succeeded or failed. `concurrency` takes `maxConcurrent`, `partitionBy` (the same choices as for `rateLimit`, `"global"` by default), and `queueTimeoutMs`. By default, a call that finds no free slot is refused at once. With a queue, it waits for a slot, and is only refused if none frees up in time: ```ts concurrency: { maxConcurrent: 1, queueTimeoutMs: 5_000 }, ``` A queued call that runs out of time gets `Queue timeout for "merge_customers" after waiting 5000ms for a concurrency slot`. For a tool a model is likely to call twice in a row, a short queue is kinder than a refusal. Every limit answers the same way: an `isError` tool result, over HTTP `200`, with a code of its own in `_meta.code` and a message that is kept in production too: | Limit | `_meta.code` | Message | | --- | --- | --- | | `rateLimit` | `RATE_LIMIT_EXCEEDED` | `Rate limit exceeded. Retry after 1200 seconds` | | `concurrency` | `CONCURRENCY_LIMIT` | `Concurrency limit reached for "merge_customers" (max: 1)` | | `concurrency` with a queue | `QUEUE_TIMEOUT` | `Queue timeout for "merge_customers" after waiting 5000ms for a concurrency slot` | | `timeout` | `EXECUTION_TIMEOUT` | `Execution of "lookup_customer" timed out after 500ms` | So the model can tell "busy, try again soon" from "broken". It still helps to put the rule in the tool's description, as `merge_customers` does, so the model knows what to do about it. ## Deadlines A tool that calls another service waits as long as that service takes. `timeout: { executeMs }` puts a limit on it. Customer `C-9` sends the CRM into a slow path: Initial call configuration: ```js { tool: "lookup_customer", arguments: { id: "C-9" } } ``` ```ts lookup-customer.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; // Stands in for a CRM that is sometimes very slow to answer. async function crmLookup(id: string) { await new Promise((resolve) => setTimeout(resolve, id === "C-9" ? 2_000 : 50)); return { id, name: id === "C-9" ? "Globex" : "Initech", plan: "business" }; } @Tool({ name: "lookup_customer", description: "Look up a customer in the CRM by id. Gives up after half a second if the CRM is slow.", inputSchema: { id: z.string().describe("Customer id, like C-1") }, annotations: { readOnlyHint: true, openWorldHint: true }, timeout: { executeMs: 500 }, }) export class LookupCustomer extends ToolContext { async execute({ id }: { id: string }) { return crmLookup(id); } } ``` ```ts lookup.test.ts import { test, expect } from "@frontmcp/testing"; test("a slow lookup fails after half a second", async ({ mcp }) => { const result = await mcp.tools.call("lookup_customer", { id: "C-9" }); expect(result.raw._meta?.code).toBe("EXECUTION_TIMEOUT"); expect(result.durationMs).toBeLessThan(1_000); }); test("a fast one answers", async ({ mcp }) => { expect(await mcp.tools.call("lookup_customer", { id: "C-1" })).toBeSuccessful(); }); ``` After half a second the call fails with `Execution of "lookup_customer" timed out after 500ms`. Try `C-1`: it answers in time. ## A timeout ends the call, not the work When a call times out, FrontMCP stops waiting for `execute()` and answers the client. `execute()` itself carries on unless it's written to stop (the [next section](https://frontmcp.dev/learn/limiting-calls#stopping-the-work) shows how), side effects and all. Its `concurrency` slot stays taken until it really ends, so runs never overlap, and a call that arrives in the meantime is refused: ```ts merge-customers.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; const merges: string[] = []; let running = 0; let mostAtOnce = 0; @Tool({ name: "merge_customers", description: "Merge one customer record into another, moving all its tickets. One merge runs at a time.", inputSchema: { from: z.string(), into: z.string() }, annotations: { destructiveHint: true }, concurrency: { maxConcurrent: 1 }, timeout: { executeMs: 200 }, }) export class MergeCustomers extends ToolContext { async execute({ from, into }: { from: string; into: string }) { running++; mostAtOnce = Math.max(mostAtOnce, running); await new Promise((resolve) => setTimeout(resolve, 500)); // takes longer than the timeout merges.push(`${from} → ${into}`); running--; return { merged: from, into }; } } @Tool({ name: "merge_log", description: "List finished merges.", inputSchema: {} }) export class MergeLog extends ToolContext { async execute() { return { merges, mostAtOnce }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { MergeCustomers, MergeLog } from "./merge-customers.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [MergeCustomers, MergeLog] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts timeout.test.ts import { test, expect } from "@frontmcp/testing"; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("the merge still happens after its call times out", async ({ mcp }) => { const result = await mcp.tools.call("merge_customers", { from: "C-7", into: "C-2" }); expect(result.raw._meta?.code).toBe("EXECUTION_TIMEOUT"); await wait(500); const log = (await mcp.tools.call("merge_log", {})).json(); expect(log.merges).toContain("C-7 → C-2"); }); test("a merge sent while a timed-out one is still running is refused", async ({ mcp }) => { await mcp.tools.call("merge_customers", { from: "C-8", into: "C-2" }); // times out, keeps running const second = await mcp.tools.call("merge_customers", { from: "C-9", into: "C-2" }); expect(second.raw._meta?.code).toBe("CONCURRENCY_LIMIT"); await wait(600); const log = (await mcp.tools.call("merge_log", {})).json(); expect(log.merges).toContain("C-8 → C-2"); expect(log.merges).not.toContain("C-9 → C-2"); expect(log.mostAtOnce).toBe(1); }); ``` For a tool that changes things, a timeout shorter than the work is worse than none. The model is told the merge failed while it's still going, then that merges are busy, and it may well run the same merge again once the first one is done. Give such tools no timeout, or one comfortably longer than the work can take, and make them safe to repeat, for example by checking whether the merge already happened before starting it. ## Stopping the work A timeout also aborts `this.signal`, the call's [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Work that listens to it stops with the call. `fetch()` and most clients for databases and other services take a `signal` option, and a loop can call `this.signal?.throwIfAborted()` between steps. (The type says `signal` may be missing, hence the `?.`.) This export reads the ticket database a page at a time, and a slow database makes it take longer than its timeout: Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts export-tickets.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; export const pagesRead: number[] = []; export const stops: string[] = []; // Stands in for a slow database: 10 pages, a tenth of a second each. async function readPage(page: number) { await new Promise((resolve) => setTimeout(resolve, 100)); pagesRead.push(page); return [`T-${page},Ticket ${page},open`]; } @Tool({ name: "export_tickets", description: "Export every support ticket as CSV. Gives up after 350 ms.", inputSchema: {}, annotations: { readOnlyHint: true }, timeout: { executeMs: 350 }, }) export class ExportTickets extends ToolContext { async execute() { const rows: string[] = []; try { for (let page = 1; page <= 10; page++) { this.signal?.throwIfAborted(); // ✅ stop between pages once the call has timed out rows.push(...(await readPage(page))); } } catch (error) { stops.push(String(this.signal?.reason?.message ?? error)); throw error; } return { csv: ["id,title,status", ...rows].join("\n") }; } } @Tool({ name: "export_stats", description: "How many database pages exports have read, and why they stopped.", inputSchema: {} }) export class ExportStats extends ToolContext { async execute() { return { pagesRead: pagesRead.length, stops }; } } ``` ```ts export.test.ts import { test, expect } from "@frontmcp/testing"; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("the export stops reading when its call times out", async ({ mcp }) => { const result = await mcp.tools.call("export_tickets", {}); expect(result.raw._meta?.code).toBe("EXECUTION_TIMEOUT"); await wait(1_200); // long enough to read all 10 pages, had it kept going const stats = (await mcp.tools.call("export_stats", {})).json(); expect(stats.pagesRead).toBeLessThan(10); expect(stats.stops).toContain('Execution of "export_tickets" timed out after 350ms'); }); ``` Take out the `throwIfAborted()` line and run the test again: the call still times out after 350 ms, but the export goes on to read all ten pages that nobody will see. `this.signal.reason` is the same timeout error the client got. Stopping is right for work that only reads. For work that changes things, stopping halfway can be worse than finishing, which is why the merge above doesn't listen to the signal. ## Turning limits off The limits in `@Tool` are enforced by FrontMCP's guard, which starts by itself when a tool declares `rateLimit` or `concurrency`. `@FrontMcp`'s `throttle` option configures the guard, and `throttle: { enabled: false }` turns it off: every `rateLimit` and `concurrency` on the server is ignored. That can be what you want for a local test run that calls the same tool many times: Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { ExportTickets } from "./export-tickets.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], throttle: { enabled: false }, // 🚩 for local testing only }) export default class Server {} ``` ```ts export-tickets.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; import { tickets } from "./store"; @Tool({ name: "export_tickets", description: "Export every support ticket as CSV. Reads the whole ticket database, so at most 2 exports an hour.", inputSchema: {}, annotations: { readOnlyHint: true }, rateLimit: { maxRequests: 2, windowMs: 60 * 60_000 }, }) export class ExportTickets extends ToolContext { async execute() { const rows = tickets.map((t) => `${t.id},${t.title},${t.status}`); return { csv: ["id,title,status", ...rows].join("\n") }; } } ``` ```ts store.ts export 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" }, ]; ``` ```ts limit.test.ts import { test, expect } from "@frontmcp/testing"; test("with the guard off, five exports in a row all succeed", async ({ mcp }) => { for (let i = 0; i < 5; i++) { expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful(); } }); ``` The description still says "at most 2 exports an hour", and nothing enforces it, so keep `enabled: false` out of anything a real client can reach. `timeout` is the one limit it doesn't turn off. ## Limits for every tool `throttle` can also set defaults, for tools that don't set their own. Unlike a tool's own limits, `throttle` needs `enabled: true`: ```ts main.ts @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], throttle: { enabled: true, defaultRateLimit: { maxRequests: 60, windowMs: 60_000, partitionBy: "ip" }, defaultConcurrency: { maxConcurrent: 5 }, defaultTimeout: { executeMs: 30_000 }, }, }) export default class Server {} ``` A tool's own `rateLimit`, `concurrency` or `timeout` replaces the default. The [`@FrontMcp` reference](https://frontmcp.dev/reference/sdk/frontmcp#setting-defaults-for-every-tool) shows `defaultRateLimit` running. > **Note** > > Two more `throttle` options apply to the server as a whole. `globalConcurrency: { maxConcurrent }` caps how many tool calls run at once across every tool; a call over it is refused with `Concurrency limit reached for "global"`. `global: { maxRequests, windowMs }` is one rate limit for every request the server gets. It's checked when a request arrives, before any tool runs, so a request over it gets HTTP `429` with a `Retry-After` header and JSON-RPC error `-32029`, not a tool error. ## Choosing limits | Kind of tool | Example | Limits | | --- | --- | --- | | Cheap and read-only | `search_tickets` | Usually none of its own. A server-wide `defaultRateLimit` if strangers can reach the server. | | Expensive to run | `export_tickets` | A `rateLimit` per caller (`"userId"`), and a `timeout` whose `this.signal` stops the work. | | Waits on another service | `lookup_customer` | A `timeout` shorter than the client is willing to wait, so the model gets an error it can explain, with `this.signal` passed on to the request. | | Changes shared state | `merge_customers` | `concurrency: { maxConcurrent: 1 }`, with a short queue. No timeout shorter than the work. | | Sends email or spends money | `email_customer`, `refund_order` | A low `rateLimit` per caller. A model stuck in a loop shouldn't be able to send a hundred emails. A limit doesn't ask anyone: to have the user agree first, add [approval](https://frontmcp.dev/learn/asking-for-approval). | Whatever you choose, write it in the description. Limits the model knows about are limits it can work with. ## Recap - `rateLimit`, `concurrency` and `timeout` on `@Tool` work as soon as a tool sets them. `throttle` in `@FrontMcp` adds defaults for every tool, and `throttle: { enabled: false }` turns rate limits and concurrency off. - A call over a limit gets an `isError` result, over HTTP `200`, with its own `_meta.code` (`RATE_LIMIT_EXCEEDED`, `CONCURRENCY_LIMIT`, `QUEUE_TIMEOUT`, `EXECUTION_TIMEOUT`) and a message kept in production. "Retry after N seconds" counts down to the end of a clock-aligned window. - `partitionBy` picks whose calls share a count. With `"userId"`, each signed-in caller has a count of their own, and all anonymous callers share one. - `concurrency: { maxConcurrent, queueTimeoutMs }` stops calls overlapping, and a timed-out call keeps its slot until `execute()` really ends. - `timeout: { executeMs }` ends the call and aborts `this.signal`. Pass the signal on so read-only work stops too, and don't give tools that change things a timeout shorter than their work. - Every guard option, and what each error looks like to a caller, is in the [Guard options reference](https://frontmcp.dev/reference/sdk/guard). ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Cap the export Nothing stops a model from exporting the ticket database over and over. Allow at most 3 exports an hour, for the whole server. Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts main.ts import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "export_tickets", description: "Export every support ticket as CSV.", inputSchema: {}, annotations: { readOnlyHint: true }, }) class ExportTickets extends ToolContext { async execute() { return { csv: "id,title,status\nT-1,Cannot log in,open\nT-2,Invoice total is wrong,closed" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` **Solution file (alternative to the starter):** ```ts main.ts solution import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "export_tickets", description: "Export every support ticket as CSV. At most 3 exports an hour.", inputSchema: {}, annotations: { readOnlyHint: true }, rateLimit: { maxRequests: 3, windowMs: 60 * 60_000 }, }) class ExportTickets extends ToolContext { async execute() { return { csv: "id,title,status\nT-1,Cannot log in,open\nT-2,Invoice total is wrong,closed" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` **Hidden fixture/test file (runs with the example):** ```ts export.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("three exports in an hour work, and the fourth is refused", async ({ mcp }) => { for (let i = 0; i < 3; i++) { expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful(); } const fourth = await mcp.tools.call("export_tickets", {}); expect(fourth).toBeError(); expect(fourth.raw._meta?.code).toBe("RATE_LIMIT_EXCEEDED"); }); test("the refusal tells the model when to retry", async ({ mcp }) => { const result = await mcp.tools.call("export_tickets", {}); expect(result.text()).toMatch(/Retry after \d+ seconds/); }); ``` **Hint:** One option on `@Tool` does it. `windowMs` is in milliseconds. **Solution:** `rateLimit: { maxRequests: 3, windowMs: 60 * 60_000 }` counts calls per hour, and the default `partitionBy: "global"` shares that count across every caller. The server needs no change: a tool that declares a limit is enough to start the guard. The description now says the rule, for the model. ### Challenge: Let the second merge wait its turn `merge_customers` runs one merge at a time, and refuses a second merge that arrives while one is running. A merge takes a fraction of a second, so make a second merge wait for the first one instead, for up to 5 seconds. Merges must still never run at the same time. Initial call configuration: ```js { tool: "merge_customers", arguments: { from: "C-7", into: "C-2" } } ``` ```ts merge-customers.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; const merges: string[] = []; let running = 0; let mostAtOnce = 0; @Tool({ name: "merge_customers", description: "Merge one customer record into another, moving all its tickets. One merge runs at a time.", inputSchema: { from: z.string(), into: z.string() }, annotations: { destructiveHint: true }, concurrency: { maxConcurrent: 1 }, }) export class MergeCustomers extends ToolContext { async execute({ from, into }: { from: string; into: string }) { running++; mostAtOnce = Math.max(mostAtOnce, running); await new Promise((resolve) => setTimeout(resolve, 300)); merges.push(`${from} → ${into}`); running--; return { merged: from, into }; } } @Tool({ name: "merge_log", description: "List finished merges.", inputSchema: {} }) export class MergeLog extends ToolContext { async execute() { return { merges, mostAtOnce }; } } ``` **Solution file (alternative to the starter):** ```ts merge-customers.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; const merges: string[] = []; let running = 0; let mostAtOnce = 0; @Tool({ name: "merge_customers", description: "Merge one customer record into another, moving all its tickets. One merge runs at a time; a second merge waits up to 5 seconds for its turn.", inputSchema: { from: z.string(), into: z.string() }, annotations: { destructiveHint: true }, concurrency: { maxConcurrent: 1, queueTimeoutMs: 5_000 }, }) export class MergeCustomers extends ToolContext { async execute({ from, into }: { from: string; into: string }) { running++; mostAtOnce = Math.max(mostAtOnce, running); await new Promise((resolve) => setTimeout(resolve, 300)); merges.push(`${from} → ${into}`); running--; return { merged: from, into }; } } @Tool({ name: "merge_log", description: "List finished merges.", inputSchema: {} }) export class MergeLog extends ToolContext { async execute() { return { merges, mostAtOnce }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { MergeCustomers, MergeLog } from "./merge-customers.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [MergeCustomers, MergeLog] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` **Hidden fixture/test file (runs with the example):** ```ts merge.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("two merges started together both succeed", async ({ mcp }) => { const results = await Promise.all([ mcp.tools.call("merge_customers", { from: "C-7", into: "C-2" }), mcp.tools.call("merge_customers", { from: "C-8", into: "C-2" }), ]); for (const result of results) expect(result).toBeSuccessful(); }); test("they ran one after the other", async ({ mcp }) => { const log = (await mcp.tools.call("merge_log", {})).json(); expect(log.merges).toHaveLength(2); expect(log.mostAtOnce).toBe(1); }); ``` **Hint:** `concurrency` has an option for how long a call may wait for a free slot. Its default, `0`, means not at all. **Solution:** `queueTimeoutMs: 5_000` makes a call that finds the slot taken wait for it, for up to 5 seconds, instead of failing at once. `maxConcurrent: 1` still holds, so the second merge starts when the first ends: both succeed, and `mostAtOnce` stays `1`. If a merge ever took longer than 5 seconds, the waiting call would fail with `QUEUE_TIMEOUT`, so the description says how long it waits. ### Challenge: Give up on a slow CRM `lookup_customer` waits as long as the CRM takes, and for `C-9` that's two seconds. Make the tool give up after 500 milliseconds, so the model hears back quickly. Lookups that answer in time must still work. Initial call configuration: ```js { tool: "lookup_customer", arguments: { id: "C-1" } } ``` ```ts lookup-customer.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; async function crmLookup(id: string) { await new Promise((resolve) => setTimeout(resolve, id === "C-9" ? 2_000 : 50)); return { id, name: id === "C-9" ? "Globex" : "Initech", plan: "business" }; } @Tool({ name: "lookup_customer", description: "Look up a customer in the CRM by id.", inputSchema: { id: z.string().describe("Customer id, like C-1") }, annotations: { readOnlyHint: true, openWorldHint: true }, }) export class LookupCustomer extends ToolContext { async execute({ id }: { id: string }) { return crmLookup(id); } } ``` **Solution file (alternative to the starter):** ```ts lookup-customer.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; async function crmLookup(id: string) { await new Promise((resolve) => setTimeout(resolve, id === "C-9" ? 2_000 : 50)); return { id, name: id === "C-9" ? "Globex" : "Initech", plan: "business" }; } @Tool({ name: "lookup_customer", description: "Look up a customer in the CRM by id. Gives up after half a second if the CRM is slow; if it does, try again later.", inputSchema: { id: z.string().describe("Customer id, like C-1") }, annotations: { readOnlyHint: true, openWorldHint: true }, timeout: { executeMs: 500 }, }) export class LookupCustomer extends ToolContext { async execute({ id }: { id: string }) { return crmLookup(id); } } ``` **Hidden fixture/test file (runs with the example):** ```ts lookup.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("a fast lookup still works", async ({ mcp }) => { const result = await mcp.tools.call("lookup_customer", { id: "C-1" }); expect(result).toBeSuccessful(); expect(result.json().name).toBe("Initech"); }); test("a slow lookup fails within a second", async ({ mcp }) => { const result = await mcp.tools.call("lookup_customer", { id: "C-9" }); expect(result).toBeError(); expect(result.text()).toContain("timed out"); expect(result.durationMs).toBeLessThan(1_000); }); ``` **Hint:** One of the three `@Tool` limits is about time. Its value is in milliseconds. **Solution:** `timeout: { executeMs: 500 }` makes FrontMCP answer with `Execution of "lookup_customer" timed out after 500ms` once half a second has passed. The lookup itself keeps running until the CRM answers, because it doesn't listen to `this.signal`; the next challenge fixes that. The description tells the model what a failure means and what to do about it. ### Challenge: Cancel the CRM request `lookup_customer` gives up after half a second, but its request to the CRM doesn't: for `C-9` it keeps a CRM connection busy for two whole seconds, for an answer nobody reads. Make the request stop when the call times out. `crmLookup` takes a `signal`, like `fetch()` does. Initial call configuration: ```js { tool: "lookup_customer", arguments: { id: "C-1" } } ``` ```ts lookup-customer.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { crmLookup } from "./crm"; @Tool({ name: "lookup_customer", description: "Look up a customer in the CRM by id. Gives up after half a second if the CRM is slow; if it does, try again later.", inputSchema: { id: z.string().describe("Customer id, like C-1") }, annotations: { readOnlyHint: true, openWorldHint: true }, timeout: { executeMs: 500 }, }) export class LookupCustomer extends ToolContext { async execute({ id }: { id: string }) { return crmLookup(id); } } ``` **Solution file (alternative to the starter):** ```ts lookup-customer.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { crmLookup } from "./crm"; @Tool({ name: "lookup_customer", description: "Look up a customer in the CRM by id. Gives up after half a second if the CRM is slow; if it does, try again later.", inputSchema: { id: z.string().describe("Customer id, like C-1") }, annotations: { readOnlyHint: true, openWorldHint: true }, timeout: { executeMs: 500 }, }) export class LookupCustomer extends ToolContext { async execute({ id }: { id: string }) { return crmLookup(id, { signal: this.signal }); } } ``` ```ts crm.ts import { Tool, ToolContext } from "@frontmcp/sdk"; // Stands in for the CRM's client library, and counts what happens to its requests. const requests = { answered: 0, cancelled: 0 }; export async function crmLookup(id: string, options: { signal?: AbortSignal } = {}) { await new Promise((resolve, reject) => { const timer = setTimeout(resolve, id === "C-9" ? 2_000 : 50); options.signal?.addEventListener("abort", () => { clearTimeout(timer); requests.cancelled += 1; reject(options.signal?.reason); }); }); requests.answered += 1; return { id, name: id === "C-9" ? "Globex" : "Initech", plan: "business" }; } @Tool({ name: "crm_requests", description: "How many CRM requests were answered and cancelled.", inputSchema: {} }) export class CrmRequests extends ToolContext { async execute() { return { ...requests }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts lookup.test.ts hidden import { test, expect } from "@frontmcp/testing"; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("a fast lookup still works", async ({ mcp }) => { const result = await mcp.tools.call("lookup_customer", { id: "C-1" }); expect(result).toBeSuccessful(); expect(result.json().name).toBe("Initech"); }); test("a slow lookup still times out", async ({ mcp }) => { const result = await mcp.tools.call("lookup_customer", { id: "C-9" }); expect(result.raw._meta?.code).toBe("EXECUTION_TIMEOUT"); }); test("its CRM request is cancelled", async ({ mcp }) => { await wait(100); expect((await mcp.tools.call("crm_requests", {})).json().cancelled).toBe(1); }); ``` **Hint:** When a call times out, FrontMCP aborts the call's `AbortSignal`. Where does a tool find it? **Solution:** `this.signal` is aborted when the call times out, and `crmLookup(id, { signal: this.signal })` hands it to the CRM client, which drops the request as soon as that happens. The model hears back after half a second either way; the difference is that the CRM stops working on an answer nobody will read. Pass `this.signal` on the same way to `fetch()` and to any client that takes one. --- # Doing Work in the Background > When a tool isn't enough. FrontMCP jobs for slow, flaky and long-running work, the tools clients run them with, background runs and retries, and workflows that chain jobs. Source: https://frontmcp.dev/learn/background-work [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Every tool in this course so far does its work while the client waits, and answers in the same call. That's right for most tools. Some work doesn't fit: an export that takes minutes, a sync with a CRM that fails now and then and should just be tried again, a nightly report made of several steps, or anything that should keep going after the call has returned. FrontMCP has **jobs** and **workflows** for this. This chapter covers jobs and workflows, including retries, background runs and deployment limits, and points out where 1.8 behaves differently from its docs. **In this chapter** - [How to write a job, run it, and read what comes back](https://frontmcp.dev/learn/your-first-job) - [How to run a job in the background, retry it, and check on it](https://frontmcp.dev/learn/running-jobs-in-the-background) - [How to chain jobs into a workflow](https://frontmcp.dev/learn/chaining-jobs-into-workflows) ## Your first job A job is written like a tool: a class with an input schema, an output schema and an `execute()` method. What's different is how it runs. A client doesn't call a job directly. It calls `execute_job` with the job's name, and FrontMCP runs the job, records the run, and returns the result with the job's logs: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "find-sla-breaches", input: { priority: "high" } } } ``` ```ts main.ts active import { App, FrontMcp, Job, JobContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", priority: "high", status: "open", hoursOpen: 6 }, { id: "T-2", priority: "normal", status: "open", hoursOpen: 30 }, { id: "T-3", priority: "high", status: "open", hoursOpen: 2 }, ]; @Job({ name: "find-sla-breaches", description: "Find the open tickets of one priority that have waited longer than their SLA allows.", inputSchema: { priority: z.enum(["high", "normal"]) }, outputSchema: { breaches: z.array(z.string()) }, }) class FindSlaBreaches extends JobContext { async execute({ priority }: { priority: "high" | "normal" }) { const limit = priority === "high" ? 4 : 24; const breaches = tickets.filter((t) => t.priority === priority && t.hoursOpen > limit).map((t) => t.id); this.log(`${breaches.length} ${priority}-priority tickets are past their SLA`); return { breaches }; } } @App({ id: "help-desk", name: "Help Desk", jobs: [FindSlaBreaches] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts job.test.ts import { test, expect } from "@frontmcp/testing"; test("registering a job adds FrontMCP's job tools", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t) => t.name).sort(); expect(names).toEqual(["execute_job", "execute_workflow", "get_job_status", "get_workflow_status"]); }); test("execute_job runs the job and returns the run", async ({ mcp }) => { const run = (await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "high" } })).json(); expect(run).toEqual({ runId: expect.any(String), state: "completed", result: { breaches: ["T-1"] }, logs: [expect.stringMatching(/\] 1 high-priority tickets are past their SLA$/)], }); }); ``` Open the **Capabilities** tab: the job isn't a tool. Registering it with `@App({ jobs })` added four tools of FrontMCP's own, the same four for any server with jobs or workflows: | Tool | What it does | | --- | --- | | `execute_job` | Runs a job by name, and returns the run: at the end of the call, or, with `background: true`, at once. | | `get_job_status` | Reads a run by its id: its state, and its result or error. | | `execute_workflow` | Runs a workflow, the same way. | | `get_workflow_status` | Reads a workflow's run. | Nothing in `tools/list` says which jobs exist, so a server has to tell the model, for example in its `instructions`. ### [Your First Job](https://frontmcp.dev/learn/your-first-job) Learn how to write and register a job, how a model finds out which jobs there are, what `execute_job` returns, what happens when the input is wrong or the job throws, and when to write a job instead of a tool. ## Running jobs in the background With `background: true`, `execute_job` answers straight away with a run id, and the job carries on. `get_job_status` reads the run later, but only for the caller who started it, and under MCP 2026-07-28 an anonymous caller is a new caller on every request. The Playground's calls are anonymous, so they can start a run but never read it: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "export-tickets", background: true } } ``` ```ts main.ts active import { App, FrontMcp, Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "export-tickets", description: "Export every support ticket as CSV. Takes a while.", inputSchema: {}, outputSchema: { csv: z.string() }, }) class ExportTickets extends JobContext { async execute() { await new Promise((resolve) => setTimeout(resolve, 1_000)); // reading the whole database return { csv: "id,title,status\nT-1,Cannot log in,open" }; } } @App({ id: "help-desk", name: "Help Desk", jobs: [ExportTickets] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts background.test.ts import { test, expect } from "@frontmcp/testing"; test("a background run starts at once", async ({ mcp }) => { const started = await mcp.tools.call("execute_job", { name: "export-tickets", background: true }); expect(started.json()).toEqual({ runId: expect.any(String), state: "pending" }); expect(started.durationMs).toBeLessThan(200); }); test("an anonymous caller can't read it afterwards", async ({ mcp }) => { const { runId } = (await mcp.tools.call("execute_job", { name: "export-tickets", background: true })).json(); const status = await mcp.tools.call("get_job_status", { runId }); expect(status).toBeError("INVALID_INPUT"); expect(status.text()).toBe(`Run "${runId}" not found`); }); ``` Call `get_job_status` with the `runId` from the Call tab to see it for yourself. The run is there, and a caller who signed in could read it. ### [Running Jobs in the Background](https://frontmcp.dev/learn/running-jobs-in-the-background) Learn what states a run goes through, how to retry a flaky job and what gets retried, who can read a run and how to get results to anonymous callers anyway, and how to keep runs in Redis. ## Chaining jobs into workflows A workflow chains jobs. Each step names a job, the steps it depends on, and its input, which can be built from the outputs of earlier steps. FrontMCP runs the steps in order, and steps that don't depend on each other at the same time: Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "nightly-ticket-report", input: { priority: "high" } } } ``` ```ts main.ts active import { App, FrontMcp, Job, JobContext, Workflow, z } from "@frontmcp/sdk"; @Job({ name: "find-sla-breaches", inputSchema: { priority: z.enum(["high", "normal"]) }, outputSchema: { breaches: z.array(z.string()) }, }) class FindSlaBreaches extends JobContext { async execute({ priority }: { priority: "high" | "normal" }) { return { breaches: priority === "high" ? ["T-1"] : ["T-2"] }; } } @Job({ name: "write-report", inputSchema: { breaches: z.array(z.string()) }, outputSchema: { report: z.string() }, }) class WriteReport extends JobContext { async execute({ breaches }: { breaches: string[] }) { return { report: `${breaches.length} tickets broke their SLA: ${breaches.join(", ")}.` }; } } @Workflow({ name: "nightly-ticket-report", steps: [ { id: "breaches", jobName: "find-sla-breaches" }, { id: "report", jobName: "write-report", dependsOn: ["breaches"], input: (steps) => ({ breaches: steps.get("breaches").outputs.breaches }), }, ], }) class NightlyTicketReport {} @App({ id: "help-desk", name: "Help Desk", jobs: [FindSlaBreaches, WriteReport], workflows: [NightlyTicketReport] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts workflow.test.ts import { test, expect } from "@frontmcp/testing"; test("each step's output is in the result", async ({ mcp }) => { const run = (await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: { priority: "high" } })).json(); expect(run).toMatchObject({ state: "completed", result: { stepResults: { breaches: { state: "completed", outputs: { breaches: ["T-1"] } }, report: { state: "completed", outputs: { report: "1 tickets broke their SLA: T-1." } }, }, }, }); }); ``` The first step has no `input` of its own, so it gets the workflow's, `{ priority: "high" }`. The second waits for it, and builds its input from its output. ### [Chaining Jobs into Workflows](https://frontmcp.dev/learn/chaining-jobs-into-workflows) Learn how to pass data between steps, which steps run at the same time, what a failing step does to the others, how to make a step optional, and how to read a workflow that ran in the background. ## Tool, job or workflow? | If the work… | Write | | --- | --- | | Answers the model right away, like a search or closing a ticket | A tool | | Takes a while, should be retried when it fails, or should keep going after the call | A job | | Is several jobs, where some need what others produce | A workflow of jobs | ## What's next? Start the chapter with [Your First Job](https://frontmcp.dev/learn/your-first-job). To see all three lessons in one server, read the [Nightly Ticket Report](https://frontmcp.dev/examples/nightly-ticket-report) example, and look up every option in the [`@Job`](https://frontmcp.dev/reference/sdk/job) and [`@Workflow`](https://frontmcp.dev/reference/sdk/workflow) references. After this chapter, [Delegating to Agents](https://frontmcp.dev/learn/delegating-to-agents) covers handing a whole task to a model of your own, with its own tools and instructions. --- # Your First Job > How to write a FrontMCP job with @Job and JobContext, register it on an app, run it with the execute_job tool, and read the result, logs and errors that come back. Source: https://frontmcp.dev/learn/your-first-job [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Every tool so far has done its work inside one call and answered. A **job** is work you hand to FrontMCP instead: a named piece of work with an input schema and an output schema, which FrontMCP runs when a client asks, records as a *run*, and can retry, run in the background, or chain with other jobs. This lesson writes one, runs it the way a client would, and looks at everything that comes back, including what happens when the input is wrong or the job fails. **You will learn** - How to write a job with `@Job` and `JobContext`, and register it on an app - Which tools FrontMCP adds for jobs, and how a client runs one with `execute_job` - What a run's result holds: `runId`, `state`, `result` and `logs` - What the caller gets back when the input is wrong or the job throws - When to write a job, and when a tool is the better choice ## Writing a job The help desk promises to answer high-priority tickets within 4 hours and normal ones within a day. Finding the open tickets that broke that promise reads every open ticket, it should run every night whether or not anyone is chatting with a model, and later in this chapter it becomes one step of a nightly report. That's work for a job. Open the **Tests** tab: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "find-sla-breaches", input: { priority: "high" } } } ``` ```ts find-sla-breaches.job.ts active import { Job, JobContext, z } from "@frontmcp/sdk"; import { tickets } from "./store"; const SLA_HOURS = { high: 4, normal: 24 }; @Job({ name: "find-sla-breaches", description: "Find the open tickets of one priority that have waited longer than their SLA allows.", inputSchema: { priority: z.enum(["high", "normal"]).describe("Which tickets to check"), }, outputSchema: { priority: z.enum(["high", "normal"]), breaches: z.array(z.object({ id: z.string(), hoursOpen: z.number() })), }, }) export class FindSlaBreaches extends JobContext { async execute({ priority }: { priority: "high" | "normal" }) { const open = tickets.filter((t) => t.status === "open" && t.priority === priority); this.log(`Checking ${open.length} open ${priority}-priority tickets`); const breaches = open .filter((t) => t.hoursOpen > SLA_HOURS[priority]) .map((t) => ({ id: t.id, hoursOpen: t.hoursOpen })); this.log(`Found ${breaches.length} past the ${SLA_HOURS[priority]}-hour SLA`); return { priority, breaches }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { FindSlaBreaches } from "./find-sla-breaches.job"; @App({ id: "help-desk", name: "Help Desk", jobs: [FindSlaBreaches] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts store.ts export const tickets = [ { id: "T-1", title: "Cannot log in", priority: "high", status: "open", hoursOpen: 6 }, { id: "T-2", title: "Invoice total is wrong", priority: "normal", status: "open", hoursOpen: 30 }, { id: "T-3", title: "Login link expired", priority: "high", status: "open", hoursOpen: 2 }, { id: "T-4", title: "Refund not received", priority: "normal", status: "closed", hoursOpen: 50 }, ]; ``` ```ts sla.test.ts import { test, expect } from "@frontmcp/testing"; test("finds the high-priority ticket past its SLA", async ({ mcp }) => { const run = await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "high" } }); expect(run).toBeSuccessful(); expect(run.json()).toMatchObject({ runId: expect.any(String), state: "completed", result: { priority: "high", breaches: [{ id: "T-1", hoursOpen: 6 }] }, }); }); test("each log line starts with the time it was written", async ({ mcp }) => { const run = (await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "normal" } })).json(); expect(run.logs).toEqual([ expect.stringMatching(/^\[\d{4}-\d\d-\d\dT[\d:.]+Z\] Checking 1 open normal-priority tickets$/), expect.stringMatching(/\] Found 1 past the 24-hour SLA$/), ]); }); test("every run gets its own id", async ({ mcp }) => { const first = (await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "high" } })).json(); const second = (await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "high" } })).json(); expect(second.runId).not.toBe(first.runId); }); ``` A job looks a lot like a tool: - **`@Job({ name, description, inputSchema, outputSchema })`** describes it. `name` is what a client asks for. `inputSchema` and `outputSchema` are Zod shapes, as on a tool, and both are required: a job without an `outputSchema` fails as soon as its class is decorated. - **`JobContext`** is the base class, and **`execute(input)`** does the work. It gets the input already checked against `inputSchema`, and what it returns is the job's result. - **`this.log(message)`** adds a line to the run's logs, with the time in front. - **`jobs: [FindSlaBreaches]`** on `@App` registers it. That's all it takes: FrontMCP turns jobs on for any server with an app that has jobs or workflows. ## How a client runs a job The job isn't a tool, and it doesn't show up in `tools/list`. Open the **Capabilities** tab of the example above: registering one job added four tools of FrontMCP's own, and the call in the Call tab went through the first of them. | Tool | What it does | | --- | --- | | `execute_job` | Runs the job called `name` with `input`, and returns the run. `background: true` starts it and returns at once, which the [next lesson](https://frontmcp.dev/learn/running-jobs-in-the-background) covers. | | `get_job_status` | Reads a run by its `runId`. | | `execute_workflow` | Runs a workflow, a chain of jobs. See [Chaining Jobs into Workflows](https://frontmcp.dev/learn/chaining-jobs-into-workflows). | | `get_workflow_status` | Reads a workflow's run. | All four appear as soon as one app has a job, even if there are no workflows. FrontMCP also adds `list_jobs`, `list_workflows`, `remove_job` and `remove_workflow`, but leaves them out of `tools/list`. They still answer when a client calls them by name. That leaves a model with a problem. It sees `execute_job`, described as "Execute a registered job by name", and nothing that says which names exist or what input they take. Tell it, in the server's [`instructions`](https://frontmcp.dev/reference/sdk/frontmcp#giving-the-model-instructions): Initial call configuration: ```js { tool: "execute_job", arguments: { name: "find-sla-breaches", input: { priority: "high" } } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { FindSlaBreaches } from "./find-sla-breaches.job"; @App({ id: "help-desk", name: "Help Desk", jobs: [FindSlaBreaches] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], instructions: "Help desk jobs, run with execute_job. " + 'find-sla-breaches, input { priority: "high" | "normal" }: lists open tickets that have waited longer than their SLA.', }) export default class Server {} ``` ```ts find-sla-breaches.job.ts import { Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "find-sla-breaches", description: "Find the open tickets of one priority that have waited longer than their SLA allows.", inputSchema: { priority: z.enum(["high", "normal"]).describe("Which tickets to check") }, outputSchema: { priority: z.enum(["high", "normal"]), breaches: z.array(z.object({ id: z.string(), hoursOpen: z.number() })) }, }) export class FindSlaBreaches extends JobContext { async execute({ priority }: { priority: "high" | "normal" }) { return { priority, breaches: priority === "high" ? [{ id: "T-1", hoursOpen: 6 }] : [] }; } } ``` ```ts discovery.test.ts import { test, expect } from "@frontmcp/testing"; test("tools/list has FrontMCP's four job tools, not the job", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t) => t.name).sort(); expect(names).toEqual(["execute_job", "execute_workflow", "get_job_status", "get_workflow_status"]); }); test("the instructions name the job and its input", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" }); expect(response.result.instructions).toContain('find-sla-breaches, input { priority: "high" | "normal" }'); }); test("list_jobs isn't listed, but answers when called", async ({ mcp }) => { const result = await mcp.tools.call("list_jobs", {}); expect(result.json()).toMatchObject({ count: 1, jobs: [{ name: "find-sla-breaches" }] }); }); ``` Clients read `instructions` from `server/discover` and usually add them to the model's context. `list_jobs` returns each job's name, description and input schema, but a model only calls a tool it has heard of, so don't rely on it. From here on, examples that don't need a server of their own leave out `main.ts`. When an example exports jobs but no app, the Playground registers them in one for you, as it does tools. ## What comes back `execute_job` runs the job and answers when it's finished, with the run as `structuredContent` and as JSON text: ```json { "runId": "5b0e3c1e-8a47-4c1e-9d0a-1f6c2e7b9a30", "state": "completed", "result": { "priority": "high", "breaches": [{ "id": "T-1", "hoursOpen": 6 }] }, "logs": [ "[2026-09-27T09:00:00.123Z] Checking 2 open high-priority tickets", "[2026-09-27T09:00:00.124Z] Found 1 past the 4-hour SLA" ] } ``` - **`runId`** names this run, and it's new every time. It's what `get_job_status` takes. - **`state`** is `"completed"`. A run that fails doesn't come back as a run at all, but as an error, [below](https://frontmcp.dev/learn/your-first-job#when-a-job-fails). - **`result`** is what `execute()` returned, checked against `outputSchema`. - **`logs`** are the `this.log()` lines, each starting with the time it was written in ISO format. > **Pitfall: Outside data has to match the output schema** > > `@Job`'s types make TypeScript check that `execute()` returns what `outputSchema` describes, and FrontMCP checks the result again when the job runs. That matters when the data comes from somewhere TypeScript can't see, like a response from another service: > > Initial call configuration: > > ```js > { tool: "execute_job", arguments: { name: "find-sla-breaches", input: { priority: "high" } } } > ``` > > ```ts find-sla-breaches.job.ts active > import { Job, JobContext, z } from "@frontmcp/sdk"; > > // Stands in for a reporting service. It renamed two fields in its last release, but > // only for high-priority tickets, and it sends each customer's email address along. > async function fetchBreaches(priority: string): Promise { > if (priority === "high") return [{ ticket: "T-1", hours: 6 }]; > return [{ id: "T-2", hoursOpen: 30, customerEmail: "ap@globex.example" }]; > } > > @Job({ > name: "find-sla-breaches", > inputSchema: { priority: z.enum(["high", "normal"]) }, > outputSchema: { priority: z.enum(["high", "normal"]), breaches: z.array(z.object({ id: z.string(), hoursOpen: z.number() })) }, > }) > export class FindSlaBreaches extends JobContext { > async execute({ priority }: { priority: "high" | "normal" }) { > return { priority, breaches: await fetchBreaches(priority) }; // 🚩 not always what outputSchema says > } > } > ``` > > ```ts output.test.ts > import { test, expect } from "@frontmcp/testing"; > > test("a result that doesn't match outputSchema fails the call", async ({ mcp }) => { > const result = await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "high" } }); > expect(result).toBeError("INVALID_OUTPUT"); > expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at breaches.0.id)"); > }); > > test("a field outputSchema doesn't declare is dropped", async ({ mcp }) => { > const run = (await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "normal" } })).json(); > expect(run.result.breaches).toEqual([{ id: "T-2", hoursOpen: 30 }]); > }); > ``` > > A result that doesn't match fails the call with `INVALID_OUTPUT`, and the message names the first field that's wrong. That tells the model the job broke, but not what it could do about it. Fields that `outputSchema` doesn't declare are dropped, so the customer's email never reaches the client. Parse outside data yourself, for example with the same Zod schema, and fail with a [`PublicMcpError`](https://frontmcp.dev/learn/your-first-job#when-a-job-fails) that says what went wrong. (Changed in 1.9.2: before, FrontMCP didn't check the result at run time, and sent it as it was.) ## When the input is wrong `execute_job`'s own input schema takes any object as `input`. FrontMCP checks it against the job's `inputSchema` when it runs the job, and a mismatch fails the call. Here the model asked for a priority that doesn't exist, and a job name that doesn't either: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "find-sla-breaches", input: { priority: "urgent" } } } ``` ```ts find-sla-breaches.job.ts import { Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "find-sla-breaches", inputSchema: { priority: z.enum(["high", "normal"]).describe("Which tickets to check") }, outputSchema: { priority: z.enum(["high", "normal"]), breaches: z.array(z.object({ id: z.string(), hoursOpen: z.number() })) }, }) export class FindSlaBreaches extends JobContext { async execute({ priority }: { priority: "high" | "normal" }) { return { priority, breaches: [] }; } } ``` ```ts input.test.ts active import { test, expect } from "@frontmcp/testing"; test("input that doesn't match the job's schema fails the call", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "urgent" } }); expect(result).toBeError("TOOL_EXECUTION_ERROR"); expect(result.text()).toMatch(/^Tool "execute_job" execution failed: \[/); expect(result.text()).toContain('"path": [\n "priority"\n ]'); }); test("leaving out `input` is the same as sending {}", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "find-sla-breaches" }); expect(result).toBeError("TOOL_EXECUTION_ERROR"); expect(result.text()).toContain("expected one of"); }); test("an unknown job name is refused", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "find-breaches", input: { priority: "high" } }); expect(result).toBeError("JOB_NOT_AUTHORIZED"); expect(result.text()).toBe('Job or workflow "find-breaches" not found or not permitted'); }); ``` A tool with the same schema would refuse `"urgent"` with `INVALID_INPUT` and Zod's message. A job's input is checked later, inside `execute_job`, so the call fails with `TOOL_EXECUTION_ERROR`: `Tool "execute_job" execution failed:` and then Zod's list of problems. That's the development message, which is what the Playground shows. In production, FrontMCP hides the message of an error like this, and the model only gets `Internal FrontMCP error. Please contact support with error ID: …`, with nothing to say what to fix. An unknown name gets `JOB_NOT_AUTHORIZED`: `Job or workflow "find-breaches" not found or not permitted`. A job the caller isn't allowed to run, because of the job's [`permissions`](https://frontmcp.dev/reference/sdk/job#restricting-who-can-run-a-job), gets exactly the same answer, so a caller can't find out which jobs exist by guessing. This message is kept in production. So the model has to get the name and the input right the first time. Say both in `instructions`, as in the [previous section](https://frontmcp.dev/learn/your-first-job#how-a-client-runs-a-job), with the allowed values spelled out. ## When a job fails A job that throws fails the call. What the caller reads depends on what you throw, just as it does for a [tool](https://frontmcp.dev/learn/calling-other-services). This job copies a customer's plan from the CRM: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "sync-customer", input: { customerId: "C-404" } } } ``` ```ts sync-customer.job.ts active import { Job, JobContext, PublicMcpError, z } from "@frontmcp/sdk"; import { crm } from "./crm"; @Job({ name: "sync-customer", description: "Copy a customer's plan from the CRM into the help desk.", inputSchema: { customerId: z.string().describe("Customer id, like C-1") }, outputSchema: { customerId: z.string(), plan: z.string() }, }) export class SyncCustomer extends JobContext { async execute({ customerId }: { customerId: string }) { const customer = await crm.find(customerId); if (!customer) { // ✅ A message the model can act on, kept in production throw new PublicMcpError(`There's no customer ${customerId} in the CRM. Check the id with the user.`); } return { customerId, plan: customer.plan }; } } ``` ```ts crm.ts // Stands in for the CRM's client. C-500 makes it fail the way a service does when it's down. export const crm = { async find(id: string) { if (id === "C-500") throw new Error("connect ECONNREFUSED 10.0.4.7:5432"); return id === "C-1" ? { id, plan: "business" } : undefined; }, }; ``` ```ts failures.test.ts import { test, expect } from "@frontmcp/testing"; test("a PublicMcpError reaches the caller word for word", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-404" } }); expect(result).toBeError("PUBLIC_ERROR"); expect(result.text()).toBe("There's no customer C-404 in the CRM. Check the id with the user."); }); test("any other error is a TOOL_EXECUTION_ERROR", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-500" } }); expect(result).toBeError("TOOL_EXECUTION_ERROR"); expect(result.text()).toMatch(/^Tool "execute_job" execution failed: connect ECONNREFUSED/); }); test("a customer the CRM knows is synced", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-1" } }); expect(result.json().result).toEqual({ customerId: "C-1", plan: "business" }); }); ``` - **`PublicMcpError`**, thrown or passed to `this.fail()`, fails the call with your message, in production too, and code `PUBLIC_ERROR` unless you pass it a code of your own. Use it when the model can do something about the failure, like asking the user for the right id. - **Any other error** fails it with `TOOL_EXECUTION_ERROR`. In development the text is `Tool "execute_job" execution failed:` with the error's message and stack; in production it's `Internal FrontMCP error. Please contact support with error ID: …`, which keeps the address of your database out of the model's context. Either way, the caller gets an error, not a run with `state: "failed"`. Failed runs are recorded too, and the [next lesson](https://frontmcp.dev/learn/running-jobs-in-the-background#what-a-failed-run-looks-like) shows how to read one. ## A job as a function A job with no state of its own can be a function. `job({...})` takes the same options as `@Job` and returns a function that takes the handler; register what it returns, as you would a class: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "format-ticket-id", input: { number: 42 } } } ``` ```ts main.ts active import { App, FrontMcp, job, z } from "@frontmcp/sdk"; const FormatTicketId = job({ name: "format-ticket-id", description: "Turn a ticket number into the id customers see, like T-0042.", inputSchema: { number: z.number().int().positive() }, outputSchema: { id: z.string() }, })(({ number }, ctx) => { const id = `T-${String(number).padStart(4, "0")}`; ctx.log(`Formatted ${number} as ${id}`); return { id }; }); @App({ id: "help-desk", name: "Help Desk", jobs: [FormatTicketId] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts format.test.ts import { test, expect } from "@frontmcp/testing"; test("a function-style job runs like a class", async ({ mcp }) => { const run = (await mcp.tools.call("execute_job", { name: "format-ticket-id", input: { number: 42 } })).json(); expect(run).toMatchObject({ state: "completed", result: { id: "T-0042" } }); expect(run.logs).toEqual([expect.stringMatching(/\] Formatted 42 as T-0042$/)]); }); ``` The handler gets the job's context as its second argument, so `ctx.log()` writes to the run's logs, as `this.log()` does in a class. (Changed in 1.9.1: before, `log()` was `protected`, so `ctx.log()` didn't compile.) ## Jobs or tools? A job can do anything a tool can, so which should you write? | | Tool | Job | | --- | --- | --- | | How the model finds it | In `tools/list`, with its own description and schema | By a name you have to tell it, through `execute_job` | | When it runs | When called, and the answer comes back in the same call | When `execute_job` is called, in the call or [in the background](https://frontmcp.dev/learn/running-jobs-in-the-background) | | If it fails | The call fails | The call fails, or FrontMCP [retries](https://frontmcp.dev/learn/running-jobs-in-the-background#retrying-a-flaky-job) it first | | Afterwards | Nothing is kept | A run with an id, a state and logs | | In a workflow | No | Yes, as a [step](https://frontmcp.dev/learn/chaining-jobs-into-workflows) | If the model should call it and use the answer right away, like searching tickets or closing one, write a tool: it gets a proper description and schema in `tools/list`, and errors the model can read. Write a job for work that takes a while, that should be retried when it fails, that should keep going after the call returns, or that is one step of something bigger. A nightly report is all four. ## Recap - A job is a class that extends `JobContext`, decorated with `@Job({ name, inputSchema, outputSchema })`, or a function made with `job({...})`. Register it with `@App({ jobs: [...] })`. - Jobs aren't in `tools/list`. FrontMCP adds `execute_job`, `get_job_status`, `execute_workflow` and `get_workflow_status`, and the model has to be told which job names exist, for example in `instructions`. - `execute_job { name, input }` runs the job and returns `{ runId, state: "completed", result, logs }`. `this.log()` lines start with the time. - A result that doesn't match `outputSchema` fails with `INVALID_OUTPUT`, and fields it doesn't declare are dropped. Input that doesn't match `inputSchema` fails with `TOOL_EXECUTION_ERROR`, whose message production hides. An unknown name is `JOB_NOT_AUTHORIZED`. - Throw `PublicMcpError` for failures the model can act on. Anything else becomes `Internal FrontMCP error` in production. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Register the job `count-open-tickets` is written, but `execute_job` can't find it, and it doesn't log anything yet. Register it, and make it log how many open tickets it counted, like `Counted 2 open tickets for Acme`. ```ts main.ts active import { App, FrontMcp, Job, JobContext, Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./store"; @Job({ name: "count-open-tickets", description: "Count a customer's open tickets.", inputSchema: { customer: z.string().describe("Customer name, like Acme") }, outputSchema: { customer: z.string(), open: z.number() }, }) class CountOpenTickets extends JobContext { async execute({ customer }: { customer: string }) { const open = tickets.filter((t) => t.customer === customer && t.status === "open").length; return { customer, open }; } } @Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` **Solution file (alternative to the starter):** ```ts main.ts solution import { App, FrontMcp, Job, JobContext, Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./store"; @Job({ name: "count-open-tickets", description: "Count a customer's open tickets.", inputSchema: { customer: z.string().describe("Customer name, like Acme") }, outputSchema: { customer: z.string(), open: z.number() }, }) class CountOpenTickets extends JobContext { async execute({ customer }: { customer: string }) { const open = tickets.filter((t) => t.customer === customer && t.status === "open").length; this.log(`Counted ${open} open tickets for ${customer}`); return { customer, open }; } } @Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets], jobs: [CountOpenTickets] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts store.ts export const tickets = [ { id: "T-1", title: "Cannot log in", customer: "Acme", status: "open" }, { id: "T-2", title: "Invoice total is wrong", customer: "Globex", status: "open" }, { id: "T-3", title: "Login link expired", customer: "Acme", status: "open" }, { id: "T-4", title: "Refund not received", customer: "Acme", status: "closed" }, ]; ``` **Hidden fixture/test file (runs with the example):** ```ts count.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`execute_job` is listed", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("execute_job"); }); test("`count-open-tickets` counts Acme's open tickets", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Acme" } }); expect(result).toBeSuccessful(); expect(result.json()).toMatchObject({ state: "completed", result: { customer: "Acme", open: 2 } }); }); test("it logs the count", async ({ mcp }) => { const run = (await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Globex" } })).json(); expect(run.logs).toEqual([expect.stringMatching(/\] Counted 1 open tickets for Globex$/)]); }); ``` **Hint:** `@App` has a list for jobs next to `tools`. Inside `execute()`, a job writes to its logs with a method it inherits from `JobContext`. **Solution:** `jobs: [CountOpenTickets]` on `@App` registers the job, and with it FrontMCP adds `execute_job` and the other job tools. `this.log()` adds a line to the run's `logs`, and FrontMCP puts the time in front of it. ### Challenge: Say which customer is missing `sync-customer` throws a plain `Error` when the CRM doesn't know the customer. In production, the model would only read "Internal FrontMCP error". Make the call fail with a message the model can act on, one that names the customer id, in production too. Initial call configuration: ```js { tool: "execute_job", arguments: { name: "sync-customer", input: { customerId: "C-1" } } } ``` ```ts sync-customer.job.ts active import { Job, JobContext, z } from "@frontmcp/sdk"; const crm = new Map([["C-1", { plan: "business" }]]); @Job({ name: "sync-customer", description: "Copy a customer's plan from the CRM into the help desk.", inputSchema: { customerId: z.string() }, outputSchema: { customerId: z.string(), plan: z.string() }, }) export class SyncCustomer extends JobContext { async execute({ customerId }: { customerId: string }) { const customer = crm.get(customerId); if (!customer) throw new Error(`customer lookup returned null for ${customerId}`); return { customerId, plan: customer.plan }; } } ``` **Solution file (alternative to the starter):** ```ts sync-customer.job.ts solution import { Job, JobContext, PublicMcpError, z } from "@frontmcp/sdk"; const crm = new Map([["C-1", { plan: "business" }]]); @Job({ name: "sync-customer", description: "Copy a customer's plan from the CRM into the help desk.", inputSchema: { customerId: z.string() }, outputSchema: { customerId: z.string(), plan: z.string() }, }) export class SyncCustomer extends JobContext { async execute({ customerId }: { customerId: string }) { const customer = crm.get(customerId); if (!customer) throw new PublicMcpError(`There's no customer ${customerId} in the CRM. Check the id with the user.`); return { customerId, plan: customer.plan }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts sync.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("an unknown customer fails with `PUBLIC_ERROR`", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-9" } }); expect(result).toBeError("PUBLIC_ERROR"); }); test("the message names the customer", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-9" } }); expect(result.text()).toContain("C-9"); expect(result.text()).not.toContain("execution failed"); }); test("a known customer still syncs", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-1" } }); expect(result.json().result).toEqual({ customerId: "C-1", plan: "business" }); }); ``` **Hint:** FrontMCP keeps the message of one kind of error in production. It's the same one tools use. **Solution:** A `PublicMcpError` fails `execute_job` with code `PUBLIC_ERROR` and exactly your message, in development and production alike. Any other error becomes `TOOL_EXECUTION_ERROR`, whose message production replaces with "Internal FrontMCP error". The new message also tells the model what to do next: check the id with the user. ### Challenge: Tell the model which jobs exist This server has two jobs, but a model connected to it only sees `execute_job` and three other tools, with nothing that names the jobs. Add `instructions` that say to run jobs with `execute_job`, and name both jobs with the input each one takes. ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { CountOpenTickets, FindSlaBreaches } from "./jobs"; @App({ id: "help-desk", name: "Help Desk", jobs: [FindSlaBreaches, CountOpenTickets] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` **Solution file (alternative to the starter):** ```ts main.ts solution import { App, FrontMcp } from "@frontmcp/sdk"; import { CountOpenTickets, FindSlaBreaches } from "./jobs"; @App({ id: "help-desk", name: "Help Desk", jobs: [FindSlaBreaches, CountOpenTickets] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], instructions: "Help desk jobs, run with execute_job. " + 'find-sla-breaches, input { priority: "high" | "normal" }: lists open tickets past their SLA. ' + "count-open-tickets, input { customer: string }: counts a customer's open tickets.", }) export default class Server {} ``` ```ts jobs.ts import { Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "find-sla-breaches", inputSchema: { priority: z.enum(["high", "normal"]) }, outputSchema: { breaches: z.array(z.string()) }, }) export class FindSlaBreaches extends JobContext { async execute({ priority }: { priority: "high" | "normal" }) { return { breaches: priority === "high" ? ["T-1"] : [] }; } } @Job({ name: "count-open-tickets", inputSchema: { customer: z.string() }, outputSchema: { open: z.number() }, }) export class CountOpenTickets extends JobContext { async execute({ customer }: { customer: string }) { return { open: customer === "Acme" ? 2 : 0 }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts instructions.test.ts hidden import { test, expect } from "@frontmcp/testing"; async function instructions(mcp: any): Promise { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" }); return response.result.instructions ?? ""; } test("the instructions say to use `execute_job`", async ({ mcp }) => { expect(await instructions(mcp)).toContain("execute_job"); }); test("they name `find-sla-breaches` and its `priority`", async ({ mcp }) => { const text = await instructions(mcp); expect(text).toContain("find-sla-breaches"); expect(text).toContain("priority"); }); test("they name `count-open-tickets` and its `customer`", async ({ mcp }) => { const text = await instructions(mcp); expect(text).toContain("count-open-tickets"); expect(text).toContain("customer"); }); ``` **Hint:** `@FrontMcp` has an option for text that describes the server as a whole. Clients read it from `server/discover`. **Solution:** `instructions` is sent with `server/discover`, and clients usually add it to the model's context. It's the one place a model reads about jobs before it calls anything: `tools/list` only shows FrontMCP's generic job tools, and `list_jobs` is hidden from it. Spelling out the allowed values, like `"high" | "normal"`, matters because a job's input errors are hidden in production. --- # Running Jobs in the Background > How to start a FrontMCP job in the background and check on it with get_job_status, what states a run goes through, how to retry a flaky job with backoff, who may read a run, and where runs are stored. Source: https://frontmcp.dev/learn/running-jobs-in-the-background [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Run inline, a job holds its call open until it's done, and a model waits the whole time. With `background: true`, FrontMCP's `execute_job` tool answers at once with a run id, the job keeps working on the server, and `get_job_status` reports on it later. This lesson starts jobs that way, retries the ones that fail now and then, and shows who can read a run afterwards. Under MCP 2026-07-28, that rules out anonymous callers, so the lesson also shows how to get results to them another way. **You will learn** - How to start a job in the background, and read its run with `get_job_status` - What states a run goes through, and what a failed run looks like - How to retry a flaky job with backoff, and what gets retried - Who can read a run, and how to hand results to anonymous callers - Where runs are stored, and how to keep them in Redis > **Note** > > Jobs aren't MCP tasks. A job is FrontMCP's own unit of work, which clients start with the `execute_job` tool and read with `get_job_status`. An MCP task is a protocol feature: a client asks for an ordinary tool call to run as a task, and checks back on it later. FrontMCP supports both; tasks are in [Background tasks](https://frontmcp.dev/reference/server/tasks). ## A job that takes a while Exporting the ticket database reads it a page at a time, and each page takes a fifth of a second. Run inline, the call takes as long as the export. The call in this example starts it in the background instead. Open the **Tests** tab: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "export-tickets", background: true } } ``` ```ts export-tickets.job.ts active import { Job, JobContext, z } from "@frontmcp/sdk"; import { readPage } from "./database"; @Job({ name: "export-tickets", description: "Export every support ticket as CSV. Reads the database a page at a time, so it takes a while.", inputSchema: {}, outputSchema: { rows: z.number(), csv: z.string() }, }) export class ExportTickets extends JobContext { async execute() { const rows: string[] = []; for (let page = 1; page <= 5; page++) { rows.push(...(await readPage(page))); this.log(`Read page ${page} of 5`); } return { rows: rows.length, csv: ["id,title,status", ...rows].join("\n") }; } } ``` ```ts database.ts // Stands in for a slow database: 5 pages, a fifth of a second each. export async function readPage(page: number) { await new Promise((resolve) => setTimeout(resolve, 200)); return [`T-${page},Ticket ${page},open`]; } ``` ```ts background.test.ts import { test, expect } from "@frontmcp/testing"; test("inline, the call waits for the whole export", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "export-tickets" }); expect(result.json()).toMatchObject({ state: "completed", result: { rows: 5 } }); expect(result.durationMs).toBeGreaterThanOrEqual(1_000); }); test("in the background, the call answers at once with a run id", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "export-tickets", background: true }); expect(result.json()).toEqual({ runId: expect.any(String), state: "pending" }); expect(result.durationMs).toBeLessThan(200); }); ``` With `background: true`, `execute_job` returns `{ runId, state: "pending" }` and nothing else: no result, no logs. The job carries on after the call has returned. A client reads the run later by passing that `runId` to `get_job_status`. ## Who can read a run Copy the `runId` from the Call tab, and call `get_job_status` with it. It fails with `Run "…" not found`, although the run is right there. Open the **Tests** tab: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "export-tickets", background: true } } ``` ```ts status.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; const asNour = { authContext: { user: { sub: "nour" } } }; const asSam = { authContext: { user: { sub: "sam" } } }; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("an anonymous caller can't read the run it started", async ({ mcp }) => { const { runId } = (await mcp.tools.call("execute_job", { name: "export-tickets", background: true })).json(); const status = await mcp.tools.call("get_job_status", { runId }); expect(status).toBeError("INVALID_INPUT"); expect(status.text()).toBe(`Run "${runId}" not found`); }); test("a signed-in caller can read their own run, and nobody else can", async () => { // createDirect() calls the server in-process, as the user you name. const server = await FrontMcpInstance.createDirect(config); const started = await server.callTool("execute_job", { name: "export-tickets", background: true }, asNour); const { runId } = started.structuredContent as { runId: string }; const mine = await server.callTool("get_job_status", { runId }, asNour); await expect(server.callTool("get_job_status", { runId }, asSam)).rejects.toThrow(`Run "${runId}" not found`); await wait(1_200); await server.dispose(); expect(mine.structuredContent).toMatchObject({ runId, jobName: "export-tickets", state: "running" }); }); ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { ExportTickets } from "./export-tickets.job"; @App({ id: "help-desk", name: "Help Desk", jobs: [ExportTickets] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }; @FrontMcp(config) export default class Server {} ``` ```ts export-tickets.job.ts import { Job, JobContext, z } from "@frontmcp/sdk"; const readPage = async (page: number) => { await new Promise((resolve) => setTimeout(resolve, 200)); return [`T-${page},Ticket ${page},open`]; }; @Job({ name: "export-tickets", description: "Export every support ticket as CSV.", inputSchema: {}, outputSchema: { rows: z.number(), csv: z.string() }, }) export class ExportTickets extends JobContext { async execute() { const rows: string[] = []; for (let page = 1; page <= 5; page++) { rows.push(...(await readPage(page))); this.log(`Read page ${page} of 5`); } return { rows: rows.length, csv: ["id,title,status", ...rows].join("\n") }; } } ``` A run holds the job's input and result, which can be anybody's data, so FrontMCP only lets the caller who started a run read it. It records the caller's `sub` with the run, and `get_job_status` answers only a caller with the same `sub`. Anyone else gets `Run "…" not found` with code `INVALID_INPUT`, exactly what they'd get for a run that doesn't exist, so a run id that leaks tells a stranger nothing. That's where anonymous callers lose out. Under MCP 2026-07-28 [each anonymous request is a new caller](https://frontmcp.dev/learn/authenticating-clients#a-server-anyone-can-call), with a new `anon:` id, so the request that asks for the status is never the one that started the run. The Playground's calls are anonymous, which is why yours failed. The second test calls the server in-process with [`createDirect()`](https://frontmcp.dev/learn/running-frontmcp-anywhere#calling-your-tools-without-a-client-createdirect), as the signed-in user `nour`: she can read her run while it works, and `sam` can't. > **Pitfall: Anonymous callers can't read their runs** > > On a public server under 2026-07-28, `get_job_status` and `get_workflow_status` never find a run for an anonymous caller. The job still runs, and anything it changes still changes; the caller just can't read the result. Either have callers [authenticate](https://frontmcp.dev/learn/authenticating-clients), so each has a `sub` of their own, or give the result another way out, as [further down](https://frontmcp.dev/learn/running-jobs-in-the-background#handing-results-to-anonymous-callers) shows. With `auth: { mode: "static" }`, the caller is derived from the key, so every client that shares a key can read the others' runs, and a client with another key can't. The Playground can't send a key, so that one you'd try on a real server. ## What a run looks like over time For the rest of this lesson, the tests read runs as `nour`. `get_job_status` returns the run's record: ```ts states.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; const asNour = { authContext: { user: { sub: "nour" } } }; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("a run is `running` with no logs, then `completed` with all of them", async () => { const server = await FrontMcpInstance.createDirect(config); const status = async (runId: string) => (await server.callTool("get_job_status", { runId }, asNour)).structuredContent as any; const started = await server.callTool("execute_job", { name: "export-tickets", background: true }, asNour); const { runId } = started.structuredContent as { runId: string }; await wait(500); // two pages in const during = await status(runId); await wait(800); const after = await status(runId); await server.dispose(); expect(during).toEqual({ runId, jobName: "export-tickets", state: "running", startedAt: expect.any(Number), attempt: 1, logs: [] }); expect(after).toMatchObject({ state: "completed", result: { rows: 5 }, attempt: 1, completedAt: expect.any(Number) }); expect(after.logs).toHaveLength(5); expect(after.logs[4]).toMatch(/\] Read page 5 of 5$/); }); ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { ExportTickets } from "./export-tickets.job"; @App({ id: "help-desk", name: "Help Desk", jobs: [ExportTickets] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }; @FrontMcp(config) export default class Server {} ``` ```ts export-tickets.job.ts import { Job, JobContext, z } from "@frontmcp/sdk"; const readPage = async (page: number) => { await new Promise((resolve) => setTimeout(resolve, 200)); return [`T-${page},Ticket ${page},open`]; }; @Job({ name: "export-tickets", description: "Export every support ticket as CSV.", inputSchema: {}, outputSchema: { rows: z.number(), csv: z.string() }, }) export class ExportTickets extends JobContext { async execute() { const rows: string[] = []; for (let page = 1; page <= 5; page++) { rows.push(...(await readPage(page))); this.log(`Read page ${page} of 5`); } return { rows: rows.length, csv: ["id,title,status", ...rows].join("\n") }; } } ``` | Field | Meaning | | --- | --- | | `runId`, `jobName` | Which run, of which job. | | `state` | Where the run is; see below. | | `result` | What `execute()` returned, once the run has completed. | | `error` | `{ message, name }` of the error, once the run has failed. | | `startedAt`, `completedAt` | Milliseconds since 1970. `completedAt` is set when the run completes or fails. | | `attempt` | Counts attempts, when the job [retries](https://frontmcp.dev/learn/running-jobs-in-the-background#retrying-a-flaky-job). | | `logs` | The `this.log()` lines, **once the run has completed**. Until then it's empty. | A run's `state` is one of: | `state` | When | | --- | --- | | `"pending"` | Created, not started yet. It's what `execute_job` returns for a background run. | | `"running"` | The job is working. | | `"retrying"` | An attempt failed, and the run is waiting to try again. | | `"completed"` | The job returned. `result` is set. | | `"failed"` | The last attempt threw. `error` is set. | The empty `logs` in `during` matter: a client can't watch a run's progress. FrontMCP saves the logs when the run ends, and only if it succeeds. A client learns how far a run got from `state` alone, so poll it every few seconds, not in a tight loop. ### What about this.progress()? `JobContext` has `this.progress(progress, total, message)`, as tools do. For a tool it sends a progress notification on the call's stream. For a job it looks for the caller's session, and under 2026-07-28 there isn't one, so it sends nothing and returns `false`: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "export-tickets" } } ``` ```ts main.ts active import { App, FrontMcp, Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "export-tickets", inputSchema: {}, outputSchema: { delivered: z.array(z.boolean()) } }) class ExportTickets extends JobContext { async execute() { const delivered: boolean[] = []; for (let page = 1; page <= 3; page++) { delivered.push(await this.progress(page, 3, `Read page ${page} of 3`)); } return { delivered }; } } @App({ id: "help-desk", name: "Help Desk", jobs: [ExportTickets] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts progress.test.ts import { test, expect } from "@frontmcp/testing"; test("no progress reaches the caller", async ({ mcp }) => { const progress = mcp.notifications.collectProgress(); const run = (await mcp.tools.call("execute_job", { name: "export-tickets" })).json(); expect(run.result.delivered).toEqual([false, false, false]); expect(progress.all).toEqual([]); }); ``` If a user should see how far a long export got, have the job write its progress somewhere a tool can read, the same way as [results for anonymous callers](https://frontmcp.dev/learn/running-jobs-in-the-background#handing-results-to-anonymous-callers). ## Retrying a flaky job The CRM the help desk syncs with drops requests now and then: here, the first two tries for each customer fail. `retry` tells FrontMCP to run the job again when it throws, waiting a little longer each time: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "sync-customer", input: { customerId: "C-1" } } } ``` ```ts sync-customer.job.ts active import { Job, JobContext, PublicMcpError, z } from "@frontmcp/sdk"; import { crm } from "./crm"; @Job({ name: "sync-customer", description: "Copy a customer's plan from the CRM into the help desk.", inputSchema: { customerId: z.string().describe("Customer id, like C-1") }, outputSchema: { customerId: z.string(), plan: z.string() }, retry: { maxAttempts: 3, backoffMs: 200 }, }) export class SyncCustomer extends JobContext { async execute({ customerId }: { customerId: string }) { this.log(`Attempt ${this.attempt}: asking the CRM for ${customerId}`); const customer = await crm.find(customerId); if (!customer) throw new PublicMcpError(`There's no customer ${customerId} in the CRM.`); return { customerId, plan: customer.plan }; } } ``` ```ts crm.ts // Stands in for a CRM that drops the first two requests for each customer. export const requests: Record = {}; export const crm = { async find(id: string) { requests[id] = (requests[id] ?? 0) + 1; if (requests[id] <= 2) throw new Error("CRM timed out"); return id.startsWith("C-4") ? undefined : { id, plan: "business" }; }, }; ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { SyncCustomer } from "./sync-customer.job"; @App({ id: "help-desk", name: "Help Desk", jobs: [SyncCustomer] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }; @FrontMcp(config) export default class Server {} ``` ```ts retry.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; import { requests } from "./crm"; const asNour = { authContext: { user: { sub: "nour" } } }; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("the third try works, after waiting 200 ms and then 400 ms", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-2" } }); expect(result.json()).toMatchObject({ state: "completed", result: { customerId: "C-2", plan: "business" } }); expect(requests["C-2"]).toBe(3); expect(result.durationMs).toBeGreaterThanOrEqual(600); }); test("only the last attempt's logs are kept", async ({ mcp }) => { const run = (await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-3" } })).json(); expect(run.logs).toEqual([expect.stringMatching(/\] Attempt 3: asking the CRM for C-3$/)]); }); test("between attempts, the run is `retrying`", async () => { const server = await FrontMcpInstance.createDirect(config); const started = await server.callTool("execute_job", { name: "sync-customer", input: { customerId: "C-5" }, background: true }, asNour); const { runId } = started.structuredContent as { runId: string }; await wait(100); const during = (await server.callTool("get_job_status", { runId }, asNour)).structuredContent; await wait(700); const after = (await server.callTool("get_job_status", { runId }, asNour)).structuredContent; await server.dispose(); expect(during).toMatchObject({ state: "retrying", attempt: 1 }); expect(after).toMatchObject({ state: "completed", attempt: 3 }); }); test("a mistake is retried too", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-404" } }); expect(result).toBeError("PUBLIC_ERROR"); expect(requests["C-404"]).toBe(3); }); ``` The first try fails, FrontMCP waits 200 ms, the second fails, it waits 400 ms, and the third works. Take `retry` out and run the tests again: the job runs once, and the call fails with the CRM's first timeout. `retry` takes: | Field | Default | Meaning | | --- | --- | --- | | `maxAttempts` | `3` | How many times to run the job in all, counting the first. | | `backoffMs` | `1000` | How long to wait before the first retry, in milliseconds. | | `backoffMultiplier` | `2` | What each wait is multiplied by for the next one. | | `maxBackoffMs` | `60000` | The longest FrontMCP waits between two attempts. | The defaults apply once `retry` is set, so `retry: {}` means three attempts, waiting 1 and then 2 seconds. Without `retry`, a job runs once. An inline call waits through every retry, so keep `maxAttempts` and the waits small for jobs a model runs inline, and run slow-to-retry jobs in the background. A few things about retries are easy to trip over: - **Almost everything is retried.** The last test asks for customer `C-404`, which the CRM doesn't have. Retrying can't fix that, but the job throws, so FrontMCP tries three times before the caller hears about it. Input that doesn't match `inputSchema` is retried as well. Keep `maxAttempts` low when a job can fail for reasons that won't go away. The one failure that isn't retried is a result that doesn't match [`outputSchema`](https://frontmcp.dev/learn/your-first-job#what-comes-back). - **Logs are per attempt.** A run keeps the logs of its last attempt only. - **`this.attempt` is the attempt number**, from 1, as the second test's log line shows. (Changed in 1.9.2: before, it was always `1`.) - **Hooks see every attempt.** Each attempt runs the `jobs:execute-job` flow, so a plugin's [`JobHook`](https://frontmcp.dev/reference/sdk/job#hooking-every-attempt-of-a-job) sees each retry and its error, while the caller only hears about the last one. (New in 1.9.4.) > **Pitfall: A retried job runs again from the top** > > Each attempt runs `execute()` from the beginning, so anything the job did before it failed happens again. A job that emails a customer and then fails on the next line emails them once per attempt. Before you add `retry`, make the job safe to run twice: do the part that can fail first, or check whether the work was already done. The [second challenge](https://frontmcp.dev/learn/running-jobs-in-the-background#try-some-challenges) is this bug. ## What a failed run looks like When the last attempt fails, the run's `state` becomes `"failed"`, with the error: ```ts failed.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; const asNour = { authContext: { user: { sub: "nour" } } }; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("a failed run keeps the error, and none of the logs", async () => { const server = await FrontMcpInstance.createDirect(config); const started = await server.callTool("execute_job", { name: "sync-customer", input: { customerId: "C-404" }, background: true }, asNour); const { runId } = started.structuredContent as { runId: string }; await wait(400); const status = (await server.callTool("get_job_status", { runId }, asNour)).structuredContent; await server.dispose(); expect(status).toEqual({ runId, jobName: "sync-customer", state: "failed", error: { message: "There's no customer C-404 in the CRM.", name: "PublicMcpError" }, startedAt: expect.any(Number), completedAt: expect.any(Number), attempt: 3, logs: [], }); }); ``` ```ts sync-customer.job.ts import { Job, JobContext, PublicMcpError, z } from "@frontmcp/sdk"; @Job({ name: "sync-customer", description: "Copy a customer's plan from the CRM into the help desk.", inputSchema: { customerId: z.string() }, outputSchema: { customerId: z.string(), plan: z.string() }, retry: { maxAttempts: 3, backoffMs: 50 }, }) export class SyncCustomer extends JobContext { async execute({ customerId }: { customerId: string }) { this.log(`Asking the CRM for ${customerId}`); if (customerId !== "C-1") throw new PublicMcpError(`There's no customer ${customerId} in the CRM.`); return { customerId, plan: "business" }; } } ``` ```ts main.ts import { App, FrontMcp, LogLevel } from "@frontmcp/sdk"; import { SyncCustomer } from "./sync-customer.job"; @App({ id: "help-desk", name: "Help Desk", jobs: [SyncCustomer] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], logging: { level: LogLevel.Info }, }; @FrontMcp(config) export default class Server {} ``` - **`error`** has the last attempt's `message`, as thrown, and `name`, the error's class. - **`logs`** is empty: a failed run keeps none of its logs. `this.log()` also writes each line to the server's own log, tagged `[job:sync-customer]`, so that's where to look when a run fails. It writes them at `info`. The Playground runs FrontMCP in a browser, where the level is `Warn` unless you set it, so `main.ts` sets `LogLevel.Info` and the lines show in the **Logs** tab. - **`attempt`** is the number of attempts made. (Changed in 1.9.2: before, it was one less.) ## Handing results to anonymous callers A job can share state with tools, as tools share it with each other: through a [provider](https://frontmcp.dev/learn/sharing-state-with-providers) or a module. So when callers can't read runs, the job can put its result where a tool can read it. The nightly report job saves each day's report, and `get_daily_report` returns it: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "build-daily-report", input: { day: "2026-09-26" }, background: true } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { BuildDailyReport, GetDailyReport, ReportStore } from "./reports"; @App({ id: "help-desk", name: "Help Desk", tools: [GetDailyReport], jobs: [BuildDailyReport], providers: [ReportStore], // one store, for the job and the tool }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts reports.ts import { Job, JobContext, Provider, Tool, ToolContext, z } from "@frontmcp/sdk"; @Provider({ name: "ReportStore" }) export class ReportStore { readonly reports = new Map(); } @Job({ name: "build-daily-report", description: "Count the tickets opened and closed on one day, and save the report.", inputSchema: { day: z.string().describe("The day, like 2026-09-26") }, outputSchema: { day: z.string() }, }) export class BuildDailyReport extends JobContext { async execute({ day }: { day: string }) { await new Promise((resolve) => setTimeout(resolve, 300)); // reading every ticket this.get(ReportStore).reports.set(day, { opened: 12, closed: 9 }); return { day }; } } @Tool({ name: "get_daily_report", description: "Get the ticket report for one day. The build-daily-report job makes it; if it isn't ready, try again in a minute.", inputSchema: { day: z.string().describe("The day, like 2026-09-26") }, annotations: { readOnlyHint: true }, }) export class GetDailyReport extends ToolContext { async execute({ day }: { day: string }) { const report = this.get(ReportStore).reports.get(day); return report ? { day, ready: true, ...report } : { day, ready: false }; } } ``` ```ts report.test.ts import { test, expect } from "@frontmcp/testing"; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("an anonymous caller starts the report, and reads it with the tool", async ({ mcp }) => { await mcp.tools.call("execute_job", { name: "build-daily-report", input: { day: "2026-09-25" }, background: true }); expect((await mcp.tools.call("get_daily_report", { day: "2026-09-25" })).json()).toEqual({ day: "2026-09-25", ready: false }); await wait(400); expect((await mcp.tools.call("get_daily_report", { day: "2026-09-25" })).json()).toEqual({ day: "2026-09-25", ready: true, opened: 12, closed: 9, }); }); ``` Press **Call** with `get_daily_report` and `{ "day": "2026-09-26" }` a moment after the example starts: the report is there. The job and the tool see the same `ReportStore` because it's registered once, on their app. The report is keyed by day, not by run, because a job doesn't know its run's id. Anyone can read it, too, so keep this for results that every caller may see. A job finds providers on its own app and on the server, as a tool does. (Changed in 1.9.2: before, a job only found the server's.) A provider on another app isn't there for it: the [third challenge](https://frontmcp.dev/learn/running-jobs-in-the-background#try-some-challenges) starts from that mistake. ## Where runs are kept By default FrontMCP keeps runs in memory, in the server process. That's what the Playground uses, and it's fine on one machine, with two limits: runs are lost when the server restarts, and a server behind a load balancer only knows the runs it started itself, so `get_job_status` fails whenever the request lands on another instance. For those, keep runs in Redis: ```ts main.ts @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], jobs: { enabled: true, store: { redis: { provider: "redis", host: "redis.internal", port: 6379, password: process.env.REDIS_PASSWORD }, keyPrefix: "help-desk:jobs:", // the default is "mcp:jobs:" }, }, }) export default class Server {} ``` The Playground can't connect to Redis, so this part is for a real server. It was checked on one, with FrontMCP 1.9.4, against a Redis that needs a password. What FrontMCP does with it: - **`enabled: true` is required** once you write a `jobs` option. `jobs: { enabled: false }` turns jobs off: FrontMCP adds none of the job tools, even with jobs registered. - **It needs the `ioredis` package.** If FrontMCP can't load it, it logs `Failed to create Redis job state store, falling back to memory` and carries on in memory. - **It connects the way the server's [`redis`](https://frontmcp.dev/reference/deployment/redis#options) option does**: with `host`, `port`, `password`, `db` and `tls`, or with a `url` such as `redis://:@redis.internal:6379`. Beside a `url`, those fields only fill in what it leaves out, and one that contradicts it stops the server at startup. So does a missing password (`NOAUTH Authentication required.`) or a wrong one (`WRONGPASS invalid username-password pair`). (Changed in 1.9.3: before, only `host` and `port` reached Redis, so a Redis that needs a password or TLS couldn't hold runs.) - **Runs in Redis expire after 24 hours.** Runs in memory stay until the server restarts. The sets FrontMCP keeps beside the runs, `idx:jobId:` and `idx:sessionId:`, never expire, and keep every run's id, so they grow for as long as the server starts runs. ## Recap - `execute_job { name, input, background: true }` returns `{ runId, state: "pending" }` at once, and the job keeps running. `get_job_status { runId }` returns the run: `state`, `result` or `error`, `attempt`, times and `logs`. - A run goes `pending`, `running`, and `completed` or `failed`, with `retrying` between attempts. Logs appear only when a run completes; a failed run keeps none. - Only the caller who started a run can read it. Under 2026-07-28 an anonymous caller is new on every request, so it can never read its runs: have callers sign in, or have the job save its result where a tool can read it. - `retry: { maxAttempts, backoffMs, backoffMultiplier, maxBackoffMs }` runs a failed job again, waiting longer each time. Every error is retried, and each attempt starts from the top, so make retried jobs safe to run twice. - A job finds providers on its own app and on the server, so a job and a tool can share one. - Runs are in memory by default. `jobs: { enabled: true, store: { redis } }` keeps them in Redis, connected like the server's `redis` option, password and TLS included. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Retry the CRM sync The CRM drops the first two requests for each customer, so `sync-customer` fails every first call. Make FrontMCP try up to 3 times, waiting 100 ms before the first retry and 200 ms before the second. Initial call configuration: ```js { tool: "execute_job", arguments: { name: "sync-customer", input: { customerId: "C-1" } } } ``` ```ts sync-customer.job.ts active import { Job, JobContext, z } from "@frontmcp/sdk"; import { crm } from "./crm"; @Job({ name: "sync-customer", description: "Copy a customer's plan from the CRM into the help desk.", inputSchema: { customerId: z.string() }, outputSchema: { customerId: z.string(), plan: z.string() }, }) export class SyncCustomer extends JobContext { async execute({ customerId }: { customerId: string }) { const customer = await crm.find(customerId); return { customerId, plan: customer.plan }; } } ``` **Solution file (alternative to the starter):** ```ts sync-customer.job.ts solution import { Job, JobContext, z } from "@frontmcp/sdk"; import { crm } from "./crm"; @Job({ name: "sync-customer", description: "Copy a customer's plan from the CRM into the help desk.", inputSchema: { customerId: z.string() }, outputSchema: { customerId: z.string(), plan: z.string() }, retry: { maxAttempts: 3, backoffMs: 100 }, }) export class SyncCustomer extends JobContext { async execute({ customerId }: { customerId: string }) { const customer = await crm.find(customerId); return { customerId, plan: customer.plan }; } } ``` ```ts crm.ts // Stands in for a CRM that drops the first two requests for each customer. export const requests: Record = {}; export const crm = { async find(id: string) { requests[id] = (requests[id] ?? 0) + 1; if (requests[id] <= 2) throw new Error("CRM timed out"); return { id, plan: "business" }; }, }; ``` **Hidden fixture/test file (runs with the example):** ```ts retry.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { requests } from "./crm"; test("syncing C-2 succeeds on the third try", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-2" } }); expect(result).toBeSuccessful(); expect(result.json().result).toEqual({ customerId: "C-2", plan: "business" }); expect(requests["C-2"]).toBe(3); }); test("the retries wait 100 ms and then 200 ms", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-3" } }); expect(result.durationMs).toBeGreaterThanOrEqual(300); expect(result.durationMs).toBeLessThan(1_000); }); ``` **Hint:** `@Job` has a `retry` option. Its multiplier already doubles each wait, so you only need to set the first one. **Solution:** `retry: { maxAttempts: 3, backoffMs: 100 }` runs the job up to three times. The first wait is `backoffMs`, and each wait after that is multiplied by `backoffMultiplier`, which is `2` by default: 100 ms, then 200 ms. `retry: {}` would also get the sync through, but with the default waits of 1 and 2 seconds, so a model running the job inline would wait three seconds for it. ### Challenge: Send the reminder once `remind-customer` emails a customer about an unpaid invoice, then marks the invoice as reminded in billing. Billing times out on the first try, so the job is retried, and customers get two emails. Fix the job so a successful run sends exactly one email. The retry must stay. Initial call configuration: ```js { tool: "list_outbox", arguments: {} } ``` ```ts remind-customer.job.ts active import { Job, JobContext, z } from "@frontmcp/sdk"; import { billing, outbox } from "./services"; @Job({ name: "remind-customer", description: "Email a customer about an unpaid invoice, and record that they were reminded.", inputSchema: { invoiceId: z.string() }, outputSchema: { invoiceId: z.string(), reminded: z.boolean() }, retry: { maxAttempts: 3, backoffMs: 50 }, }) export class RemindCustomer extends JobContext { async execute({ invoiceId }: { invoiceId: string }) { outbox.push(`Invoice ${invoiceId} is still unpaid.`); await billing.markReminded(invoiceId); return { invoiceId, reminded: true }; } } ``` **Solution file (alternative to the starter):** ```ts remind-customer.job.ts solution import { Job, JobContext, z } from "@frontmcp/sdk"; import { billing, outbox } from "./services"; @Job({ name: "remind-customer", description: "Email a customer about an unpaid invoice, and record that they were reminded.", inputSchema: { invoiceId: z.string() }, outputSchema: { invoiceId: z.string(), reminded: z.boolean() }, retry: { maxAttempts: 3, backoffMs: 50 }, }) export class RemindCustomer extends JobContext { async execute({ invoiceId }: { invoiceId: string }) { await billing.markReminded(invoiceId); // the part that can fail goes first outbox.push(`Invoice ${invoiceId} is still unpaid.`); return { invoiceId, reminded: true }; } } ``` ```ts services.ts import { Tool, ToolContext } from "@frontmcp/sdk"; export const outbox: string[] = []; const attempts: Record = {}; // Stands in for billing: the first request for each invoice times out. export const billing = { async markReminded(invoiceId: string) { attempts[invoiceId] = (attempts[invoiceId] ?? 0) + 1; if (attempts[invoiceId] === 1) throw new Error("billing timed out"); }, }; @Tool({ name: "list_outbox", description: "List the emails sent to customers.", inputSchema: {} }) export class ListOutbox extends ToolContext { async execute() { return { emails: outbox }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts remind.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("the reminder succeeds despite billing's first timeout", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "remind-customer", input: { invoiceId: "INV-7" } }); expect(result.json()).toMatchObject({ state: "completed", result: { invoiceId: "INV-7", reminded: true } }); }); test("the customer gets exactly one email", async ({ mcp }) => { await mcp.tools.call("execute_job", { name: "remind-customer", input: { invoiceId: "INV-8" } }); const { emails } = (await mcp.tools.call("list_outbox", {})).json(); expect(emails.filter((e: string) => e.includes("INV-8"))).toHaveLength(1); }); ``` **Hint:** Each attempt runs `execute()` from the top. Which line runs on every attempt, even the ones that fail? **Solution:** The email went out before the call to billing, so every attempt that billing failed had already sent one. Calling billing first means a failed attempt has sent nothing, and the email goes out once, on the attempt that gets past billing. When a job has two side effects, put the one that can fail first, or check whether the work was already done before doing it again. ### Challenge: Let the job find the report store `build-daily-report` should save each day's report in `ReportStore`, for `get_daily_report` to return. The job is in the help desk's app, and the tool in the reporting team's. But the job fails, and the report never appears. Fix it so that after the job runs, the tool returns the report. Initial call configuration: ```js { tool: "execute_job", arguments: { name: "build-daily-report", input: { day: "2026-09-26" } } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { BuildDailyReport, GetDailyReport, ReportStore } from "./reports"; @App({ id: "help-desk", name: "Help Desk", jobs: [BuildDailyReport] }) class HelpDeskApp {} @App({ id: "reports", name: "Reports", tools: [GetDailyReport], providers: [ReportStore] }) class ReportsApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, ReportsApp], }) export default class Server {} ``` **Solution file (alternative to the starter):** ```ts main.ts solution import { App, FrontMcp } from "@frontmcp/sdk"; import { BuildDailyReport, GetDailyReport, ReportStore } from "./reports"; @App({ id: "help-desk", name: "Help Desk", jobs: [BuildDailyReport] }) class HelpDeskApp {} @App({ id: "reports", name: "Reports", tools: [GetDailyReport] }) class ReportsApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, ReportsApp], providers: [ReportStore], // on the server, for both apps }) export default class Server {} ``` ```ts reports.ts import { Job, JobContext, Provider, Tool, ToolContext, z } from "@frontmcp/sdk"; @Provider({ name: "ReportStore" }) export class ReportStore { readonly reports = new Map(); } @Job({ name: "build-daily-report", description: "Count the tickets opened and closed on one day, and save the report.", inputSchema: { day: z.string() }, outputSchema: { day: z.string() }, }) export class BuildDailyReport extends JobContext { async execute({ day }: { day: string }) { this.get(ReportStore).reports.set(day, { opened: 12, closed: 9 }); return { day }; } } @Tool({ name: "get_daily_report", description: "Get the ticket report for one day, if the build-daily-report job has made it.", inputSchema: { day: z.string() }, annotations: { readOnlyHint: true }, }) export class GetDailyReport extends ToolContext { async execute({ day }: { day: string }) { const report = this.get(ReportStore).reports.get(day); return report ? { day, ready: true, ...report } : { day, ready: false }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts report.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`build-daily-report` completes", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "build-daily-report", input: { day: "2026-09-25" } }); expect(result).toBeSuccessful(); }); test("`get_daily_report` returns the report the job saved", async ({ mcp }) => { await mcp.tools.call("execute_job", { name: "build-daily-report", input: { day: "2026-09-24" } }); const report = (await mcp.tools.call("get_daily_report", { day: "2026-09-24" })).json(); expect(report).toEqual({ day: "2026-09-24", ready: true, opened: 12, closed: 9 }); }); ``` **Hint:** Read the error in the Call tab. Where does a job look for providers, and where is `ReportStore`? **Solution:** A job's `this.get()` finds providers on its own app and on the server. `ReportStore` was on the `reports` app, which only that app's tools and jobs see, so the job failed with `Provider "ReportStore" is not available`. Registered on the server, it's one instance that both apps find, so the tool returns what the job saved. Putting the job and the tool in one app, with the store, works too. --- # Chaining Jobs into Workflows > How to chain FrontMCP jobs into a workflow with @Workflow, pass each step's output to the next, run independent steps in parallel, and decide what a failing step does to the rest. Source: https://frontmcp.dev/learn/chaining-jobs-into-workflows [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 The help desk's nightly report is three jobs: collect the day's tickets, find the ones that broke their SLA, and write the report. A model could run them one by one with `execute_job`, copying each result into the next call. A **workflow** does that on the server: it names the jobs, says which ones need which, and says what each one gets as input. FrontMCP runs the steps in order, runs the ones that don't depend on each other at the same time, and returns what every step produced. **You will learn** - How to declare a workflow with `@Workflow` and `steps`, and run it with `execute_workflow` - How to pass one step's output to the next - Which steps run at the same time, and what `maxConcurrency` does - What a failing step does to the rest, and how to change it with `retry`, `continueOnError` and `condition` - How to run a workflow in the background, and read its steps afterwards ## One job at a time With three jobs and no workflow, the report takes three round trips through the model. It calls `execute_job` for `collect-tickets`, reads the list of tickets, copies all of it into a call to `check-sla`, then copies the day, the count and the breaches into a call to `write-report`. Every ticket passes through the model's context twice, the model can copy something wrong at each step, and nothing happens on the server while the model is thinking. And if the report should be written every night, there may be no model around at all. ## Declaring a workflow `@Workflow` lists the steps. Each step runs one job, and says which steps it depends on and where its input comes from. Open the **Tests** tab: Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "nightly-ticket-report", input: { day: "2026-09-26" } } } ``` ```ts nightly-report.workflow.ts active import { Workflow } from "@frontmcp/sdk"; import type { Ticket } from "./store"; @Workflow({ name: "nightly-ticket-report", description: "Collect a day's tickets, find the ones that broke their SLA, and write the report.", steps: [ // No `input`: the step gets the workflow's input, { day }. { id: "collect", jobName: "collect-tickets" }, { id: "breaches", jobName: "check-sla", dependsOn: ["collect"], input: (steps) => ({ tickets: steps.get("collect").outputs.tickets }), }, { id: "report", jobName: "write-report", dependsOn: ["collect", "breaches"], input: (steps) => ({ day: steps.get("collect").outputs.day, total: (steps.get("collect").outputs.tickets as Ticket[]).length, breaches: steps.get("breaches").outputs.breaches, }), }, ], }) export class NightlyTicketReport {} ``` ```ts jobs.ts import { Job, JobContext, z } from "@frontmcp/sdk"; import { ticketsByDay, type Ticket } from "./store"; const ticket = z.object({ id: z.string(), priority: z.enum(["high", "normal"]), hoursToFirstReply: z.number() }); const SLA_HOURS = { high: 4, normal: 24 }; @Job({ name: "collect-tickets", description: "Collect the tickets opened on one day.", inputSchema: { day: z.string().describe("The day, like 2026-09-26") }, outputSchema: { day: z.string(), tickets: z.array(ticket) }, }) export class CollectTickets extends JobContext { async execute({ day }: { day: string }) { const tickets = ticketsByDay[day] ?? []; this.log(`Collected ${tickets.length} tickets for ${day}`); return { day, tickets }; } } @Job({ name: "check-sla", description: "Find the tickets whose first reply came later than their SLA allows.", inputSchema: { tickets: z.array(ticket) }, outputSchema: { breaches: z.array(z.string()) }, }) export class CheckSla extends JobContext { async execute({ tickets }: { tickets: Ticket[] }) { return { breaches: tickets.filter((t) => t.hoursToFirstReply > SLA_HOURS[t.priority]).map((t) => t.id) }; } } @Job({ name: "write-report", description: "Write the nightly SLA report.", inputSchema: { day: z.string(), total: z.number(), breaches: z.array(z.string()) }, outputSchema: { report: z.string() }, }) export class WriteReport extends JobContext { async execute({ day, total, breaches }: { day: string; total: number; breaches: string[] }) { const which = breaches.length ? `: ${breaches.join(", ")}` : ""; return { report: `${day}: ${breaches.length} of ${total} tickets broke their SLA${which}.` }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { CheckSla, CollectTickets, WriteReport } from "./jobs"; import { NightlyTicketReport } from "./nightly-report.workflow"; @App({ id: "help-desk", name: "Help Desk", jobs: [CollectTickets, CheckSla, WriteReport], workflows: [NightlyTicketReport], }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts store.ts export type Ticket = { id: string; priority: "high" | "normal"; hoursToFirstReply: number }; export const ticketsByDay: Record = { "2026-09-26": [ { id: "T-1", priority: "high", hoursToFirstReply: 6 }, { id: "T-2", priority: "normal", hoursToFirstReply: 30 }, { id: "T-3", priority: "high", hoursToFirstReply: 2 }, { id: "T-4", priority: "normal", hoursToFirstReply: 5 }, ], "2026-09-27": [{ id: "T-5", priority: "normal", hoursToFirstReply: 3 }], }; ``` ```ts report.test.ts import { test, expect } from "@frontmcp/testing"; test("the report names the tickets that broke their SLA", async ({ mcp }) => { const run = await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: { day: "2026-09-26" } }); expect(run).toBeSuccessful(); expect(run.json()).toMatchObject({ runId: expect.any(String), state: "completed", result: { workflowName: "nightly-ticket-report", state: "completed", stepResults: { collect: { state: "completed", outputs: { day: "2026-09-26" } }, breaches: { state: "completed", outputs: { breaches: ["T-1", "T-2"] } }, report: { state: "completed", outputs: { report: "2026-09-26: 2 of 4 tickets broke their SLA: T-1, T-2." } }, }, }, }); }); test("a day without breaches, and no logs in the result", async ({ mcp }) => { const run = (await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: { day: "2026-09-27" } })).json(); expect(run.result.stepResults.report.outputs.report).toBe("2026-09-27: 0 of 1 tickets broke their SLA."); expect(run.logs).toBeUndefined(); }); ``` Change `day` in the Call tab to `2026-09-27` and call it again. Each step takes: | Field | What it does | | --- | --- | | `id` | The step's name within this workflow. Other steps use it in `dependsOn` and `steps.get()`. | | `jobName` | The name of the job the step runs. If no job has that name, the step fails when the workflow runs. | | `dependsOn` | The steps that must be done before this one starts. Without it, the step starts right away. | | `input` | What the job gets. See [below](https://frontmcp.dev/learn/chaining-jobs-into-workflows#passing-data-between-steps). | | `retry`, `timeout` | Override the job's own. See [When a step fails](https://frontmcp.dev/learn/chaining-jobs-into-workflows#when-a-step-fails) and [Timeouts](https://frontmcp.dev/learn/chaining-jobs-into-workflows#timeouts). | | `continueOnError`, `condition` | Let a step fail, or skip it. See [Optional steps](https://frontmcp.dev/learn/chaining-jobs-into-workflows#optional-steps). | A workflow is registered like a job, with `workflows: [...]` on `@App`, and its jobs go in `jobs`. (Most examples below leave `main.ts` out, and the Playground registers their jobs and workflows for you.) The class itself stays empty: everything is in the decorator. A client runs it with `execute_workflow`, which takes `name`, `input` and `background`, like `execute_job`, and answers with the run: ```json { "runId": "0c8f1a52-…", "state": "completed", "result": { "workflowName": "nightly-ticket-report", "state": "completed", "stepResults": { "collect": { "outputs": { "day": "2026-09-26", "tickets": [/* … */] }, "state": "completed" }, "breaches": { "outputs": { "breaches": ["T-1", "T-2"] }, "state": "completed" }, "report": { "outputs": { "report": "2026-09-26: 2 of 4 tickets broke their SLA: T-1, T-2." }, "state": "completed" } }, "startedAt": 1790467200000, "completedAt": 1790467200004 } } ``` `stepResults` has every step's `outputs`, what its job returned, and its `state`. There are no logs: the steps' `this.log()` lines only go to the server's log. So the model gets the whole report in one call, and so does a client that runs the workflow every night with no model involved. ## Passing data between steps A step's `input` can be three things: | `input` | The job gets | | --- | --- | | Left out | The workflow's input: the `input` passed to `execute_workflow`, or `{}`. | | An object | That object, on every run. | | A function | What the function returns. FrontMCP calls it just before the step starts, with `steps`. | `steps.get(id)` returns a finished step's `{ outputs, state }`. It's the only thing an input function gets: the workflow's own input isn't passed to it, which is why `collect-tickets` returns the `day` it was given, for `write-report` to use. A workflow can also declare an `inputSchema`, but in 1.8 nothing checks `execute_workflow`'s input against it; each job checks its own. > **Pitfall: Depend on every step you read** > > `steps.get()` only knows steps that have finished. If `report` reads `breaches` but only lists `collect` in `dependsOn`, the two start together as soon as `collect` is done, and `report`'s input function runs before `breaches` has an answer: > > Initial call configuration: > > ```js > { tool: "execute_workflow", arguments: { name: "nightly-ticket-report", input: { day: "2026-09-26" } } } > ``` > > ```ts nightly-report.workflow.ts active > import { Workflow } from "@frontmcp/sdk"; > > @Workflow({ > name: "nightly-ticket-report", > steps: [ > { id: "collect", jobName: "collect-tickets" }, > { id: "breaches", jobName: "check-sla", dependsOn: ["collect"], input: (steps) => ({ tickets: steps.get("collect").outputs.tickets }) }, > { > id: "report", > jobName: "write-report", > dependsOn: ["collect"], // 🚩 reads "breaches" too > input: (steps) => ({ breaches: steps.get("breaches").outputs.breaches }), > }, > ], > }) > export class NightlyTicketReport {} > ``` > > ```ts jobs.ts > import { Job, JobContext, z } from "@frontmcp/sdk"; > > @Job({ name: "collect-tickets", inputSchema: { day: z.string() }, outputSchema: { tickets: z.array(z.string()) } }) > export class CollectTickets extends JobContext { > async execute({ day }: { day: string }) { > return { tickets: ["T-1", "T-2", "T-3"] }; > } > } > > @Job({ name: "check-sla", inputSchema: { tickets: z.array(z.string()) }, outputSchema: { breaches: z.array(z.string()) } }) > export class CheckSla extends JobContext { > async execute({ tickets }: { tickets: string[] }) { > return { breaches: tickets.slice(0, 1) }; > } > } > > @Job({ name: "write-report", inputSchema: { breaches: z.array(z.string()) }, outputSchema: { report: z.string() } }) > export class WriteReport extends JobContext { > async execute({ breaches }: { breaches: string[] }) { > return { report: `${breaches.length} tickets broke their SLA.` }; > } > } > ``` > > ```ts depends.test.ts > import { test, expect } from "@frontmcp/testing"; > > test("a step that reads a step it doesn't depend on fails", async ({ mcp }) => { > const run = (await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: { day: "2026-09-26" } })).json(); > expect(run.state).toBe("failed"); > expect(run.result.stepResults.breaches.state).toBe("completed"); > expect(run.result.stepResults.report).toEqual({ outputs: {}, state: "failed" }); > }); > ``` > > `report` fails, and the result doesn't say why. The reason is only in the server's log (the **Logs** tab): `Step "breaches" has not completed yet or does not exist`. Add `"breaches"` to `dependsOn` and it works. ## Steps that run at the same time Steps that don't depend on each other don't wait for each other. The report now also counts tickets per customer, which has nothing to do with the SLA check, so both depend only on `collect` and run side by side. Paging the on-call agent needs the breaches, and nothing else: Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "nightly-ticket-report", input: { day: "2026-09-26" } } } ``` ```ts nightly-report.workflow.ts active import { Workflow } from "@frontmcp/sdk"; @Workflow({ name: "nightly-ticket-report", steps: [ { id: "collect", jobName: "collect-tickets" }, { id: "breaches", jobName: "check-sla", dependsOn: ["collect"] }, // 100 ms { id: "customers", jobName: "count-by-customer", dependsOn: ["collect"] }, // 300 ms { id: "page", jobName: "page-on-call", dependsOn: ["breaches"] }, { id: "report", jobName: "write-report", dependsOn: ["breaches", "customers"] }, ], }) export class NightlyTicketReport {} @Workflow({ name: "one-at-a-time", maxConcurrency: 1, steps: [ { id: "breaches", jobName: "check-sla" }, { id: "customers", jobName: "count-by-customer" }, ], }) export class OneAtATime {} ``` ```ts jobs.ts import { Job, JobContext } from "@frontmcp/sdk"; import { work } from "./timeline"; // Each job records when it starts and ends, and takes as long as its real work would. @Job({ name: "collect-tickets", inputSchema: {}, outputSchema: {} }) export class CollectTickets extends JobContext { async execute() { await work("collect-tickets", 10); return {}; } } @Job({ name: "check-sla", inputSchema: {}, outputSchema: {} }) export class CheckSla extends JobContext { async execute() { await work("check-sla", 100); return {}; } } @Job({ name: "count-by-customer", inputSchema: {}, outputSchema: {} }) export class CountByCustomer extends JobContext { async execute() { await work("count-by-customer", 300); return {}; } } @Job({ name: "page-on-call", inputSchema: {}, outputSchema: {} }) export class PageOnCall extends JobContext { async execute() { await work("page-on-call", 10); return {}; } } @Job({ name: "write-report", inputSchema: {}, outputSchema: {} }) export class WriteReport extends JobContext { async execute() { await work("write-report", 10); return {}; } } ``` ```ts timeline.ts export const timeline: string[] = []; export async function work(job: string, ms: number) { timeline.push(`start ${job}`); await new Promise((resolve) => setTimeout(resolve, ms)); timeline.push(`end ${job}`); } ``` ```ts parallel.test.ts import { test, expect } from "@frontmcp/testing"; import { timeline } from "./timeline"; test("steps run in batches", async ({ mcp }) => { timeline.length = 0; const run = await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: {} }); const at = (event: string) => timeline.indexOf(event); // check-sla and count-by-customer run at the same time... expect(at("start count-by-customer")).toBeLessThan(at("end check-sla")); // ...so the whole run takes about 300 ms, not 400. expect(run.durationMs).toBeLessThan(400); // 🚩 page-on-call only needs check-sla, but waits for count-by-customer too. expect(at("start page-on-call")).toBeGreaterThan(at("end count-by-customer")); }); test("with `maxConcurrency: 1`, steps run one at a time, in order", async ({ mcp }) => { timeline.length = 0; await mcp.tools.call("execute_workflow", { name: "one-at-a-time", input: {} }); expect(timeline).toEqual(["start check-sla", "end check-sla", "start count-by-customer", "end count-by-customer"]); }); ``` FrontMCP runs a workflow in **batches**. It takes every step whose `dependsOn` are all done, starts up to `maxConcurrency` of them at once (5 by default), and waits until the whole batch has finished before it looks for the next steps. So `check-sla` and `count-by-customer` overlap, and the run takes about 300 ms instead of 400. But `page-on-call` could start after 100 ms, when `check-sla` is done, and instead it waits for `count-by-customer`: a batch is as slow as its slowest step. `maxConcurrency` on `@Workflow` caps the batch. Steps beyond it wait for the next batch, in the order you declared them, and `maxConcurrency: 1` runs the steps one at a time. Lower it when the steps share something that can't take much at once, like a rate-limited API. ## When a step fails The pager service is down tonight, so `page-on-call` fails. Two steps come after it: marking the late tickets as escalated, and emailing their customers that they were. The call in this example takes three seconds: Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "nightly-ticket-report", input: {} } } ``` ```ts nightly-report.workflow.ts active import { Workflow } from "@frontmcp/sdk"; @Workflow({ name: "nightly-ticket-report", steps: [ { id: "breaches", jobName: "check-sla" }, { id: "page", jobName: "page-on-call", dependsOn: ["breaches"] }, { id: "escalate", jobName: "mark-escalated", dependsOn: ["page"] }, { id: "email", jobName: "email-customers", dependsOn: ["escalate"] }, { id: "report", jobName: "write-report", dependsOn: ["breaches"] }, ], }) export class NightlyTicketReport {} ``` ```ts jobs.ts import { Job, JobContext, z } from "@frontmcp/sdk"; export const pagerCalls: string[] = []; export const outbox: string[] = []; @Job({ name: "check-sla", inputSchema: {}, outputSchema: { breaches: z.array(z.string()) } }) export class CheckSla extends JobContext { async execute() { return { breaches: ["T-1", "T-2"] }; } } @Job({ name: "page-on-call", inputSchema: {}, outputSchema: { paged: z.string() } }) export class PageOnCall extends JobContext { async execute() { pagerCalls.push(new Date().toISOString()); throw new Error("Pager service unavailable"); } } @Job({ name: "mark-escalated", inputSchema: {}, outputSchema: { escalated: z.number() } }) export class MarkEscalated extends JobContext { async execute() { return { escalated: 2 }; } } @Job({ name: "email-customers", inputSchema: {}, outputSchema: { sent: z.number() } }) export class EmailCustomers extends JobContext { async execute() { outbox.push("Your ticket was escalated to our on-call agent."); return { sent: 1 }; } } @Job({ name: "write-report", inputSchema: {}, outputSchema: { report: z.string() } }) export class WriteReport extends JobContext { async execute() { return { report: "2 tickets broke their SLA." }; } } ``` ```ts failure.test.ts import { test, expect } from "@frontmcp/testing"; import { outbox, pagerCalls } from "./jobs"; test("what one failed step does to the rest", async ({ mcp }) => { pagerCalls.length = 0; outbox.length = 0; const call = await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: {} }); // The call succeeds; the run inside it failed. expect(call).toBeSuccessful(); const run = call.json(); expect(run.state).toBe("failed"); // page-on-call was tried 3 times, 1 s and then 2 s apart, and the error isn't in the result. expect(pagerCalls).toHaveLength(3); expect(call.durationMs).toBeGreaterThanOrEqual(3_000); expect(run.result.stepResults.page).toEqual({ outputs: {}, state: "failed" }); // The step that depends on it is skipped, and the ones that don't depend on it still run. expect(run.result.stepResults.escalate.state).toBe("skipped"); expect(run.result.stepResults.report.state).toBe("completed"); // 🚩 A step that depends on the skipped step runs: customers were told about an escalation that never happened. expect(run.result.stepResults.email.state).toBe("completed"); expect(outbox).toHaveLength(1); }); ``` A lot happened in those three seconds: - **The step was retried.** `page-on-call` has no `retry` of its own, and as a job, [it would run once](https://frontmcp.dev/learn/running-jobs-in-the-background#retrying-a-flaky-job). As a workflow step it's tried three times, waiting 1 s and then 2 s, because in 1.8 a step without `retry`, on the step or on its job, gets the defaults. - **The failed step's direct dependents were skipped.** `escalate` never ran, and its state is `"skipped"`. - **Everything else ran.** `report` doesn't depend on `page`, so the report was written. - **The workflow failed, but the call didn't.** `execute_workflow` returned normally, with `state: "failed"` in the run. A client has to check `state`, not just whether the call succeeded. - **The reason is gone.** A failed step's result is `{ outputs: {}, state: "failed" }`, whatever went wrong. `Pager service unavailable` is only in the server's log. - **`email` ran.** In 1.8 only the direct dependents of a failed step are skipped. A skipped step counts as done for the steps after it, so `email`, which depends on `escalate`, ran, and emailed customers about an escalation that never happened. The fix for the last one is to list, in `dependsOn`, every step a step needs to have *succeeded*, not just the one right before it. A step with a failed step in its `dependsOn` is skipped. And a step that should fail fast gets a `retry` of its own: Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "nightly-ticket-report", input: {} } } ``` ```ts nightly-report.workflow.ts active import { Workflow } from "@frontmcp/sdk"; @Workflow({ name: "nightly-ticket-report", steps: [ { id: "breaches", jobName: "check-sla" }, { id: "page", jobName: "page-on-call", dependsOn: ["breaches"], retry: { maxAttempts: 1 } }, // ✅ fail at once { id: "escalate", jobName: "mark-escalated", dependsOn: ["page"] }, { id: "email", jobName: "email-customers", dependsOn: ["page", "escalate"] }, // ✅ needs both to succeed { id: "report", jobName: "write-report", dependsOn: ["breaches"] }, ], }) export class NightlyTicketReport {} ``` ```ts jobs.ts import { Job, JobContext, z } from "@frontmcp/sdk"; export const pagerCalls: string[] = []; export const outbox: string[] = []; @Job({ name: "check-sla", inputSchema: {}, outputSchema: { breaches: z.array(z.string()) } }) export class CheckSla extends JobContext { async execute() { return { breaches: ["T-1", "T-2"] }; } } @Job({ name: "page-on-call", inputSchema: {}, outputSchema: { paged: z.string() } }) export class PageOnCall extends JobContext { async execute() { pagerCalls.push(new Date().toISOString()); throw new Error("Pager service unavailable"); } } @Job({ name: "mark-escalated", inputSchema: {}, outputSchema: { escalated: z.number() } }) export class MarkEscalated extends JobContext { async execute() { return { escalated: 2 }; } } @Job({ name: "email-customers", inputSchema: {}, outputSchema: { sent: z.number() } }) export class EmailCustomers extends JobContext { async execute() { outbox.push("Your ticket was escalated to our on-call agent."); return { sent: 1 }; } } @Job({ name: "write-report", inputSchema: {}, outputSchema: { report: z.string() } }) export class WriteReport extends JobContext { async execute() { return { report: "2 tickets broke their SLA." }; } } ``` ```ts failure.test.ts import { test, expect } from "@frontmcp/testing"; import { outbox, pagerCalls } from "./jobs"; test("the pager is tried once, and no customer is emailed", async ({ mcp }) => { pagerCalls.length = 0; outbox.length = 0; const call = await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: {} }); const { stepResults } = call.json().result; expect(pagerCalls).toHaveLength(1); expect(call.durationMs).toBeLessThan(500); expect(stepResults.escalate.state).toBe("skipped"); expect(stepResults.email.state).toBe("skipped"); expect(outbox).toEqual([]); expect(stepResults.report.state).toBe("completed"); }); ``` A step's `retry` takes the same fields as a [job's](https://frontmcp.dev/learn/running-jobs-in-the-background#retrying-a-flaky-job), and replaces the job's own for that step. Set it on every step whose job has no `retry`, unless three tries over three seconds is what you want. ## Optional steps Two options make a step optional, in different ways: - **`continueOnError: true`** lets a step fail without failing the workflow. Its state is still `"failed"`, but its dependents run, and the workflow can complete. - **`condition: (steps) => boolean`** decides whether a step runs at all. It's called with `steps` once the step's `dependsOn` are done, and when it returns `false` the step is `"skipped"`. Posting the report to the team's chat is nice to have, and the chat service is down. Paging on-call only makes sense when something broke: Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "nightly-ticket-report", input: { day: "2026-09-27" } } } ``` ```ts nightly-report.workflow.ts active import { Workflow } from "@frontmcp/sdk"; @Workflow({ name: "nightly-ticket-report", steps: [ { id: "breaches", jobName: "check-sla" }, { id: "page", jobName: "page-on-call", dependsOn: ["breaches"], condition: (steps) => (steps.get("breaches").outputs.breaches as string[]).length > 0, input: (steps) => ({ tickets: steps.get("breaches").outputs.breaches }), }, { id: "report", jobName: "write-report", dependsOn: ["breaches", "page"], input: (steps) => ({ breaches: steps.get("breaches").outputs.breaches, paged: steps.get("page").state === "completed" }), }, { id: "post", jobName: "post-to-chat", dependsOn: ["report"], input: (steps) => ({ text: steps.get("report").outputs.report }), continueOnError: true, retry: { maxAttempts: 1 }, }, ], }) export class NightlyTicketReport {} ``` ```ts jobs.ts import { Job, JobContext, z } from "@frontmcp/sdk"; const breachesByDay: Record = { "2026-09-26": ["T-1", "T-2"], "2026-09-27": [] }; export const pages: string[][] = []; @Job({ name: "check-sla", inputSchema: { day: z.string() }, outputSchema: { breaches: z.array(z.string()) } }) export class CheckSla extends JobContext { async execute({ day }: { day: string }) { return { breaches: breachesByDay[day] ?? [] }; } } @Job({ name: "page-on-call", inputSchema: { tickets: z.array(z.string()) }, outputSchema: { paged: z.boolean() } }) export class PageOnCall extends JobContext { async execute({ tickets }: { tickets: string[] }) { pages.push(tickets); return { paged: true }; } } @Job({ name: "write-report", inputSchema: { breaches: z.array(z.string()), paged: z.boolean() }, outputSchema: { report: z.string() }, }) export class WriteReport extends JobContext { async execute({ breaches, paged }: { breaches: string[]; paged: boolean }) { return { report: `${breaches.length} tickets broke their SLA. On-call was ${paged ? "" : "not "}paged.` }; } } @Job({ name: "post-to-chat", inputSchema: { text: z.string() }, outputSchema: { posted: z.boolean() } }) export class PostToChat extends JobContext { async execute() { throw new Error("Chat service unavailable"); } } ``` ```ts optional.test.ts import { test, expect } from "@frontmcp/testing"; import { pages } from "./jobs"; const run = async (mcp: any, day: string) => (await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: { day } })).json(); test("with no breaches, on-call isn't paged, and the report still runs", async ({ mcp }) => { pages.length = 0; const { state, result } = await run(mcp, "2026-09-27"); expect(state).toBe("completed"); expect(result.stepResults.page).toEqual({ outputs: {}, state: "skipped" }); expect(result.stepResults.report.outputs.report).toBe("0 tickets broke their SLA. On-call was not paged."); expect(pages).toEqual([]); }); test("with breaches, on-call is paged", async ({ mcp }) => { const { result } = await run(mcp, "2026-09-26"); expect(result.stepResults.page.state).toBe("completed"); expect(result.stepResults.report.outputs.report).toBe("2 tickets broke their SLA. On-call was paged."); }); test("a failed chat post doesn't fail the workflow", async ({ mcp }) => { const { state, result } = await run(mcp, "2026-09-26"); expect(result.stepResults.post.state).toBe("failed"); expect(state).toBe("completed"); }); ``` `report` depends on `page`, which is skipped on a quiet night, and runs anyway: a skipped step, whether a `condition` skipped it or a failure did, counts as done for the steps after it. So a step after an optional one has to handle both cases. `report` does that in its input function: `steps.get("page").state` is `"completed"`, `"failed"` or `"skipped"`. ## Timeouts A step can't run forever. It gets the job's `timeout`, 5 minutes unless the job sets one, or the step's own `timeout`, and when that runs out the step fails. The job itself keeps going, since nothing stops it, and in 1.8 `execute_job` doesn't apply `timeout` at all: Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "nightly-export" } } ``` ```ts export.ts active import { Job, JobContext, Workflow, z } from "@frontmcp/sdk"; export const exports: string[] = []; @Job({ name: "export-tickets", description: "Export every ticket as CSV.", inputSchema: {}, outputSchema: { rows: z.number() }, timeout: 200, }) export class ExportTickets extends JobContext { async execute() { await new Promise((resolve) => setTimeout(resolve, 500)); // takes longer than its timeout exports.push(new Date().toISOString()); return { rows: 5 }; } } @Workflow({ name: "nightly-export", steps: [{ id: "export", jobName: "export-tickets", retry: { maxAttempts: 1 } }], }) export class NightlyExport {} ``` ```ts timeout.test.ts import { test, expect } from "@frontmcp/testing"; import { exports } from "./export"; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("as a step, the job fails after 200 ms, and keeps running", async ({ mcp }) => { exports.length = 0; const call = await mcp.tools.call("execute_workflow", { name: "nightly-export" }); expect(call.json().result.stepResults.export).toEqual({ outputs: {}, state: "failed" }); expect(call.durationMs).toBeLessThan(400); await wait(400); expect(exports).toHaveLength(1); }); test("with execute_job, the timeout doesn't apply", async ({ mcp }) => { const call = await mcp.tools.call("execute_job", { name: "export-tickets" }); expect(call.json()).toMatchObject({ state: "completed", result: { rows: 5 } }); expect(call.durationMs).toBeGreaterThanOrEqual(500); }); ``` Since a timed-out job isn't stopped, a step with a `timeout` and a `retry` can start its next attempt while the last one is still running. As with [retries](https://frontmcp.dev/learn/running-jobs-in-the-background#retrying-a-flaky-job), make such a job safe to run twice. ## Running a workflow in the background `execute_workflow` takes `background: true`, like `execute_job`, and returns `{ runId, state: "pending" }` at once. The same rule decides who can read the run: [only the caller who started it](https://frontmcp.dev/learn/running-jobs-in-the-background#who-can-read-a-run), which under 2026-07-28 excludes anonymous callers. And in 1.8, reading it with `get_workflow_status` leaves out the steps: Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "nightly-ticket-report", input: { day: "2026-09-26" }, background: true } } ``` ```ts status.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; const asNour = { authContext: { user: { sub: "nour" } } }; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("get_workflow_status has no steps; get_job_status has them all", async () => { const server = await FrontMcpInstance.createDirect(config); const started = await server.callTool("execute_workflow", { name: "nightly-ticket-report", input: { day: "2026-09-26" }, background: true }, asNour); const { runId } = started.structuredContent as { runId: string }; await wait(100); const workflowStatus = (await server.callTool("get_workflow_status", { runId }, asNour)).structuredContent; const jobStatus = (await server.callTool("get_job_status", { runId }, asNour)).structuredContent as any; await server.dispose(); expect(workflowStatus).toMatchObject({ runId, workflowName: "nightly-ticket-report", state: "completed", stepResults: {} }); expect(jobStatus).toMatchObject({ runId, jobName: "nightly-ticket-report", state: "completed" }); expect(jobStatus.result.stepResults.report.outputs).toEqual({ report: "2026-09-26: 2 tickets broke their SLA." }); }); test("`trigger` is only a label", async ({ mcp }) => { const { workflows } = (await mcp.tools.call("list_workflows", {})).json(); expect(workflows).toEqual([expect.objectContaining({ name: "nightly-ticket-report", trigger: "webhook", stepCount: 2 })]); const run = await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: { day: "2026-09-26" } }); expect(run.json().state).toBe("completed"); }); ``` ```ts main.ts import { App, FrontMcp, workflow } from "@frontmcp/sdk"; import { CheckSla, WriteReport } from "./jobs"; // A workflow can also be written as a function, like a job. const NightlyTicketReport = workflow({ name: "nightly-ticket-report", trigger: "webhook", steps: [ { id: "breaches", jobName: "check-sla" }, { id: "report", jobName: "write-report", dependsOn: ["breaches"], input: (steps) => steps.get("breaches").outputs }, ], }); @App({ id: "help-desk", name: "Help Desk", jobs: [CheckSla, WriteReport], workflows: [NightlyTicketReport] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }; @FrontMcp(config) export default class Server {} ``` ```ts jobs.ts import { Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "check-sla", inputSchema: { day: z.string() }, outputSchema: { day: z.string(), breaches: z.array(z.string()) } }) export class CheckSla extends JobContext { async execute({ day }: { day: string }) { return { day, breaches: ["T-1", "T-2"] }; } } @Job({ name: "write-report", inputSchema: { day: z.string(), breaches: z.array(z.string()) }, outputSchema: { report: z.string() } }) export class WriteReport extends JobContext { async execute({ day, breaches }: { day: string; breaches: string[] }) { return { report: `${day}: ${breaches.length} tickets broke their SLA.` }; } } ``` `get_workflow_status` returns the run's `state`, times and name, with `stepResults` always empty in 1.8. A workflow's run is stored like a job's, and `get_job_status` with the same `runId` returns all of it: `jobName` is the workflow's name, and `result` is what `execute_workflow` would have returned inline, `stepResults` included. Until that's fixed, read workflow runs with `get_job_status`. > **Note** > > `trigger` on `@Workflow` takes `"manual"` (the default), `"webhook"` or `"event"`, and `webhook` takes a path, a secret and methods. In 1.8 they're labels: `list_workflows` reports `trigger`, and nothing else reads them. No route listens for webhooks, and no event starts a workflow, so every workflow runs through `execute_workflow`, whatever its `trigger` says. ## Recap - `@Workflow({ name, steps })`, or `workflow({...})`, chains jobs. Register it with `@App({ workflows })`, next to its jobs, and run it with `execute_workflow { name, input }`. The result's `stepResults` has each step's `outputs` and `state`. - A step's `input` is the workflow's input when left out, a fixed object, or a function of `steps`. Only read steps listed in `dependsOn` with `steps.get(id)`, and note that the function doesn't get the workflow's input. - Steps run in batches of up to `maxConcurrency` (5). Independent steps overlap, and each batch waits for its slowest step. - A failed step is retried 3 times by default, its direct dependents are skipped, the rest run, and the run's `state` is `"failed"` in a successful call. The error isn't in the result, and in 1.8 steps after a skipped step still run: list every step you need in `dependsOn`. - `continueOnError` lets a step fail without failing the workflow, and `condition` skips a step. Steps after either one run, so check `steps.get(id).state`. - Steps time out after their job's `timeout` (5 minutes by default), but the job keeps running. In 1.8, read background workflow runs with `get_job_status`: `get_workflow_status` has no steps. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Hand the breaches to the report The report always says "0 of 0 tickets broke their SLA", because its step has a fixed input. Make `report` run after `collect` and `breaches`, and give it the day, the number of tickets and the breaches from those steps. Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "nightly-ticket-report", input: { day: "2026-09-26" } } } ``` ```ts nightly-report.workflow.ts active import { Workflow } from "@frontmcp/sdk"; @Workflow({ name: "nightly-ticket-report", steps: [ { id: "collect", jobName: "collect-tickets" }, { id: "breaches", jobName: "check-sla", dependsOn: ["collect"], input: (steps) => ({ tickets: steps.get("collect").outputs.tickets }) }, { id: "report", jobName: "write-report", input: { day: "", total: 0, breaches: [] } }, ], }) export class NightlyTicketReport {} ``` **Solution file (alternative to the starter):** ```ts nightly-report.workflow.ts solution import { Workflow } from "@frontmcp/sdk"; @Workflow({ name: "nightly-ticket-report", steps: [ { id: "collect", jobName: "collect-tickets" }, { id: "breaches", jobName: "check-sla", dependsOn: ["collect"], input: (steps) => ({ tickets: steps.get("collect").outputs.tickets }) }, { id: "report", jobName: "write-report", dependsOn: ["collect", "breaches"], input: (steps) => ({ day: steps.get("collect").outputs.day, total: (steps.get("collect").outputs.tickets as unknown[]).length, breaches: steps.get("breaches").outputs.breaches, }), }, ], }) export class NightlyTicketReport {} ``` ```ts jobs.ts import { Job, JobContext, z } from "@frontmcp/sdk"; import { ticketsByDay, type Ticket } from "./store"; const ticket = z.object({ id: z.string(), priority: z.enum(["high", "normal"]), hoursToFirstReply: z.number() }); const SLA_HOURS = { high: 4, normal: 24 }; @Job({ name: "collect-tickets", inputSchema: { day: z.string() }, outputSchema: { day: z.string(), tickets: z.array(ticket) }, }) export class CollectTickets extends JobContext { async execute({ day }: { day: string }) { return { day, tickets: ticketsByDay[day] ?? [] }; } } @Job({ name: "check-sla", inputSchema: { tickets: z.array(ticket) }, outputSchema: { breaches: z.array(z.string()) }, }) export class CheckSla extends JobContext { async execute({ tickets }: { tickets: Ticket[] }) { return { breaches: tickets.filter((t) => t.hoursToFirstReply > SLA_HOURS[t.priority]).map((t) => t.id) }; } } @Job({ name: "write-report", inputSchema: { day: z.string(), total: z.number(), breaches: z.array(z.string()) }, outputSchema: { report: z.string() }, }) export class WriteReport extends JobContext { async execute({ day, total, breaches }: { day: string; total: number; breaches: string[] }) { return { report: `${day}: ${breaches.length} of ${total} tickets broke their SLA.` }; } } ``` ```ts store.ts export type Ticket = { id: string; priority: "high" | "normal"; hoursToFirstReply: number }; export const ticketsByDay: Record = { "2026-09-26": [ { id: "T-1", priority: "high", hoursToFirstReply: 6 }, { id: "T-2", priority: "normal", hoursToFirstReply: 30 }, { id: "T-3", priority: "high", hoursToFirstReply: 2 }, { id: "T-4", priority: "normal", hoursToFirstReply: 5 }, ], "2026-09-27": [{ id: "T-5", priority: "normal", hoursToFirstReply: 3 }], }; ``` **Hidden fixture/test file (runs with the example):** ```ts report.test.ts hidden import { test, expect } from "@frontmcp/testing"; const report = async (mcp: any, day: string) => (await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: { day } })).json().result.stepResults.report; test("the 2026-09-26 report counts 2 of 4 tickets", async ({ mcp }) => { expect(await report(mcp, "2026-09-26")).toEqual({ state: "completed", outputs: { report: "2026-09-26: 2 of 4 tickets broke their SLA." } }); }); test("the 2026-09-27 report counts 0 of 1", async ({ mcp }) => { expect((await report(mcp, "2026-09-27")).outputs.report).toBe("2026-09-27: 0 of 1 tickets broke their SLA."); }); ``` **Hint:** A step's `input` can be a function of `steps`. Which steps must be done before it can read them? **Solution:** `dependsOn: ["collect", "breaches"]` makes `report` wait for both, and its input function reads their outputs with `steps.get()`. The day comes from `collect`'s output, not from the workflow's input, because an input function only gets `steps`; that's why `collect-tickets` returns the `day` it was given. ### Challenge: Check both at once Counting tickets per customer doesn't need the SLA check, but the workflow runs one after the other, and the run takes more than 600 ms. Make the two run at the same time. The report still needs both. Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "nightly-ticket-report", input: {} } } ``` ```ts nightly-report.workflow.ts active import { Workflow } from "@frontmcp/sdk"; @Workflow({ name: "nightly-ticket-report", steps: [ { id: "collect", jobName: "collect-tickets" }, { id: "breaches", jobName: "check-sla", dependsOn: ["collect"] }, { id: "customers", jobName: "count-by-customer", dependsOn: ["breaches"] }, { id: "report", jobName: "write-report", dependsOn: ["customers"], input: (steps) => ({ breaches: steps.get("breaches").outputs.breaches, customers: steps.get("customers").outputs.customers }), }, ], }) export class NightlyTicketReport {} ``` **Solution file (alternative to the starter):** ```ts nightly-report.workflow.ts solution import { Workflow } from "@frontmcp/sdk"; @Workflow({ name: "nightly-ticket-report", steps: [ { id: "collect", jobName: "collect-tickets" }, { id: "breaches", jobName: "check-sla", dependsOn: ["collect"] }, { id: "customers", jobName: "count-by-customer", dependsOn: ["collect"] }, { id: "report", jobName: "write-report", dependsOn: ["breaches", "customers"], input: (steps) => ({ breaches: steps.get("breaches").outputs.breaches, customers: steps.get("customers").outputs.customers }), }, ], }) export class NightlyTicketReport {} ``` ```ts jobs.ts import { Job, JobContext, z } from "@frontmcp/sdk"; const slow = () => new Promise((resolve) => setTimeout(resolve, 300)); @Job({ name: "collect-tickets", inputSchema: {}, outputSchema: {} }) export class CollectTickets extends JobContext { async execute() { return {}; } } @Job({ name: "check-sla", inputSchema: {}, outputSchema: { breaches: z.number() } }) export class CheckSla extends JobContext { async execute() { await slow(); return { breaches: 2 }; } } @Job({ name: "count-by-customer", inputSchema: {}, outputSchema: { customers: z.number() } }) export class CountByCustomer extends JobContext { async execute() { await slow(); return { customers: 3 }; } } @Job({ name: "write-report", inputSchema: { breaches: z.number(), customers: z.number() }, outputSchema: { report: z.string() } }) export class WriteReport extends JobContext { async execute({ breaches, customers }: { breaches: number; customers: number }) { return { report: `${breaches} SLA breaches across ${customers} customers.` }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts parallel.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("the report still has both numbers", async ({ mcp }) => { const run = (await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: {} })).json(); expect(run.state).toBe("completed"); expect(run.result.stepResults.report.outputs.report).toBe("2 SLA breaches across 3 customers."); }); test("the run takes less than 500 ms", async ({ mcp }) => { const call = await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: {} }); expect(call.durationMs).toBeLessThan(500); }); ``` **Hint:** A step starts in the first batch after every step in its `dependsOn` is done. Which step does `count-by-customer` really need? **Solution:** `customers` now depends on `collect`, like `breaches`, so the two are ready together and run in the same batch. `report` has to list both in `dependsOn`: before, it got `breaches` through `customers`, and without it, it would start alongside `breaches` and fail to read it. ### Challenge: Keep the report when paging fails The pager is down, so `page` fails, and with it the whole workflow: the report is skipped, and the call takes three seconds of retries. Make the workflow complete with a report that says on-call wasn't paged, and make a failed page fail at once. Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "nightly-ticket-report", input: {} } } ``` ```ts nightly-report.workflow.ts active import { Workflow } from "@frontmcp/sdk"; @Workflow({ name: "nightly-ticket-report", steps: [ { id: "breaches", jobName: "check-sla" }, { id: "page", jobName: "page-on-call", dependsOn: ["breaches"] }, { id: "report", jobName: "write-report", dependsOn: ["breaches", "page"], input: (steps) => ({ breaches: steps.get("breaches").outputs.breaches, paged: steps.get("page").state === "completed" }), }, ], }) export class NightlyTicketReport {} ``` **Solution file (alternative to the starter):** ```ts nightly-report.workflow.ts solution import { Workflow } from "@frontmcp/sdk"; @Workflow({ name: "nightly-ticket-report", steps: [ { id: "breaches", jobName: "check-sla" }, { id: "page", jobName: "page-on-call", dependsOn: ["breaches"], continueOnError: true, retry: { maxAttempts: 1 } }, { id: "report", jobName: "write-report", dependsOn: ["breaches", "page"], input: (steps) => ({ breaches: steps.get("breaches").outputs.breaches, paged: steps.get("page").state === "completed" }), }, ], }) export class NightlyTicketReport {} ``` ```ts jobs.ts import { Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "check-sla", inputSchema: {}, outputSchema: { breaches: z.array(z.string()) } }) export class CheckSla extends JobContext { async execute() { return { breaches: ["T-1", "T-2"] }; } } @Job({ name: "page-on-call", inputSchema: {}, outputSchema: { paged: z.boolean() } }) export class PageOnCall extends JobContext { async execute() { throw new Error("Pager service unavailable"); } } @Job({ name: "write-report", inputSchema: { breaches: z.array(z.string()), paged: z.boolean() }, outputSchema: { report: z.string() }, }) export class WriteReport extends JobContext { async execute({ breaches, paged }: { breaches: string[]; paged: boolean }) { return { report: `${breaches.length} tickets broke their SLA. On-call was ${paged ? "" : "not "}paged.` }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts page.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("the workflow completes, and says on-call wasn't paged", async ({ mcp }) => { const call = await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: {} }); const run = call.json(); expect(run.state).toBe("completed"); expect(run.result.stepResults.page.state).toBe("failed"); expect(run.result.stepResults.report.outputs.report).toBe("2 tickets broke their SLA. On-call was not paged."); expect(call.durationMs).toBeLessThan(1_000); }); ``` **Hint:** Two step options: one lets a step fail without taking its dependents down, and one says how many times to try it. **Solution:** `continueOnError: true` keeps `page`'s state `"failed"` but lets `report` run, and the workflow completes. `report`'s input function already checks `steps.get("page").state`, so the report says what happened. `retry: { maxAttempts: 1 }` stops the step from getting the default three tries, which took three seconds. ### Challenge: Only page when something broke On-call gets paged every night, even when no ticket broke its SLA. Skip the `page` step when `check-sla` finds no breaches, and keep paging when it finds some. Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "nightly-ticket-report", input: { day: "2026-09-27" } } } ``` ```ts nightly-report.workflow.ts active import { Workflow } from "@frontmcp/sdk"; @Workflow({ name: "nightly-ticket-report", steps: [ { id: "breaches", jobName: "check-sla" }, { id: "page", jobName: "page-on-call", dependsOn: ["breaches"], input: (steps) => ({ tickets: steps.get("breaches").outputs.breaches }), }, ], }) export class NightlyTicketReport {} ``` **Solution file (alternative to the starter):** ```ts nightly-report.workflow.ts solution import { Workflow } from "@frontmcp/sdk"; @Workflow({ name: "nightly-ticket-report", steps: [ { id: "breaches", jobName: "check-sla" }, { id: "page", jobName: "page-on-call", dependsOn: ["breaches"], condition: (steps) => (steps.get("breaches").outputs.breaches as string[]).length > 0, input: (steps) => ({ tickets: steps.get("breaches").outputs.breaches }), }, ], }) export class NightlyTicketReport {} ``` ```ts jobs.ts import { Job, JobContext, z } from "@frontmcp/sdk"; const breachesByDay: Record = { "2026-09-26": ["T-1", "T-2"], "2026-09-27": [] }; export const pages: string[][] = []; @Job({ name: "check-sla", inputSchema: { day: z.string() }, outputSchema: { breaches: z.array(z.string()) } }) export class CheckSla extends JobContext { async execute({ day }: { day: string }) { return { breaches: breachesByDay[day] ?? [] }; } } @Job({ name: "page-on-call", inputSchema: { tickets: z.array(z.string()) }, outputSchema: { paged: z.boolean() } }) export class PageOnCall extends JobContext { async execute({ tickets }: { tickets: string[] }) { pages.push(tickets); return { paged: true }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts condition.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { pages } from "./jobs"; const run = async (mcp: any, day: string) => (await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: { day } })).json(); test("a night without breaches skips `page`", async ({ mcp }) => { pages.length = 0; const { state, result } = await run(mcp, "2026-09-27"); expect(state).toBe("completed"); expect(result.stepResults.page.state).toBe("skipped"); expect(pages).toEqual([]); }); test("a night with breaches still pages on-call", async ({ mcp }) => { const { result } = await run(mcp, "2026-09-26"); expect(result.stepResults.page.state).toBe("completed"); }); ``` **Hint:** A step can decide whether to run. It gets the same `steps` an input function gets. **Solution:** `condition` is called once `breaches` is done, and returning `false` marks `page` as `"skipped"` without running its job, so nobody is paged. The workflow still completes: a skipped step isn't a failure. If another step depended on `page`, it would still run, so it would have to check `steps.get("page").state`. --- # Delegating to Agents > When the client's model shouldn't do every step itself. FrontMCP agents run a model of their own, with their own instructions and tools, so a client can hand off a whole task in one call; skills teach the client's model a procedure instead. Source: https://frontmcp.dev/learn/delegating-to-agents [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Every tool in this course so far does one thing, and the client's model decides what to call next. Some tasks are a string of those decisions. Triaging a ticket means reading it, looking up the customer, choosing a priority and setting it. The client's model can do that one call at a time, if it knows how and you trust it with every tool. Or your server can do it: in FrontMCP, an **agent** is a tool that runs a model of its own, with instructions and tools you choose, so the client's model hands over the whole task in one call. This chapter covers agents that run on your server, and **skills**, the other way to teach a model a procedure, and points out where 1.8 behaves differently from its docs. **In this chapter** - [How to write an agent, and what a client sees of it](https://frontmcp.dev/learn/your-first-agent) - [How to give an agent tools, and what its loop does with them](https://frontmcp.dev/learn/giving-an-agent-tools) - [How to connect an agent to a real model, and what that costs](https://frontmcp.dev/learn/connecting-a-real-model) - [How one agent hands a task to another](https://frontmcp.dev/learn/agents-that-call-agents) - [How to teach the client's model a procedure with a skill](https://frontmcp.dev/learn/teaching-the-model-skills) > **Note** > > Two kinds of agent meet in this chapter's help desk. The people who answer tickets are **support agents**. An **agent** on its own is always a FrontMCP agent, the thing this chapter is about. ## Your first agent An agent is a class, like a tool. `@Agent` gives it a description for the client's model, instructions for its own model, an input schema, and `llm`, the model it runs. It has no `execute()`: FrontMCP sends the input to the model and returns the model's answer. A client sees one tool, `invoke_triage`: Initial call configuration: ```js { tool: "invoke_triage", arguments: { title: "Cannot log in", body: "Since this morning nobody on our team can log in." } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority for a new support ticket, with a one-line reason. Pass the ticket's title and body.", systemInstructions: "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, " + "and say why in one sentence. High is for anything that stops the customer from working.", inputSchema: { title: z.string().describe("The ticket's title"), body: z.string().describe("What the customer wrote"), }, llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. FrontMCP calls // completion() each time the agent needs the model. This one follows a fixed // rule instead of reading the ticket. A real server doesn't need this file. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion(prompt) { const blocked = /can't|cannot|down/i.test(prompt.messages[0].content ?? ""); return { content: blocked ? "High. The customer can't work until this is fixed." : "Normal. Nothing stops the customer from working.", finishReason: "stop", }; }, }; ``` ```ts agent.test.ts import { test, expect } from "@frontmcp/testing"; test("the agent is one tool, `invoke_triage`", async ({ mcp }) => { const [tool] = await mcp.tools.list(); expect(tool).toMatchObject({ name: "invoke_triage", description: "Suggest a priority for a new support ticket, with a one-line reason. Pass the ticket's title and body.", inputSchema: { properties: { title: { type: "string" }, body: { type: "string" } } }, }); }); test("its result is the model's answer", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { title: "Cannot log in", body: "Nobody on our team can log in." }); expect(result.json()).toEqual({ response: "High. The customer can't work until this is fixed." }); }); ``` The Playground can't reach a real model, so every example in this chapter has a `model.example.ts` that plays one: an object with a `completion()` method, which is all FrontMCP asks of a model. It follows a script, so the examples are the same every time you run them. ### [Your First Agent](https://frontmcp.dev/learn/your-first-agent) Learn how to write and register an agent, how a client sees it, what its model is sent and how FrontMCP builds that from the input, and what comes back, checked by `outputSchema`. ## Giving an agent tools With `tools`, an agent's model can look things up and change things, as a support agent would. FrontMCP runs a loop: it asks the model what to do, runs the tools the model asks for, gives it the results, and asks again, until the model answers. The tools are the agent's alone. The client doesn't see them: *[Illustration: An agent's loop. The MCP client makes one call, tools/call invoke_triage with a ticket id. Inside, in a loop of up to 10 turns, triage asks its model for a completion with the prompt and its tools; the model asks for get_ticket; triage runs get_ticket and gets the ticket's title and status; triage asks the model again with the tool result; the model answers. Triage returns that answer to the client as the call's result.]* Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", priority: "none" }], ["T-3", { id: "T-3", title: "Export is slow", priority: "none" }], ]); @Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") } }) class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); return ticket; } } @Tool({ name: "set_priority", description: "Set a ticket's priority.", inputSchema: { id: z.string().describe("Ticket id, like T-1"), priority: z.enum(["high", "normal", "low"]) }, }) class SetPriority extends ToolContext { async execute({ id, priority }: { id: string; priority: "high" | "normal" | "low" }) { const ticket = tickets.get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); ticket.priority = priority; return { id, priority }; } } @Agent({ name: "triage", description: "Triage a support ticket: read it and set its priority. Pass the ticket id.", systemInstructions: "You triage support tickets. Read the ticket with get_ticket, then set its priority with set_priority: " + "high if the customer can't work, normal otherwise. Finish with one sentence that says what you set.", inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket, SetPriority], llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts model.example.ts // Stands in for a real model. It follows the agent's instructions, one step // per request: read the ticket, set the priority, answer. import type { AgentCompletion, AgentLlmAdapter } from "@frontmcp/sdk"; export let requests = 0; export const model: AgentLlmAdapter = { async completion(prompt) { requests++; const { ticketId } = JSON.parse(prompt.messages[0].content ?? "{}"); const [ticket, updated] = prompt.messages.filter((m) => m.role === "tool").map((m) => JSON.parse(m.content ?? "null")); if (!ticket) return callTool("get_ticket", { id: ticketId }); if (!updated) return callTool("set_priority", { id: ticketId, priority: /cannot/i.test(ticket.title) ? "high" : "normal" }); return { content: `${ticketId} is now ${updated.priority} priority.`, finishReason: "stop" }; }, }; function callTool(name: string, args: Record): AgentCompletion { return { content: null, finishReason: "tool_calls", toolCalls: [{ id: `call_${name}`, name, arguments: args }] }; } ``` ```ts tools.test.ts import { test, expect } from "@frontmcp/testing"; import { requests } from "./model.example"; import { tickets } from "./triage.agent"; test("the model asks for two tools, then answers", async ({ mcp }) => { const before = requests; const result = await mcp.tools.call("invoke_triage", { ticketId: "T-3" }); expect(requests - before).toBe(3); expect(result.json()).toEqual({ response: "T-3 is now normal priority." }); expect(tickets.get("T-3")?.priority).toBe("normal"); }); test("the client sees the agent, not its tools", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["invoke_triage"]); expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeError("TOOL_NOT_FOUND"); }); ``` One call to `invoke_triage`, three requests to the agent's model. This loop is what every agent runs: 1. The client's model calls `invoke_triage`, and waits. 2. FrontMCP sends the agent's model its instructions, the input and the agent's tools. 3. The model asks for a tool. FrontMCP runs it and adds the call and its result to the conversation. 4. FrontMCP sends the whole conversation back, and the model decides again, as often as it needs, up to a limit you set. 5. When the model answers instead of asking for a tool, the answer is `invoke_triage`'s result. The client's model sees steps 1 and 5. Everything between, including every tool call and every failed one, happens on your server. ### [Giving an Agent Tools](https://frontmcp.dev/learn/giving-an-agent-tools) Learn how the loop runs step by step, which tools the client and the agent can each see, what the model hears when a tool fails, how to limit the loop, how to add your own steps before and after it, and what a client hears while it runs. ## Connecting a real model Outside the Playground, `llm` names a provider, a model and where the API key is: ```ts triage.agent.ts @Agent({ name: "triage", // … llm: { provider: "anthropic", // or "openai", with the `openai` package model: "claude-opus-5", apiKey: { env: "ANTHROPIC_API_KEY" }, }, }) export class Triage extends AgentContext {} ``` The Playground can't run this: it has no network and no API key. A real model also changes what an agent costs. Every turn of the loop is a request to the provider, paid for by your server's key, carrying the whole conversation so far, and the client waits for all of them. ### [Connecting a Real Model](https://frontmcp.dev/learn/connecting-a-real-model) Learn which packages to install, where the key comes from and what happens when it's missing, exactly what FrontMCP sends Anthropic and OpenAI, how failed requests are retried, and how to see and keep down what each call costs. ## Agents that call agents Billing questions need billing's tools and billing's rules. FrontMCP's docs describe nesting agents and "swarm" settings for this, but in 1.8 neither connects one agent to another. What works is one agent calling another the way it would call any tool, from its own `execute()`: Initial call configuration: ```js { tool: "invoke_triage", arguments: { message: "INV-7 was charged twice." } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { triageModel } from "./model.example"; type Answer = { response?: string; handoff?: "billing"; question?: string }; @Agent({ name: "triage", description: "Answer a customer's message, or hand billing questions to the billing agent.", systemInstructions: "Answer the customer's message. If it's about an invoice or a charge, answer only with " + '{"handoff": "billing", "question": ""}.', inputSchema: { message: z.string().describe("What the customer wrote") }, llm: { adapter: triageModel }, }) export class Triage extends AgentContext { async execute(input: { message: string }) { const answer = (await super.execute(input)) as Answer; // triage's own loop if (answer.handoff === "billing") { const billing = await this.callTool("invoke_billing", { question: answer.question }); return billing.structuredContent as Answer; } return answer; } } ``` ```ts billing.agent.ts import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { billingModel } from "./model.example"; @Agent({ name: "billing", description: "Answer a customer's question about an invoice or a charge.", systemInstructions: "You answer billing questions for a help desk.", inputSchema: { question: z.string().describe("The customer's question") }, llm: { adapter: billingModel }, }) export class Billing extends AgentContext {} ``` ```ts model.example.ts // Stand-ins for the two agents' models. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const triageModel: AgentLlmAdapter = { async completion(prompt) { const message = prompt.messages[0].content ?? ""; if (/invoice|charge|INV-/i.test(message)) { return { content: JSON.stringify({ handoff: "billing", question: message }), finishReason: "stop" }; } return { content: "A support agent will look at this today.", finishReason: "stop" }; }, }; export const billingModel: AgentLlmAdapter = { async completion() { return { content: "The second charge on INV-7 is refunded. It takes 3 to 5 business days.", finishReason: "stop" }; }, }; ``` ```ts handoff.test.ts import { test, expect } from "@frontmcp/testing"; test("a billing question is answered by billing", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { message: "INV-7 was charged twice." }); expect(result.json()).toEqual({ response: "The second charge on INV-7 is refunded. It takes 3 to 5 business days." }); }); test("anything else, triage answers itself", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { message: "The export button is grey." }); expect(result.json()).toEqual({ response: "A support agent will look at this today." }); }); ``` ### [Agents That Call Agents](https://frontmcp.dev/learn/agents-that-call-agents) Learn what two agents on one server see of each other, what nested agents and swarm settings do in 1.8, how to hand a task off from `execute()`, what to do when the other agent fails, and how to stop handoffs that never end. ## Teaching the model skills An agent does a task with your model. A **skill** hands the procedure to the client's model instead: the steps, the tools they use and the rules, which the client's model reads and then follows with your tools. A skill adds no tools; FrontMCP serves it as a document, `SKILL.md`: Initial call configuration: ```js { resource: "skill://refund-duplicate-charge/SKILL.md" } ``` ```ts refund.skill.ts active import { Skill, SkillContext } from "@frontmcp/sdk"; @Skill({ name: "refund-duplicate-charge", description: "Refund a customer who was charged more than once for the same invoice.", instructions: `1. Read the invoice with get_invoice. Refund only if it has more than one charge. 2. Refund every charge after the first with refund_charge. Never refund the first charge.`, tools: ["get_invoice", "refund_charge"], }) export class RefundDuplicateCharge extends SkillContext {} ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_invoice", description: "Get an invoice, with its charges.", inputSchema: { id: z.string() } }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, charges: [{ id: "ch_1", amount: "49 EUR" }, { id: "ch_2", amount: "49 EUR" }] }; } } @Tool({ name: "refund_charge", description: "Refund one charge.", inputSchema: { chargeId: z.string() } }) export class RefundCharge extends ToolContext { async execute({ chargeId }: { chargeId: string }) { return { refunded: chargeId }; } } ``` ```ts skill.test.ts import { test, expect } from "@frontmcp/testing"; test("a skill adds no tools", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["get_invoice", "refund_charge"]); }); test("it's served as a SKILL.md resource", async ({ mcp }) => { const md = (await mcp.resources.read("skill://refund-duplicate-charge/SKILL.md")).text(); expect(md).toContain("name: refund-duplicate-charge"); expect(md).toContain("Never refund the first charge."); }); ``` ### [Teaching the Model Skills](https://frontmcp.dev/learn/teaching-the-model-skills) Learn how to write a skill with instructions, tools, parameters and examples, how a client finds and reads skills, how to search and load them with FrontMCP's own requests, where instructions can come from, and when to write a skill, an agent or a prompt. ## Tool, agent or skill? | If the work… | Write | | --- | --- | | Is one action the model calls and uses the answer of right away | A tool | | Is a procedure the client's model can follow with your tools, once it knows the steps | A skill | | Needs a model of its own, uses tools the client shouldn't have, or should run the same way for every client | An agent | The difference that's easiest to miss is who pays. A skill runs on the client's model. An agent runs on yours, for every client that calls it, so write one when the task is worth that, and keep it behind [authentication](https://frontmcp.dev/learn/authenticating-clients) and [rate limits](https://frontmcp.dev/learn/limiting-calls). ## What's next? Start the chapter with [Your First Agent](https://frontmcp.dev/learn/your-first-agent). To see an agent with a full set of help desk tools in one server, read the [Triage Agent](https://frontmcp.dev/examples/triage-agent) example, and look up every option in the [`@Agent`](https://frontmcp.dev/reference/sdk/agent) and [`@Skill`](https://frontmcp.dev/reference/sdk/skill) references. After this chapter, [Tools with a UI](https://frontmcp.dev/learn/tools-with-a-ui) shows how a tool can answer with a small page the host displays, not just data. --- # Your First Agent > How to write a FrontMCP agent with @Agent and AgentContext, register it on an app, call it through its invoke_ tool, and read what its model is sent and what comes back. Source: https://frontmcp.dev/learn/your-first-agent [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Every tool so far does one thing and answers, and the client's model decides what happens next. A FrontMCP **agent** moves some of that deciding into your server. It's a tool that runs a model of its own, with instructions you write, so the client's model can hand it a whole task in one call. This lesson writes an agent that suggests a priority for a new support ticket, calls it the way a client would, and looks at what its model is sent and what comes back. **You will learn** - How to write an agent with `@Agent` and `AgentContext`, and register it on an app - How a client sees an agent: one `invoke_` tool - What the agent's model is sent, and how FrontMCP builds it from the input - What comes back, and how `outputSchema` checks it - How a scripted model stands in for a real one ## Writing an agent New tickets arrive with a title and whatever the customer wrote, and before anyone works on one, a support agent gives it a priority. That's a judgment call on free text, which a model makes well. Here's an agent that makes it. Open the **Tests** tab: Initial call configuration: ```js { tool: "invoke_triage", arguments: { title: "Cannot log in", body: "Since this morning nobody on our team can log in." } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority for a new support ticket, with a one-line reason. Pass the ticket's title and body.", systemInstructions: "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, " + "and say why in one sentence. High is for anything that stops the customer from working.", inputSchema: { title: z.string().describe("The ticket's title"), body: z.string().describe("What the customer wrote"), }, llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. FrontMCP calls // completion() each time the agent needs the model, and treats what it returns // as the model's reply. This one follows a fixed rule instead of reading the // ticket. A real server doesn't need this file. import type { AgentLlmAdapter, AgentPrompt } from "@frontmcp/sdk"; /** Every prompt the model was sent, oldest first. */ export const prompts: AgentPrompt[] = []; export const model: AgentLlmAdapter = { async completion(prompt) { prompts.push(structuredClone(prompt)); const ticket = prompt.messages[0].content ?? ""; const blocked = /can't|cannot|can not|down/i.test(ticket); return { content: blocked ? "High. The customer can't work until this is fixed." : "Normal. Nothing stops the customer from working.", finishReason: "stop", }; }, }; ``` ```ts triage.test.ts import { test, expect } from "@frontmcp/testing"; test("the agent is one tool, `invoke_triage`", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t) => t.name); expect(names).toEqual(["invoke_triage"]); }); test("it answers with its model's reply", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { title: "Cannot log in", body: "Since this morning nobody on our team can log in.", }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ response: "High. The customer can't work until this is fixed." }); }); test("a ticket that blocks nobody is Normal", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { title: "New invoice address", body: "Please send our invoices to the new office from next month.", }); expect(result.json()).toEqual({ response: "Normal. Nothing stops the customer from working." }); }); ``` An agent is a class, like a tool: - **`@Agent({ name, description, systemInstructions, inputSchema, llm })`** describes it. `description` is for the client's model, which decides when to call the agent. `systemInstructions` are for the agent's own model, which does the work. `inputSchema` is a Zod shape, as on a tool. - **`llm`** says which model the agent runs. Here it's `{ adapter: model }`, an object you write; the lesson [Connecting a Real Model](https://frontmcp.dev/learn/connecting-a-real-model) replaces it with OpenAI or Anthropic. - **`AgentContext`** is the base class, and there's no `execute()`. FrontMCP's own sends the input to the model and returns its answer. You can [override it](https://frontmcp.dev/learn/giving-an-agent-tools#before-and-after-the-loop) when you need to. - **`agents: [Triage]`** on `@App` registers it. > **Note** > > The Playground runs in your browser, with no network and no API key, so it can't reach a real model. Every example in this chapter has a `model.example.ts` file that plays one: an object with a `completion()` method, which is all FrontMCP asks of a model. It follows a fixed rule instead of thinking, and records what it was sent so the tests can check it. With a [real model](https://frontmcp.dev/learn/connecting-a-real-model), you don't need it. ## How a client sees an agent Open the **Capabilities** tab of the example above. The agent is a tool called `invoke_triage`, and nothing about it says there's a model behind it. A client lists it and calls it like any other tool: ```ts listing.test.ts active import { test, expect } from "@frontmcp/testing"; test("`invoke_triage` is built from the agent's options", async ({ mcp }) => { const [tool] = await mcp.tools.list(); expect(tool).toEqual({ name: "invoke_triage", description: "Suggest a priority for a new support ticket, with a one-line reason. Pass the ticket's title and body.", inputSchema: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: { title: { type: "string", description: "The ticket's title" }, body: { type: "string", description: "What the customer wrote" }, }, required: ["title", "body"], }, annotations: { title: "triage", readOnlyHint: false, openWorldHint: true }, }); }); test("input that doesn't match `inputSchema` never reaches the model", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { title: "Cannot log in" }); expect(result).toBeError("INVALID_INPUT"); }); ``` ```ts triage.agent.ts import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority for a new support ticket, with a one-line reason. Pass the ticket's title and body.", systemInstructions: "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, " + "and say why in one sentence. High is for anything that stops the customer from working.", inputSchema: { title: z.string().describe("The ticket's title"), body: z.string().describe("What the customer wrote"), }, llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts model.example.ts // Stands in for a real model. See the first example on this page. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion() { return { content: "High. The customer can't work until this is fixed.", finishReason: "stop" }; }, }; ``` From here on, examples leave out `main.ts`: when an example exports an agent but no app, the Playground registers it in one for you, as it does tools. | `@Agent` option | What the client sees | | --- | --- | | `name` | The tool's name, `invoke_` and the name, and its `annotations.title`. | | `description` | The tool's description. | | `inputSchema` | The tool's input schema. Input that doesn't match fails with `INVALID_INPUT` before the model is asked, as for any tool. | | `systemInstructions` | Nothing. They go to the agent's model only. | FrontMCP marks every agent's tool `readOnlyHint: false` and `openWorldHint: true`, whatever the agent does. The [`@Agent` reference](https://frontmcp.dev/reference/sdk/agent) has the rest of the naming rules, like `id`, which names the tool instead of `name`. > **Pitfall: Don't leave out the description** > > Without a `description`, FrontMCP makes one up from the start of the system instructions: > > ```ts triage.agent.ts active > import { Agent, AgentContext, z } from "@frontmcp/sdk"; > import { model } from "./model.example"; > > @Agent({ > name: "triage", > // 🚩 no description > systemInstructions: > "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, " + > "and say why in one sentence. High is for anything that stops the customer from working.", > inputSchema: { title: z.string(), body: z.string() }, > llm: { adapter: model }, > }) > export class Triage extends AgentContext {} > ``` > > ```ts model.example.ts > // Stands in for a real model. See the first example on this page. > import type { AgentLlmAdapter } from "@frontmcp/sdk"; > > export const model: AgentLlmAdapter = { > async completion() { > return { content: "High. The customer can't work until this is fixed.", finishReason: "stop" }; > }, > }; > ``` > > ```ts description.test.ts > import { test, expect } from "@frontmcp/testing"; > > test("🚩 the description is the instructions' first 100 characters", async ({ mcp }) => { > const [tool] = await mcp.tools.list(); > expect(tool.description).toBe( > "Invoke the triage agent. You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, and say why in ...", > ); > }); > ``` > > The system instructions are written for the agent's model, as if it were doing the work. The client's model needs something else: what the agent is for and what to pass it, as in any [tool description](https://frontmcp.dev/learn/writing-descriptions-agents-understand). Write both. ## What the agent's model is sent When `invoke_triage` is called, FrontMCP calls the adapter's `completion(prompt, tools)`. `prompt` is the whole conversation so far, and at the start that's the system instructions and one message from the "user", made from the input. The model in this example records every prompt, and the test reads the first: Initial call configuration: ```js { tool: "invoke_triage", arguments: { title: "Cannot log in", body: "Since this morning nobody on our team can log in." } } ``` ```ts prompt.test.ts active import { test, expect } from "@frontmcp/testing"; import { prompts } from "./model.example"; test("the model gets the instructions and the input as JSON", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { title: "Cannot log in", body: "Since this morning nobody on our team can log in." }); expect(prompts[0]).toEqual({ system: "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, " + "and say why in one sentence. High is for anything that stops the customer from working.", messages: [{ role: "user", content: '{"title":"Cannot log in","body":"Since this morning nobody on our team can log in."}' }], }); }); ``` ```ts triage.agent.ts import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority for a new support ticket, with a one-line reason. Pass the ticket's title and body.", systemInstructions: "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, " + "and say why in one sentence. High is for anything that stops the customer from working.", inputSchema: { title: z.string().describe("The ticket's title"), body: z.string().describe("What the customer wrote"), }, llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. FrontMCP calls // completion() each time the agent needs the model, and treats what it returns // as the model's reply. This one follows a fixed rule instead of reading the // ticket. A real server doesn't need this file. import type { AgentLlmAdapter, AgentPrompt } from "@frontmcp/sdk"; /** Every prompt the model was sent, oldest first. */ export const prompts: AgentPrompt[] = []; export const model: AgentLlmAdapter = { async completion(prompt) { prompts.push(structuredClone(prompt)); const ticket = prompt.messages[0].content ?? ""; const blocked = /can't|cannot|can not|down/i.test(ticket); return { content: blocked ? "High. The customer can't work until this is fixed." : "Normal. Nothing stops the customer from working.", finishReason: "stop", }; }, }; ``` - **`system`** is `systemInstructions`, or `""` if you didn't set any. - **`messages`** starts with one `{ role: "user", content }`. The model reads the input as JSON, so field names like `title` and `body` are part of what it reads: name them for the model. What the model sends back is the reply. `completion()` returns `{ content, finishReason }`, and `finishReason: "stop"` means the model is done, so its `content` is the agent's answer. The other value that matters is `"tool_calls"`, which the [next lesson](https://frontmcp.dev/learn/giving-an-agent-tools) covers. > **Pitfall: A `query` or `message` field hides the rest of the input** > > When the input has a field called `query`, `message`, `prompt` or `input`, and it isn't empty, FrontMCP sends the model that field alone, as it is, and drops everything else: > > Initial call configuration: > > ```js > { tool: "invoke_triage", arguments: { message: "Nothing works since your update.", customer: "Acme" } } > ``` > > ```ts triage.agent.ts active > import { Agent, AgentContext, z } from "@frontmcp/sdk"; > import { model } from "./model.example"; > > @Agent({ > name: "triage", > description: "Suggest a priority for a new support ticket. Pass what the customer wrote, and who they are.", > systemInstructions: "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, and say why.", > inputSchema: { > message: z.string().describe("What the customer wrote"), > customer: z.string().describe("The customer's company"), > }, > llm: { adapter: model }, > }) > export class Triage extends AgentContext {} > ``` > > ```ts model.example.ts > // Stands in for a real model. It records what it was sent. > import type { AgentLlmAdapter, AgentPrompt } from "@frontmcp/sdk"; > > export const prompts: AgentPrompt[] = []; > > export const model: AgentLlmAdapter = { > async completion(prompt) { > prompts.push(structuredClone(prompt)); > return { content: "High. The customer can't work until this is fixed.", finishReason: "stop" }; > }, > }; > ``` > > ```ts message.test.ts > import { test, expect } from "@frontmcp/testing"; > import { prompts } from "./model.example"; > > test("🚩 the model never hears about the customer", async ({ mcp }) => { > await mcp.tools.call("invoke_triage", { message: "Nothing works since your update.", customer: "Acme" }); > expect(prompts[0].messages).toEqual([{ role: "user", content: "Nothing works since your update." }]); > }); > ``` > > That's handy for an agent that takes a single question as `query`, and a trap for one that takes more. Either give the fields other names, or write the message yourself: `AgentContext` builds it in `protected buildUserMessage(input)`, which returns a string, and you can override it. The [third challenge](https://frontmcp.dev/learn/your-first-agent#try-some-challenges) does. ## What comes back The agent's answer is the model's last reply. A reply that's text arrives as `{ response: text }`, as in the first example. A reply that's a JSON object arrives as that object. So when a client needs fields, not prose, ask the model for JSON, and declare what it should look like with `outputSchema`: Initial call configuration: ```js { tool: "invoke_triage", arguments: { title: "Cannot log in", body: "Since this morning nobody on our team can log in." } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority for a new support ticket, with a one-line reason. Pass the ticket's title and body.", systemInstructions: "You triage tickets for a help desk. Answer with JSON only: " + '{"priority": "high" | "normal" | "low", "reason": one sentence}. ' + "High is for anything that stops the customer from working.", inputSchema: { title: z.string().describe("The ticket's title"), body: z.string().describe("What the customer wrote"), }, outputSchema: { priority: z.enum(["high", "normal", "low"]), reason: z.string(), }, llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts model.example.ts // Stands in for a real model. It answers in JSON, as the instructions ask, but // when a customer shouts, it makes up a priority of its own, and a question // makes it forget the JSON, as models do now and then. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion(prompt) { const ticket = prompt.messages[0].content ?? ""; if (ticket.includes("?")) return { content: "Normal: it's a question.", finishReason: "stop" }; const priority = /!!!/.test(ticket) ? "urgent" : /can't|cannot|down/i.test(ticket) ? "high" : "normal"; const reason = priority === "normal" ? "Nothing stops the customer from working." : "The customer can't work."; return { content: JSON.stringify({ priority, reason }), finishReason: "stop" }; }, }; ``` ```ts output.test.ts import { test, expect } from "@frontmcp/testing"; test("a JSON reply is the result", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { title: "Cannot log in", body: "Nobody on our team can log in." }); expect(result.json()).toEqual({ priority: "high", reason: "The customer can't work." }); expect(result.raw.structuredContent).toEqual(result.json()); }); test("`outputSchema` is in tools/list", async ({ mcp }) => { const [tool] = await mcp.tools.list(); expect(tool.outputSchema).toMatchObject({ properties: { priority: { enum: ["high", "normal", "low"] }, reason: { type: "string" } }, required: ["priority", "reason"], }); }); test("a reply that doesn't match fails the call", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { title: "DOWN AGAIN", body: "Fix it now!!!" }); expect(result).toBeError("INVALID_OUTPUT"); expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at priority)"); }); test("so does a reply that isn't JSON", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { title: "Export", body: "How do I export my tickets?" }); expect(result).toBeError("INVALID_OUTPUT"); expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at priority)"); }); ``` FrontMCP checks the model's answer against `outputSchema` as it checks a tool's result. A reply with a priority the help desk doesn't have fails the call with `INVALID_OUTPUT`, and the text names the field that didn't match, `priority`. So does a reply that isn't JSON at all, because it arrives as `{ response }`, which has no `priority`. That's better than handing the client's model a priority nobody uses, but the call still failed, and whoever called it has to try again. Say exactly what you want in `systemInstructions`, and keep `outputSchema` as the check. Changed in 1.8.7: FrontMCP 1.8.5 and 1.8.6 sent this failure as a `TOOL_EXECUTION_ERROR` with a stack trace in the development text, where 1.8.4 sent `INVALID_OUTPUT`. In 1.8.7 it's `INVALID_OUTPUT` again, with only the one line. ## Recap - An agent is a class that extends `AgentContext`, decorated with `@Agent({ name, description, systemInstructions, inputSchema, llm })`, and registered with `@App({ agents: [...] })`. It needs no `execute()`. - A client sees one tool, `invoke_`, with the agent's `description` and `inputSchema`. The system instructions go to the agent's model only, so write a description for the client's model too. - `llm: { adapter }` takes any object with `completion(prompt, tools)`. It's called with `{ system, messages }` and returns `{ content, finishReason }`; `"stop"` ends the agent's work. - The model is sent the input as JSON, unless the input has a `query`, `message`, `prompt` or `input` field, which is then sent alone. Override `buildUserMessage(input)` to write the message yourself. - A text reply arrives as `{ response }`, a JSON object as itself. `outputSchema` is advertised and checked: a reply that doesn't match fails the call. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Register the agent, and describe it The `triage` agent is written, but a client connected to this server only finds `search_tickets`. Register the agent, and give it a description that tells the client's model what it's for: suggesting a priority for a ticket. ```ts main.ts active import { Agent, AgentContext, App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", systemInstructions: "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, and say why.", inputSchema: { title: z.string(), body: z.string() }, llm: { adapter: model }, }) class Triage extends AgentContext {} @Tool({ name: "search_tickets", description: "Search tickets by words in their title", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [] }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` **Solution file (alternative to the starter):** ```ts main.ts solution import { Agent, AgentContext, App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority for a new support ticket, with a one-line reason. Pass the ticket's title and body.", systemInstructions: "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, and say why.", inputSchema: { title: z.string(), body: z.string() }, llm: { adapter: model }, }) class Triage extends AgentContext {} @Tool({ name: "search_tickets", description: "Search tickets by words in their title", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [] }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets], agents: [Triage] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion(prompt) { const blocked = /can't|cannot|down/i.test(prompt.messages[0].content ?? ""); return { content: blocked ? "High. The customer can't work." : "Normal. Nothing is blocked.", finishReason: "stop" }; }, }; ``` **Hidden fixture/test file (runs with the example):** ```ts register.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`invoke_triage` is listed", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("invoke_triage"); }); test("its description mentions a priority", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "invoke_triage"); expect(tool?.description).toMatch(/priority/i); expect(tool?.description).not.toMatch(/^Invoke the triage agent/); }); test("it triages a ticket", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { title: "Cannot log in", body: "Nobody can log in." }); expect(result.json()).toEqual({ response: "High. The customer can't work." }); }); ``` **Hint:** `@App` has a list for agents next to `tools`. The text the client's model reads is a different option from the one the agent's model reads. **Solution:** `agents: [Triage]` on `@App` adds the `invoke_triage` tool. Without a `description`, FrontMCP would have described it as "Invoke the triage agent." and the start of the system instructions, which are written for the agent's model. The description says what the tool is for and what to pass, for the model deciding whether to call it. ### Challenge: Keep the team the model suggests The model now also suggests which team should take the ticket, and says so in its JSON. But the result only has `priority` and `reason`: FrontMCP drops fields that `outputSchema` doesn't list. Add `team`, which is `"accounts"`, `"billing"` or `"product"`, so it reaches the client and clients know it's there. Initial call configuration: ```js { tool: "invoke_triage", arguments: { title: "Cannot log in", body: "Nobody on our team can log in." } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority and a team for a new support ticket. Pass the ticket's title and body.", systemInstructions: "You triage tickets for a help desk. Answer with JSON only: " + '{"priority": "high" | "normal" | "low", "team": "accounts" | "billing" | "product", "reason": one sentence}.', inputSchema: { title: z.string(), body: z.string() }, outputSchema: { priority: z.enum(["high", "normal", "low"]), reason: z.string(), }, llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` **Solution file (alternative to the starter):** ```ts triage.agent.ts solution import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority and a team for a new support ticket. Pass the ticket's title and body.", systemInstructions: "You triage tickets for a help desk. Answer with JSON only: " + '{"priority": "high" | "normal" | "low", "team": "accounts" | "billing" | "product", "reason": one sentence}.', inputSchema: { title: z.string(), body: z.string() }, outputSchema: { priority: z.enum(["high", "normal", "low"]), team: z.enum(["accounts", "billing", "product"]), reason: z.string(), }, llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion(prompt) { const ticket = prompt.messages[0].content ?? ""; const answer = /invoice|refund|charge/i.test(ticket) ? { priority: "normal", team: "billing", reason: "A billing question, and nothing is blocked." } : { priority: "high", team: "accounts", reason: "The customer can't log in." }; return { content: JSON.stringify(answer), finishReason: "stop" }; }, }; ``` **Hidden fixture/test file (runs with the example):** ```ts team.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("the result has the `team`", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { title: "Cannot log in", body: "Nobody on our team can log in." }); expect(result.json()).toEqual({ priority: "high", team: "accounts", reason: "The customer can't log in." }); }); test("`team` is in the output schema, with its three values", async ({ mcp }) => { const [tool] = await mcp.tools.list(); expect(tool.outputSchema?.properties?.team).toMatchObject({ enum: ["accounts", "billing", "product"] }); }); test("a billing ticket goes to `billing`", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { title: "Wrong invoice", body: "We were charged twice." }); expect(result.json().team).toBe("billing"); }); ``` **Hint:** The agent's result goes through the same check as a tool's: only what `outputSchema` describes gets through. **Solution:** `team: z.enum(["accounts", "billing", "product"])` in `outputSchema` lets the field through, advertises it in `tools/list`, and fails the call if the model ever names a team that doesn't exist. The instructions already asked for it; the schema is what makes FrontMCP keep it. ### Challenge: Tell the model who the customer is This agent takes the customer's `message` and their `plan`, because a customer on the `enterprise` plan gets High for anything that blocks them. But the model only ever gets the message. Make the user message it's sent contain both, for example `Plan: enterprise. Message: Nothing works since your update.`, without changing the agent's input schema. Initial call configuration: ```js { tool: "invoke_triage", arguments: { message: "Nothing works since your update.", plan: "enterprise" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority for a new support ticket. Pass what the customer wrote, and their plan.", systemInstructions: "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, and say why.", inputSchema: { message: z.string().describe("What the customer wrote"), plan: z.enum(["free", "business", "enterprise"]).describe("The customer's plan"), }, llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` **Solution file (alternative to the starter):** ```ts triage.agent.ts solution import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; type Input = { message: string; plan: "free" | "business" | "enterprise" }; @Agent({ name: "triage", description: "Suggest a priority for a new support ticket. Pass what the customer wrote, and their plan.", systemInstructions: "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, and say why.", inputSchema: { message: z.string().describe("What the customer wrote"), plan: z.enum(["free", "business", "enterprise"]).describe("The customer's plan"), }, llm: { adapter: model }, }) export class Triage extends AgentContext { protected buildUserMessage({ message, plan }: Input) { return `Plan: ${plan}. Message: ${message}`; } } ``` ```ts model.example.ts // Stands in for a real model. It records what it was sent. import type { AgentLlmAdapter, AgentPrompt } from "@frontmcp/sdk"; export const prompts: AgentPrompt[] = []; export const model: AgentLlmAdapter = { async completion(prompt) { prompts.push(structuredClone(prompt)); const ticket = prompt.messages[0].content ?? ""; return { content: /enterprise/.test(ticket) ? "High. An enterprise customer is blocked." : "Normal.", finishReason: "stop" }; }, }; ``` **Hidden fixture/test file (runs with the example):** ```ts plan.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { prompts } from "./model.example"; test("the model is sent the plan", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { message: "Nothing works since your update.", plan: "enterprise" }); expect(prompts.at(-1)?.messages[0].content).toContain("enterprise"); }); test("and the message", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { message: "The export button is grey.", plan: "free" }); const content = prompts.at(-1)?.messages[0].content; expect(content).toContain("The export button is grey."); expect(content).toContain("free"); }); test("the input schema still has `message` and `plan`", async ({ mcp }) => { const [tool] = await mcp.tools.list(); expect(Object.keys(tool.inputSchema.properties ?? {}).sort()).toEqual(["message", "plan"]); }); test("an enterprise customer gets High", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { message: "Nothing works since your update.", plan: "enterprise" }); expect(result.json()).toEqual({ response: "High. An enterprise customer is blocked." }); }); ``` **Hint:** Because the input has a field called `message`, FrontMCP sends that field alone. `AgentContext` has a method that builds the user message, and a subclass can override it. **Solution:** Overriding `buildUserMessage(input)` replaces FrontMCP's rule: whatever string it returns is the user message. Writing it yourself also lets you phrase it for the model, rather than sending raw JSON. Renaming `message` would have worked too, but it changes what clients send, which the challenge ruled out. --- # Giving an Agent Tools > Give a FrontMCP agent a limited tool set, follow its execution loop and handle tool failures before connecting a real model. Source: https://frontmcp.dev/learn/giving-an-agent-tools [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 The triage agent in the [last lesson](https://frontmcp.dev/learn/your-first-agent) could only judge what it was handed. A support agent triaging a ticket looks things up first: the ticket, the customer and their plan, and then sets the priority in the help desk. Give an agent tools, and its model can work the same way. FrontMCP runs a loop: it asks the model what to do, runs the tools the model asks for, sends back the results, and asks again, until the model answers. This lesson gives the triage agent tools, follows that loop step by step, and covers what happens when a tool fails, when the model won't stop, and how to add steps of your own. **You will learn** - How to give an agent tools, and which of them the client can see - How the loop runs: tool calls, results and the final answer - What the model hears when a tool fails - How to limit the loop with `maxIterations` and `timeout` - How to add your own steps before and after the loop, and report progress ## An agent with tools This agent takes a ticket id. To triage it, its model needs three tools: one to read the ticket, one to look up the customer, and one to set the priority. They're ordinary tools, and `tools` on `@Agent` hands them to the agent. Open the **Tests** tab: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; import { GetCustomer, GetTicket, SetPriority } from "./tools"; @Agent({ name: "triage", description: "Triage a support ticket: read it, look up its customer, and set its priority. Pass the ticket id.", systemInstructions: "You triage support tickets. Read the ticket with get_ticket and look up its customer with get_customer. " + "Then set its priority with set_priority: high if the customer is on the enterprise plan or can't work, " + "low if they're on the free plan, normal otherwise. Finish with one sentence that says what you set.", inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket, GetCustomer, SetPriority], llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts tools.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { customers, tickets } from "./store"; @Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); return ticket; } } @Tool({ name: "get_customer", description: "Get a customer by id: their name and plan.", inputSchema: { id: z.string().describe("Customer id, like C-1") }, }) export class GetCustomer extends ToolContext { async execute({ id }: { id: string }) { const customer = customers.get(id); if (!customer) throw new PublicMcpError(`There's no customer ${id}.`); return customer; } } @Tool({ name: "set_priority", description: "Set a ticket's priority.", inputSchema: { id: z.string().describe("Ticket id, like T-1"), priority: z.enum(["high", "normal", "low"]), }, }) export class SetPriority extends ToolContext { async execute({ id, priority }: { id: string; priority: "high" | "normal" | "low" }) { const ticket = tickets.get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); ticket.priority = priority; return { id, priority }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts store.ts export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", customerId: "C-1", status: "open", priority: "none" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", customerId: "C-2", status: "open", priority: "none" }], ["T-3", { id: "T-3", title: "Export is slow", customerId: "C-3", status: "open", priority: "none" }], ]); export const customers = new Map([ ["C-1", { id: "C-1", name: "Acme", plan: "business" }], ["C-2", { id: "C-2", name: "Globex", plan: "enterprise" }], ["C-3", { id: "C-3", name: "Initech", plan: "free" }], ]); ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. It follows the // steps in the agent's instructions, one per request: read the ticket, look up // the customer, set the priority, answer. A real model works the steps out // itself, from the instructions and the tools it's offered. A real server // doesn't need this file. import type { AgentCompletion, AgentLlmAdapter, AgentPrompt, AgentToolDefinition } from "@frontmcp/sdk"; /** Every request FrontMCP made to the model, oldest first. */ export const requests: { prompt: AgentPrompt; tools?: AgentToolDefinition[] }[] = []; export const model: AgentLlmAdapter = { async completion(prompt, tools) { requests.push(structuredClone({ prompt, tools })); const { ticketId } = JSON.parse(prompt.messages[0].content ?? "{}"); const results = prompt.messages.filter((m) => m.role === "tool").map((m) => JSON.parse(m.content ?? "null")); const [ticket, customer, updated] = results; if (!ticket) return callTool("get_ticket", { id: ticketId }); if (!customer) return callTool("get_customer", { id: ticket.customerId }); if (!updated) return callTool("set_priority", { id: ticket.id, priority: choose(ticket, customer) }); return { content: `${ticket.id} is now ${updated.priority} priority.`, finishReason: "stop" }; }, }; function choose(ticket: { title: string }, customer: { plan: string }) { if (customer.plan === "enterprise" || /can't|cannot/i.test(ticket.title)) return "high"; return customer.plan === "free" ? "low" : "normal"; } function callTool(name: string, args: Record): AgentCompletion { return { content: null, finishReason: "tool_calls", toolCalls: [{ id: `call_${name}`, name, arguments: args }] }; } ``` ```ts triage.test.ts import { test, expect } from "@frontmcp/testing"; import { tickets } from "./store"; test("the agent sets the priority, and says so", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json()).toEqual({ response: "T-1 is now high priority." }); expect(tickets.get("T-1")?.priority).toBe("high"); }); test("an enterprise customer's ticket is high", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { ticketId: "T-2" }); expect(tickets.get("T-2")?.priority).toBe("high"); }); test("a free customer's ticket is low", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { ticketId: "T-3" }); expect(tickets.get("T-3")?.priority).toBe("low"); }); ``` ```ts loop.test.ts import { test, expect } from "@frontmcp/testing"; import { requests } from "./model.example"; test("the model is offered the agent's tools, with JSON Schemas", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); const tools = requests[0].tools ?? []; expect(tools.map((t) => t.name)).toEqual(["get_ticket", "get_customer", "set_priority"]); expect(tools[0]).toEqual({ name: "get_ticket", description: "Get a support ticket by its id.", parameters: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: { id: { type: "string", description: "Ticket id, like T-1" } }, required: ["id"], }, }); }); test("each tool call and its result join the conversation", async ({ mcp }) => { const before = requests.length; await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(requests.length - before).toBe(4); const ticket = expect.stringMatching(/^\{"id":"T-1","title":"Cannot log in","customerId":"C-1"/); expect(requests.at(-1)?.prompt.messages).toEqual([ { role: "user", content: '{"ticketId":"T-1"}' }, { role: "assistant", content: null, toolCalls: [{ id: "call_get_ticket", name: "get_ticket", arguments: { id: "T-1" } }] }, { role: "tool", content: ticket, toolCallId: "call_get_ticket", name: "get_ticket" }, { role: "assistant", content: null, toolCalls: [{ id: "call_get_customer", name: "get_customer", arguments: { id: "C-1" } }] }, { role: "tool", content: '{"id":"C-1","name":"Acme","plan":"business"}', toolCallId: "call_get_customer", name: "get_customer" }, { role: "assistant", content: null, toolCalls: [{ id: "call_set_priority", name: "set_priority", arguments: { id: "T-1", priority: "high" } }] }, { role: "tool", content: '{"id":"T-1","priority":"high"}', toolCallId: "call_set_priority", name: "set_priority" }, ]); }); ``` The tools are written like any other; nothing in them knows an agent calls them. What's new is on `@Agent`: - **`tools: [GetTicket, GetCustomer, SetPriority]`** gives the agent's model these tools, and only these. - **`systemInstructions`** say which tool is for what, and in what order. A real model reads the tools' descriptions too, but the instructions are where you say how a support agent would go about it. The model in `model.example.ts` follows those instructions to the letter: its replies are the ones a real model would make on a good day, one step at a time. ## The loop, step by step One call to `invoke_triage` made four requests to the model, and the model in that example recorded each one. Open `loop.test.ts` in it: each request carries the whole conversation so far, and the test reads the last. This is the loop every agent runs: 1. FrontMCP sends the model the system instructions, the conversation, and the agent's tools, each as a name, a description and its input as JSON Schema. 2. The model replies with `finishReason: "tool_calls"` and a list of `toolCalls`, each with an `id` it makes up, a tool `name` and `arguments`. 3. FrontMCP adds that reply to the conversation, runs each tool call, and adds one `tool` message per call: the result as JSON, with the call's `id` as `toolCallId`. 4. It sends the longer conversation back to the model, and the model decides what to do next. 5. The first reply that isn't a tool call ends the loop. Its `content` is the agent's answer, as in the [last lesson](https://frontmcp.dev/learn/your-first-agent#what-comes-back). *[Illustration: An agent's loop. The MCP client makes one call, tools/call invoke_triage with a ticket id. Inside, in a loop of up to 10 turns, triage asks its model for a completion with the prompt and its tools; the model asks for get_ticket; triage runs get_ticket and gets the ticket's title and status; triage asks the model again with the tool result; the model answers. Triage returns that answer to the client as the call's result.]* Each request carries everything before it. By the fourth, the model is reading the ticket and the customer again, along with every tool's schema. That's what a model needs to decide the next step, and it's also what you pay for with a [real model](https://frontmcp.dev/learn/connecting-a-real-model#cost-and-latency). The tools' input is checked against their `inputSchema`, as for a client's call. When one reply asks for several tools, FrontMCP runs them one after another, in the order the model gave them; `execution` has no option to run them in parallel. ## What the client sees An agent's tools belong to the agent. They aren't in `tools/list`, and a client that calls one by name gets `TOOL_NOT_FOUND`. It works the other way too: by default, the agent's model is offered its own tools and nothing else, not even the app's: ```ts main.ts active import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title.", inputSchema: { query: z.string() }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [{ id: "T-1", title: "Cannot log in" }].filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets], agents: [Triage] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts visibility.test.ts import { test, expect } from "@frontmcp/testing"; import { Agent, AgentContext, App, FrontMcpInstance, z } from "@frontmcp/sdk"; import { SearchTickets } from "./main"; import { model, requests } from "./model.example"; import { GetTicket } from "./triage.agent"; test("the client sees the agent and the app's tool", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t) => t.name); expect(names).toEqual(["invoke_triage", "search_tickets"]); }); test("the client can't call the agent's tools", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result).toBeError("TOOL_NOT_FOUND"); expect(result.text()).toBe('Tool "get_ticket" not found'); }); test("the agent's model isn't offered the app's tools", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(requests[0].tools?.map((t) => t.name)).toEqual(["get_ticket", "set_priority"]); }); test("unless the agent sets `inheritParentTools`", async () => { @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket], execution: { inheritParentTools: true }, llm: { adapter: model } }) class Triage extends AgentContext {} @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets], agents: [Triage] }) class HelpDeskApp {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); try { requests.length = 0; await server.callTool("invoke_triage", { ticketId: "T-1" }); expect(requests[0].tools?.map((t) => t.name)).toEqual(["get_ticket", "search_tickets"]); } finally { await server.dispose(); } }); ``` ```ts triage.agent.ts import { Agent, AgentContext, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; const tickets = new Map([["T-1", { id: "T-1", title: "Cannot log in", priority: "none" }]]); @Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); return ticket; } } @Tool({ name: "set_priority", description: "Set a ticket's priority.", inputSchema: { id: z.string(), priority: z.enum(["high", "normal", "low"]) } }) class SetPriority extends ToolContext { async execute({ id, priority }: { id: string; priority: "high" | "normal" | "low" }) { return { id, priority }; } } @Agent({ name: "triage", description: "Triage a support ticket and set its priority. Pass the ticket id.", systemInstructions: "You triage support tickets. Read the ticket with get_ticket, then set its priority with set_priority.", inputSchema: { ticketId: z.string() }, tools: [GetTicket, SetPriority], llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts model.example.ts // Stands in for a real model. It records every request, and answers at once. import type { AgentLlmAdapter, AgentPrompt, AgentToolDefinition } from "@frontmcp/sdk"; export const requests: { prompt: AgentPrompt; tools?: AgentToolDefinition[] }[] = []; export const model: AgentLlmAdapter = { async completion(prompt, tools) { requests.push(structuredClone({ prompt, tools })); return { content: "T-1 is now high priority.", finishReason: "stop" }; }, }; ``` That split is often what you want. `set_priority` changes the help desk; with it inside the agent, a client can only set a priority by going through triage and its rules. When both should have a tool, like a read-only `get_ticket`, list the same class in `@App({ tools })` and in `@Agent({ tools })`. To offer the model every tool of the app, set `execution: { inheritParentTools: true }` on the agent: its model then gets the app's tools besides its own, other agents excepted, and they run as a client's call to them would. (New in 1.9: before, the option had no effect.) An agent that declares `resources` or `prompts` of its own also offers its model tools to read them, `list_resources` and `read_resource`, `list_prompts` and `get_prompt`: see [Reading the agent's resources and prompts](https://frontmcp.dev/reference/sdk/agent#reading-the-agents-resources-and-prompts). (New in 1.9.4.) ### Which providers an agent's tools see A tool inside an agent gets [providers](https://frontmcp.dev/learn/sharing-state-with-providers) as the app's own tools do: the app's and the server's. Here `TicketStore` is on the app, and `get_ticket` is both the client's and the agent's. Both calls find it: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts main.ts active import { Agent, AgentContext, App, FrontMcp, Provider, Tool, ToolContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Provider({ name: "TicketStore" }) export class TicketStore { find(id: string) { return id === "T-1" ? { id, title: "Cannot log in" } : undefined; } } @Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return this.get(TicketStore).find(id); } } @Agent({ name: "triage", description: "Triage a support ticket. Pass the ticket id.", inputSchema: { ticketId: z.string() }, tools: [GetTicket], llm: { adapter: model }, }) export class Triage extends AgentContext {} @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], agents: [Triage], providers: [TicketStore] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts model.example.ts // Stands in for a real model. It reads the ticket, and repeats what it heard. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion(prompt) { const heard = prompt.messages.find((m) => m.role === "tool"); if (heard) return { content: `Heard: ${heard.content}`, finishReason: "stop" }; return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: "T-1" } }] }; }, }; ``` ```ts providers.test.ts import { test, expect } from "@frontmcp/testing"; import { Agent, AgentContext, App, FrontMcpInstance, z } from "@frontmcp/sdk"; import { GetTicket, TicketStore } from "./main"; import { model } from "./model.example"; test("a client's call finds the app's provider", async ({ mcp }) => { expect((await mcp.tools.call("get_ticket", { id: "T-1" })).json()).toEqual({ id: "T-1", title: "Cannot log in" }); }); test("so does the same tool inside the agent", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json()).toEqual({ response: 'Heard: {"id":"T-1","title":"Cannot log in"}' }); }); test("a provider on @Agent is for the agent's tools", async () => { @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket], providers: [TicketStore], llm: { adapter: model } }) class Triage extends AgentContext {} @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], agents: [Triage] }) class HelpDeskApp {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); try { const result = await server.callTool("invoke_triage", { ticketId: "T-1" }); expect(result.structuredContent).toEqual({ response: 'Heard: {"id":"T-1","title":"Cannot log in"}' }); await expect(server.callTool("get_ticket", { id: "T-1" })).rejects.toThrow('Provider "TicketStore" is not available'); } finally { await server.dispose(); } }); ``` A provider that only the agent's tools use can go on the agent instead, with `@Agent({ providers })`, as the last test does: the app's tools don't see it there. (Changed in 1.9.2: before, an agent's tools didn't see the app's providers, only the server's and the agent's own.) The other examples on this page keep their data in a module, which every tool sees. ## When a tool fails A tool that fails doesn't end the loop. FrontMCP catches the error, and the model reads it as that tool's result, `{"error": "…"}`, then decides what to do. This model gets five things wrong in its first reply: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts model.example.ts active // Stands in for a real model. This one makes five mistakes in its first reply, // to show what the model hears back about each one. Then it gives up. import type { AgentLlmAdapter, AgentPrompt } from "@frontmcp/sdk"; export const prompts: AgentPrompt[] = []; export const model: AgentLlmAdapter = { async completion(prompt) { prompts.push(structuredClone(prompt)); if (prompt.messages.length > 1) return { content: "I couldn't triage T-1.", finishReason: "stop" }; return { content: null, finishReason: "tool_calls", toolCalls: [ { id: "call_1", name: "get_customer", arguments: { id: "C-9" } }, // a customer that doesn't exist { id: "call_2", name: "get_ticket", arguments: { id: "T-7" } }, // the database is down for this one { id: "call_3", name: "set_priority", arguments: { id: "T-1", priority: "urgent" } }, // not a priority { id: "call_4", name: "close_ticket", arguments: { id: "T-1" } }, // a tool the agent doesn't have { id: "call_5", name: "get_ticket", arguments: { id: "T-9" } }, // a ticket that doesn't exist ], }; }, }; ``` ```ts triage.agent.ts import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; import { GetCustomer, GetTicket, SetPriority } from "./tools"; @Agent({ name: "triage", description: "Triage a support ticket: read it, look up its customer, and set its priority. Pass the ticket id.", systemInstructions: "You triage support tickets. Read the ticket, look up its customer, then set its priority.", inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket, GetCustomer, SetPriority], llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts tools.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { customers, findTicket } from "./store"; @Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = await findTicket(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); return ticket; } } @Tool({ name: "get_customer", description: "Get a customer by id: their name and plan.", inputSchema: { id: z.string().describe("Customer id, like C-1") } }) export class GetCustomer extends ToolContext { async execute({ id }: { id: string }) { const customer = customers.get(id); if (!customer) throw new PublicMcpError(`There's no customer ${id}. Customer ids are on the ticket.`); return customer; } } @Tool({ name: "set_priority", description: "Set a ticket's priority.", inputSchema: { id: z.string().describe("Ticket id, like T-1"), priority: z.enum(["high", "normal", "low"]) }, }) export class SetPriority extends ToolContext { async execute({ id, priority }: { id: string; priority: "high" | "normal" | "low" }) { return { id, priority }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts store.ts const tickets = new Map([["T-1", { id: "T-1", title: "Cannot log in", customerId: "C-1" }]]); export const customers = new Map([["C-1", { id: "C-1", name: "Acme", plan: "business" }]]); // Stands in for the ticket database, which fails for T-7 the way a database does when it's down. export async function findTicket(id: string) { if (id === "T-7") throw new Error("connect ECONNREFUSED 10.0.4.7:5432"); return tickets.get(id); } ``` ```ts failures.test.ts import { test, expect } from "@frontmcp/testing"; import { prompts } from "./model.example"; test("the model reads each failure as that tool's result", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); const results = prompts[1].messages.filter((m) => m.role === "tool").map((m) => m.content); expect(results).toEqual([ '{"error":"There\'s no customer C-9. Customer ids are on the ticket."}', '{"error":"Tool \\"get_ticket\\" execution failed: connect ECONNREFUSED 10.0.4.7:5432"}', '{"error":"Invalid tool input"}', '{"error":"Tool \\"close_ticket\\" not found in agent \\"triage\\". Available tools: [get_ticket, get_customer, set_priority]"}', '{"error":"There\'s no ticket T-9."}', ]); }); test("the call still succeeds, with whatever the model said", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ response: "I couldn't triage T-1." }); }); test("the client's log has a line for each failure", async ({ mcp }) => { const log = mcp.notifications.collect(); await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); const errors = log.received.filter((n) => n.params?.level === "error").map((n) => n.params?.data); expect(errors).toHaveLength(5); expect(errors[0]).toEqual({ message: "Tool get_customer failed: There's no customer C-9. Customer ids are on the ticket." }); expect(errors[1].message).toMatch(/^Tool get_ticket failed: Internal FrontMCP error\. Please contact support with error ID: err_\w+$/); }); ``` | What went wrong | What the model reads | | --- | --- | | The tool threw a `PublicMcpError`, or passed one to `this.fail()` | Its message, word for word. | | The tool threw any other error | `Tool "get_ticket" execution failed:` and the message. That's the development message, which the Playground shows. | | The arguments don't match the tool's `inputSchema` | `Invalid tool input`, and nothing about which argument or why. | | The agent has no tool by that name | `Tool "close_ticket" not found in agent "triage".`, and the names it does have. | So the model hears about every failure and can try something else: ask for the right customer id, try again, or say it couldn't. `invoke_triage` succeeded, and its answer is all the client's model gets: it hears about a failure only if the agent's model says so. The client's log does get a line for each failure, as the last test shows, from the messages an agent sends about its loop (see [Progress during the loop](https://frontmcp.dev/learn/giving-an-agent-tools#progress-during-the-loop)). That line has the public message only: a `PublicMcpError`'s, or else FrontMCP's generic `Internal FrontMCP error` with an error id, so the database's address stays on the server. The server's own log line for the failure has the same id and the whole error. (Changed in 1.9.3: before, the client's log line had `connect ECONNREFUSED 10.0.4.7:5432`.) Two things follow. Fail with a `PublicMcpError` whose message says what to do next, because that's the only kind of failure that tells the model something it can act on. And give every argument a `.describe()`, with the allowed values: a model that gets `Invalid tool input` has to guess what it got wrong. ## Limiting the loop The model decides when the loop ends, and a model can keep asking for tools without getting anywhere. FrontMCP stops it after `execution.maxIterations` requests to the model, 10 by default, and fails the call. `execution.timeout` limits the whole loop in milliseconds, 120000 by default: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, Tool, ToolContext, z } from "@frontmcp/sdk"; import { model, slowModel } from "./model.example"; export const searches: string[] = []; @Tool({ name: "search_tickets", description: "Search tickets by words in their title.", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { searches.push(query); return { tickets: [] }; } } @Agent({ name: "triage", description: "Triage a support ticket. Pass the ticket id.", systemInstructions: "You triage support tickets. Look for similar tickets first.", inputSchema: { ticketId: z.string() }, tools: [SearchTickets], llm: { adapter: model }, execution: { maxIterations: 3 }, }) export class Triage extends AgentContext {} @Agent({ name: "summarize", description: "Summarize a support ticket in one sentence. Pass the ticket id.", inputSchema: { ticketId: z.string() }, tools: [SearchTickets], llm: { adapter: slowModel }, execution: { timeout: 300 }, }) export class Summarize extends AgentContext {} ``` ```ts model.example.ts // Stand-ins for real models. `model` never finds what it's looking for, and // keeps searching. `slowModel` takes 200 ms to answer each request, and // searches twice before it answers. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export let requests = 0; export const model: AgentLlmAdapter = { async completion() { requests++; const query = ["log in", "login", "sign in", "password"][requests % 4]; return { content: null, finishReason: "tool_calls", toolCalls: [{ id: `call_${requests}`, name: "search_tickets", arguments: { query } }] }; }, }; export const slowModel: AgentLlmAdapter = { async completion(prompt) { await new Promise((resolve) => setTimeout(resolve, 200)); const searches = prompt.messages.filter((m) => m.role === "tool").length; if (searches === 2) return { content: "A customer can't log in.", finishReason: "stop" }; const query = searches === 0 ? "log in" : "login"; return { content: null, finishReason: "tool_calls", toolCalls: [{ id: `call_${searches}`, name: "search_tickets", arguments: { query } }] }; }, }; ``` ```ts limits.test.ts import { test, expect } from "@frontmcp/testing"; import { requests } from "./model.example"; import { searches } from "./triage.agent"; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("the model is asked 3 times, and the call fails", async ({ mcp }) => { const before = requests; const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(requests - before).toBe(3); expect(result).toBeError("TOOL_EXECUTION_ERROR"); expect(result.text()).toMatch(/^Tool "invoke_triage" execution failed: Agent reached maximum iterations \(3\) without completing/); }); test("a loop that takes too long fails the call, 🚩 and keeps going", async ({ mcp }) => { const before = searches.length; const result = await mcp.tools.call("invoke_summarize", { ticketId: "T-1" }); expect(result).toBeError("TOOL_EXECUTION_ERROR"); expect(result.text()).toMatch(/^Tool "invoke_summarize" execution failed: Agent execution timed out after 300ms/); expect(result.durationMs).toBeLessThan(400); expect(searches.length - before).toBe(1); // the first search, before the timeout await wait(250); expect(searches.length - before).toBe(2); // the second, after the call had failed }); ``` With `maxIterations: 3`, the model was asked three times and the tool ran three times. The last search's result never reached the model, and the call failed with `TOOL_EXECUTION_ERROR`: `Agent reached maximum iterations (3) without completing`. With `timeout: 300`, the call failed after 300 ms with `Agent execution timed out after 300ms`. Both are the development messages; in production, FrontMCP hides the message of a `TOOL_EXECUTION_ERROR`, so a client only learns that the call failed. > **Pitfall: A timeout doesn't stop the loop** > > When `timeout` fails the call, FrontMCP stops waiting for the loop, but doesn't stop it. The request to the model in flight still finishes, and if the model had asked for tools, they'd still run, with no one to hear the result. So a timeout doesn't protect you from a tool that changes something: keep tools that change things idempotent, as for [retried jobs](https://frontmcp.dev/learn/running-jobs-in-the-background#retrying-a-flaky-job). Set `maxIterations` from what the task needs. Triage takes four requests on a good day, so 6 leaves room for a retry or two, while the default of 10 lets a confused model make ten paid requests before anyone hears about it. Set `timeout` below the time your clients wait for a tool call. ## Before and after the loop `AgentContext` runs the loop in `execute(input)`. Override it to do something before the model is asked, after it's done, or instead, and call `super.execute(input)` for the loop itself. The model's answer is a sentence; this agent adds the priority the ticket actually has in the store, which a client can rely on: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; const tickets = new Map([["T-1", { id: "T-1", title: "Cannot log in", priority: "none" }]]); @Tool({ name: "set_priority", description: "Set a ticket's priority.", inputSchema: { id: z.string(), priority: z.enum(["high", "normal", "low"]) } }) class SetPriority extends ToolContext { async execute({ id, priority }: { id: string; priority: "high" | "normal" | "low" }) { const ticket = tickets.get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); ticket.priority = priority; return { id, priority }; } } @Agent({ name: "triage", description: "Triage a support ticket and set its priority. Pass the ticket id.", systemInstructions: "You triage support tickets. Set the ticket's priority with set_priority, then say what you did.", inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [SetPriority], llm: { adapter: model }, }) export class Triage extends AgentContext { async execute(input: { ticketId: string }) { const answer = await super.execute(input); // the loop return { ...answer, priority: tickets.get(input.ticketId)?.priority ?? "none" }; } } ``` ```ts model.example.ts // Stands in for a real model: sets a priority, then answers in a sentence. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion(prompt) { if (prompt.messages.some((m) => m.role === "tool")) return { content: "I've looked at T-1 and set its priority.", finishReason: "stop" }; return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "set_priority", arguments: { id: "T-1", priority: "high" } }] }; }, }; ``` ```ts execute.test.ts import { test, expect } from "@frontmcp/testing"; test("the result has the model's answer and the stored priority", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json()).toEqual({ response: "I've looked at T-1 and set its priority.", priority: "high" }); }); ``` What `execute()` returns is the result. Before the loop, `this.fail()` works as in a tool, and ends the call without a single request to the model: the [second challenge](https://frontmcp.dev/learn/giving-an-agent-tools#try-some-challenges) uses it to refuse closed tickets. ### Each request and each tool call Inside the loop, `AgentContext` calls two more methods you can override. `completion(prompt, tools, options)` sends one request to the model, and `executeTool(name, args)` runs one tool call the model asked for. An override of either sees every one, and can change what goes in or what comes back; call `super` to do the work: ```ts triage.agent.ts active import { Agent, AgentContext, Tool, ToolContext, z } from "@frontmcp/sdk"; import type { AgentCompletion, AgentCompletionOptions, AgentPrompt, AgentToolDefinition } from "@frontmcp/sdk"; import { model } from "./model.example"; export const overrides: string[] = []; @Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string() } }) class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in" }; } } @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket], llm: { adapter: model } }) export class Triage extends AgentContext { protected async completion(prompt: AgentPrompt, tools?: AgentToolDefinition[], options?: AgentCompletionOptions): Promise { overrides.push(`completion: ${prompt.messages.length} message(s)`); // each request to the model return super.completion(prompt, tools, options); } protected async executeTool(name: string, args: Record) { overrides.push(`executeTool: ${name}`); // each tool call return super.executeTool(name, args); } } ``` ```ts model.example.ts // Stands in for a real model: reads the ticket, then answers. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion(prompt) { if (prompt.messages.some((m) => m.role === "tool")) return { content: "T-1 is high priority.", finishReason: "stop" }; return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: "T-1" } }] }; }, }; ``` ```ts overrides.test.ts import { test, expect } from "@frontmcp/testing"; import { overrides } from "./triage.agent"; test("the loop calls `completion()` for each request and `executeTool()` for each tool call", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json()).toEqual({ response: "T-1 is high priority." }); expect(overrides).toEqual(["completion: 1 message(s)", "executeTool: get_ticket", "completion: 3 message(s)"]); }); ``` Pass `tools` and `options` on to `super.completion()`: without `tools`, the model isn't offered any. `options` holds what the agent's `llm` sets for every request, `temperature` and `maxTokens`, and is empty for an `adapter` of your own. To change what a tool does for everyone who calls it, change the tool or add a [hook](https://frontmcp.dev/learn/hooking-into-calls) instead. (Changed in 1.9.2: before, the loop called the adapter directly, and a `completion()` override never ran. Before 1.9, an `executeTool()` override never ran either.) ## Progress during the loop An agent's call takes as long as all its requests to the model and all its tool calls together. With a real model that's seconds to minutes, and the client's user waits the whole time. What can the client hear before the answer arrives? Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, Tool, ToolContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string() } }) class GetTicket extends ToolContext { async execute({ id }: { id: string }) { await this.notify(`Reading ticket ${id}`); return { id, title: "Cannot log in", status: "open" }; } } @Agent({ name: "triage", description: "Triage a support ticket. Pass the ticket id.", systemInstructions: "You triage support tickets. Read the ticket with get_ticket first.", inputSchema: { ticketId: z.string() }, tools: [GetTicket], llm: { adapter: model }, execution: { enableAutoProgress: true }, }) export class Triage extends AgentContext { async execute(input: { ticketId: string }) { await this.notify("Starting triage"); return super.execute(input); } } ``` ```ts model.example.ts // Stands in for a real model: reads the ticket, then answers. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion(prompt) { if (prompt.messages.some((m) => m.role === "tool")) return { content: "T-1 is high priority.", finishReason: "stop" }; return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: "T-1" } }] }; }, }; ``` ```ts progress.test.ts import { test, expect } from "@frontmcp/testing"; test("the client hears from the agent and from its tools", async ({ mcp }) => { const notifications = mcp.notifications.collect(); await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); const messages = notifications.received.filter((n) => n.method === "notifications/message"); expect(messages.map((n) => n.params)).toEqual([ { level: "info", logger: "triage", data: { message: "Starting triage" } }, { level: "info", logger: "triage", data: { message: "Identified 1 tool call(s): get_ticket" } }, { level: "info", logger: "triage", data: { message: "Calling tool: get_ticket" } }, { level: "info", logger: "get_ticket", data: { message: "Reading ticket T-1" } }, ]); }); test("`enableAutoProgress` reports the loop's progress", async ({ mcp }) => { const notifications = mcp.notifications.collect(); await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); const progress = notifications.received.filter((n) => n.method === "notifications/progress"); expect(progress.map(({ params: { progress, total, message } }) => ({ progress, total, message }))).toEqual([ { progress: 0, total: 100, message: "Starting LLM call (iteration 1/10)" }, { progress: 100, total: 100, message: "Agent completed" }, ]); }); ``` The client hears from the agent and from the tools it runs, during the call, and the Call tab shows it all above the result: - The agent's own `this.notify()` and `this.progress()` send [notifications](https://frontmcp.dev/learn/reporting-progress) as a tool's do: `Starting triage`. - For each tool call the model asks for, the agent sends `Calling tool: get_ticket`, and `Tool get_ticket failed: …` with the error's public message, at level `error`, when the call fails. `execution: { enableNotifications: false }` turns these off. - The tools send their own, as usual: `Reading ticket T-1`. - `execution: { enableAutoProgress: true }` adds progress updates as the loop goes, out of 100, and an `Identified 1 tool call(s)` message for each reply that asks for tools. It sends at most one update per `execution.notificationInterval`, 1000 ms by default, besides the last, so a loop as quick as this one reports only its start and its end. - `execution: { enableStreaming: true }` sends the model's text as it's written instead, each piece as a progress update, when the model can stream: see [Streaming the reply](https://frontmcp.dev/reference/sdk/agent#streaming-the-reply). (New in 1.9.4: before, the option had no effect.) (Changed in 1.9.2: before, under MCP 2026-07-28, none of the agent's own messages reached the client, only its tools'.) ## Recap - `@Agent({ tools: [...] })` gives the agent's model those tools, and by default no others. The client can't see or call them; list a tool in `@App({ tools })` too when both should have it, or set `execution.inheritParentTools` to offer the model all of the app's. - The loop: FrontMCP sends the conversation and the tools' schemas, runs each tool call the model asks for, one at a time, adds the results as `tool` messages, and asks again, until a reply isn't a tool call. - A failing tool doesn't end the loop: the model reads `{"error": "…"}`. Only a `PublicMcpError` tells it something useful; bad arguments only say `Invalid tool input`. The client's model hears nothing unless the agent's model says so. - `execution.maxIterations` (10) and `execution.timeout` (120000 ms) fail the call with `TOOL_EXECUTION_ERROR`. A timeout doesn't stop the loop. - Override `execute(input)` and call `super.execute(input)` to add steps before and after. Override `completion()` to see each request to the model, and `executeTool()` to see each tool call. - During the loop, the client hears from the agent's `this.notify()` and `this.progress()`, a `Calling tool: …` message per tool call, and the tools' own notifications; `enableAutoProgress` adds progress for each step. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Give the agent the tool it needs The instructions tell the model to look up the customer, but the agent doesn't have `get_customer`, so the model is told the tool doesn't exist and sets every priority to normal. Give the agent the tool, without letting clients call it directly. Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-2" } } ``` ```ts main.ts active import { Agent, AgentContext, App, FrontMcp, z } from "@frontmcp/sdk"; import { model } from "./model.example"; import { GetCustomer, GetTicket, SetPriority } from "./tools"; @Agent({ name: "triage", description: "Triage a support ticket: read it, look up its customer, and set its priority. Pass the ticket id.", systemInstructions: "You triage support tickets. Read the ticket with get_ticket and look up its customer with get_customer. " + "Then set its priority with set_priority: high for the enterprise plan, normal otherwise.", inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket, SetPriority], llm: { adapter: model }, }) class Triage extends AgentContext {} @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` **Solution file (alternative to the starter):** ```ts main.ts solution import { Agent, AgentContext, App, FrontMcp, z } from "@frontmcp/sdk"; import { model } from "./model.example"; import { GetCustomer, GetTicket, SetPriority } from "./tools"; @Agent({ name: "triage", description: "Triage a support ticket: read it, look up its customer, and set its priority. Pass the ticket id.", systemInstructions: "You triage support tickets. Read the ticket with get_ticket and look up its customer with get_customer. " + "Then set its priority with set_priority: high for the enterprise plan, normal otherwise.", inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket, GetCustomer, SetPriority], llm: { adapter: model }, }) class Triage extends AgentContext {} @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts tools.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { customers, tickets } from "./store"; @Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); return ticket; } } @Tool({ name: "get_customer", description: "Get a customer by id: their name and plan.", inputSchema: { id: z.string() } }) export class GetCustomer extends ToolContext { async execute({ id }: { id: string }) { const customer = customers.get(id); if (!customer) throw new PublicMcpError(`There's no customer ${id}.`); return customer; } } @Tool({ name: "set_priority", description: "Set a ticket's priority.", inputSchema: { id: z.string(), priority: z.enum(["high", "normal", "low"]) } }) export class SetPriority extends ToolContext { async execute({ id, priority }: { id: string; priority: "high" | "normal" | "low" }) { const ticket = tickets.get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); ticket.priority = priority; return { id, priority }; } } ``` ```ts store.ts export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", customerId: "C-1", priority: "none" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", customerId: "C-2", priority: "none" }], ]); export const customers = new Map([ ["C-1", { id: "C-1", name: "Acme", plan: "business" }], ["C-2", { id: "C-2", name: "Globex", plan: "enterprise" }], ]); ``` ```ts model.example.ts // Stands in for a real model. It follows the instructions, and when a tool // fails, it carries on without it. import type { AgentCompletion, AgentLlmAdapter, AgentToolDefinition } from "@frontmcp/sdk"; export const offered: string[][] = []; export const model: AgentLlmAdapter = { async completion(prompt, tools?: AgentToolDefinition[]) { offered.push((tools ?? []).map((t) => t.name)); const { ticketId } = JSON.parse(prompt.messages[0].content ?? "{}"); const [ticket, customer, updated] = prompt.messages.filter((m) => m.role === "tool").map((m) => JSON.parse(m.content ?? "null")); if (!ticket) return callTool("get_ticket", { id: ticketId }); if (!customer) return callTool("get_customer", { id: ticket.customerId }); if (!updated) return callTool("set_priority", { id: ticketId, priority: customer.plan === "enterprise" ? "high" : "normal" }); return { content: `${ticketId} is now ${updated.priority} priority.`, finishReason: "stop" }; }, }; function callTool(name: string, args: Record): AgentCompletion { return { content: null, finishReason: "tool_calls", toolCalls: [{ id: `call_${name}`, name, arguments: args }] }; } ``` **Hidden fixture/test file (runs with the example):** ```ts tools.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { tickets } from "./store"; import { offered } from "./model.example"; test("the model is offered `get_customer`", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(offered.at(-1)).toContain("get_customer"); }); test("Globex is on enterprise, so T-2 is high", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" }); expect(result.json()).toEqual({ response: "T-2 is now high priority." }); expect(tickets.get("T-2")?.priority).toBe("high"); }); test("a client still can't call `get_customer`", async ({ mcp }) => { expect(await mcp.tools.list()).not.toContainTool("get_customer"); }); ``` **Hint:** Look at what the agent's `tools` lists. A tool in `@App({ tools })` is the client's, not the agent's. **Solution:** Adding `GetCustomer` to `@Agent({ tools })` offers it to the agent's model, and only to it. Before, the model's call got `Tool "get_customer" not found in agent "triage"` as its result, carried on without a plan, and set `normal`. Adding it to `@App({ tools })` instead would have let clients call it, and still not given it to the agent. ### Challenge: Don't triage closed tickets Triage runs on closed tickets too, which wastes a request to the model and changes the priority of a ticket nobody is working on. Refuse a closed ticket before the model is asked, with a `PublicMcpError` that says it's closed. Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-4" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, PublicMcpError, z } from "@frontmcp/sdk"; import { model } from "./model.example"; import { tickets } from "./store"; import { GetTicket, SetPriority } from "./tools"; @Agent({ name: "triage", description: "Triage an open support ticket and set its priority. Pass the ticket id.", systemInstructions: "You triage support tickets. Read the ticket with get_ticket, then set its priority with set_priority.", inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket, SetPriority], llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` **Solution file (alternative to the starter):** ```ts triage.agent.ts solution import { Agent, AgentContext, PublicMcpError, z } from "@frontmcp/sdk"; import { model } from "./model.example"; import { tickets } from "./store"; import { GetTicket, SetPriority } from "./tools"; @Agent({ name: "triage", description: "Triage an open support ticket and set its priority. Pass the ticket id.", systemInstructions: "You triage support tickets. Read the ticket with get_ticket, then set its priority with set_priority.", inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket, SetPriority], llm: { adapter: model }, }) export class Triage extends AgentContext { async execute(input: { ticketId: string }) { if (tickets.get(input.ticketId)?.status === "closed") { this.fail(new PublicMcpError(`${input.ticketId} is closed. Reopen it before triaging it.`)); } return super.execute(input); } } ``` ```ts tools.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./store"; @Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); return ticket; } } @Tool({ name: "set_priority", description: "Set a ticket's priority.", inputSchema: { id: z.string(), priority: z.enum(["high", "normal", "low"]) } }) export class SetPriority extends ToolContext { async execute({ id, priority }: { id: string; priority: "high" | "normal" | "low" }) { const ticket = tickets.get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); ticket.priority = priority; return { id, priority }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts store.ts export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open", priority: "none" }], ["T-4", { id: "T-4", title: "Refund not received", status: "closed", priority: "normal" }], ]); ``` ```ts model.example.ts // Stands in for a real model: reads the ticket, sets a priority, answers. import type { AgentCompletion, AgentLlmAdapter } from "@frontmcp/sdk"; /** The ticket ids the model was asked about, oldest first. */ export const asked: string[] = []; export const model: AgentLlmAdapter = { async completion(prompt) { const { ticketId } = JSON.parse(prompt.messages[0].content ?? "{}"); asked.push(ticketId); const [ticket, updated] = prompt.messages.filter((m) => m.role === "tool").map((m) => JSON.parse(m.content ?? "null")); if (!ticket) return callTool("get_ticket", { id: ticketId }); if (!updated) return callTool("set_priority", { id: ticketId, priority: "high" }); return { content: `${ticketId} is now ${updated.priority} priority.`, finishReason: "stop" }; }, }; function callTool(name: string, args: Record): AgentCompletion { return { content: null, finishReason: "tool_calls", toolCalls: [{ id: `call_${name}`, name, arguments: args }] }; } ``` **Hidden fixture/test file (runs with the example):** ```ts closed.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { asked } from "./model.example"; import { tickets } from "./store"; test("a closed ticket fails with `PUBLIC_ERROR`", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-4" }); expect(result).toBeError("PUBLIC_ERROR"); expect(result.text()).toMatch(/closed/i); }); test("the model is never asked about it", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { ticketId: "T-4" }); expect(asked).not.toContain("T-4"); expect(tickets.get("T-4")?.priority).toBe("normal"); }); test("an open ticket is still triaged", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json()).toEqual({ response: "T-1 is now high priority." }); }); ``` **Hint:** The loop runs in a method you can override. Check the ticket first, and only then call the method you overrode. **Solution:** Overriding `execute(input)` puts your code in front of the loop. `this.fail()` with a `PublicMcpError` ends the call before `super.execute(input)` runs, so no request goes to the model and no tool runs. Leaving the check to the model would cost a request, and depends on the model obeying an instruction it may not follow. --- # Connecting a Real Model > Connect a FrontMCP agent to Anthropic or OpenAI: API keys, requests and replies, provider failures, and what a real model costs in tokens and time. Source: https://frontmcp.dev/learn/connecting-a-real-model [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Every agent in this chapter so far has run a `model.example.ts`: a few lines that follow a script. A real agent runs a real model, and FrontMCP comes with adapters for two providers, Anthropic and OpenAI. This lesson connects the triage agent to one, shows exactly what FrontMCP sends the provider and how it reads the answer, and covers what a real model changes: API keys, failures and retries, cost and waiting. **You will learn** - How to connect an agent to Anthropic or OpenAI, and which package each needs - Where the API key comes from, and what happens when it's missing - What FrontMCP sends the provider, and how it reads the reply - What a failing provider does to a call, and how retries add up - What a real model costs in requests, tokens and time > **Note** > > The Playground can't reach a model provider: it runs in your browser, with no network and no API key, and the providers' packages aren't in it. So the configuration in this lesson is plain code, to run on your own machine. The examples that do run use FrontMCP's real adapters, with a stand-in for the provider's client, the way the earlier lessons used a stand-in for the model. ## Choosing a provider Replace the stand-in with `provider`, `model` and `apiKey`: ```ts triage.agent.ts import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { GetCustomer, GetTicket, SetPriority } from "./tools"; @Agent({ name: "triage", description: "Triage a support ticket: read it, look up its customer, and set its priority. Pass the ticket id.", systemInstructions: "You triage support tickets. …", inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket, GetCustomer, SetPriority], llm: { provider: "anthropic", model: "claude-opus-5", apiKey: { env: "ANTHROPIC_API_KEY" }, }, }) export class Triage extends AgentContext {} ``` And install the provider's package next to `@frontmcp/sdk`. FrontMCP doesn't depend on either, and loads it the first time an agent asks the model: ```bash yarn add @anthropic-ai/sdk # for provider: "anthropic" yarn add openai # for provider: "openai" ``` If the package is missing, the server still starts, and every call to the agent fails with `The "@anthropic-ai/sdk" package is not installed.`, after about seven seconds of [retries](https://frontmcp.dev/learn/connecting-a-real-model#when-the-provider-fails). That's a Node-only failure, so there's no example of it here. | `llm` option | Type | | | --- | --- | --- | | `provider` | `"anthropic"` or `"openai"` | Which adapter FrontMCP builds. | | `model` | `string` | The provider's model id, sent with every request. | | `apiKey` | `{ env: string }` or `string` | Where the key comes from, [below](https://frontmcp.dev/learn/connecting-a-real-model#where-the-key-comes-from). | | `baseUrl` | `string` | Optional. Another address for the provider's API. | | `maxTokens` | `number` | Optional. The longest reply, in tokens. Anthropic's API requires one, and FrontMCP sends `4096` when you don't. | | `temperature` | `number` | Optional. Sent as is; leave it out for models that don't take it. | `provider` is a shorthand: it builds an `AnthropicAdapter` or an `OpenAIAdapter`, both exported from `@frontmcp/sdk`. Build one yourself for what the shorthand doesn't cover, and pass it as `llm: { adapter }`: ```ts import Anthropic from "@anthropic-ai/sdk"; import { AnthropicAdapter, OpenAIAdapter } from "@frontmcp/sdk"; // OpenAI's Responses API, instead of Chat Completions new OpenAIAdapter({ model: "gpt-5", apiKey: process.env.OPENAI_API_KEY!, api: "responses" }); // Any service with an OpenAI-compatible API new OpenAIAdapter({ model: "my-model", apiKey: process.env.GATEWAY_KEY!, baseUrl: "https://llm-gateway.internal/v1" }); // A client you've configured yourself, and fewer retries new AnthropicAdapter({ model: "claude-opus-5", client: new Anthropic(), maxRetries: 1 }); ``` For anything else, write an adapter: an object with `completion(prompt, tools)`, like the stand-ins in this chapter, that calls your provider. ## Where the key comes from `apiKey: { env: "ANTHROPIC_API_KEY" }` reads the variable once, when the server starts. If it isn't set, the server doesn't start. In the Playground no variable is ever set, so the test builds a second server with a real provider, and watches it fail: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts key.test.ts active import { test, expect } from "@frontmcp/testing"; import { Agent, AgentContext, App, FrontMcpInstance } from "@frontmcp/sdk"; test("without the variable, the server doesn't start", async () => { @Agent({ name: "triage", llm: { provider: "anthropic", model: "claude-opus-5", apiKey: { env: "HELP_DESK_ANTHROPIC_KEY" } }, }) class Triage extends AgentContext {} @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) class HelpDeskApp {} const starting = FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); await expect(starting).rejects.toThrow("Environment variable HELP_DESK_ANTHROPIC_KEY is not set"); }); ``` ```ts triage.agent.ts import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Triage a support ticket. Pass the ticket id.", inputSchema: { ticketId: z.string() }, llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts model.example.ts // Stands in for a real model, so this example's own server starts. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion() { return { content: "T-1 is high priority.", finishReason: "stop" }; }, }; ``` That's what you want: a server with a missing key fails at once, when it's deployed, not on the first call a user makes. `apiKey` also takes the key itself as a string, but then it's in your source code and everywhere your source code goes. Keep keys in the environment, or in your platform's secret store, which sets the environment for you. The key is your server's, not the user's. Every call to the agent, from any client, is paid for by that key, so an agent is one more reason to [authenticate clients](https://frontmcp.dev/learn/authenticating-clients) and [limit calls](https://frontmcp.dev/learn/limiting-calls). ## What FrontMCP sends the provider An adapter turns FrontMCP's prompt into the provider's request, and the provider's reply back into `{ content, finishReason, toolCalls }`. `AnthropicAdapter` takes an Anthropic client, so here it gets a stand-in that records each request and answers the way Anthropic's Messages API does. Open the **Tests** tab: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, AnthropicAdapter, Tool, ToolContext, z } from "@frontmcp/sdk"; import { anthropic } from "./anthropic.example"; @Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") } }) class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Agent({ name: "triage", description: "Triage a support ticket. Pass the ticket id.", systemInstructions: "You triage support tickets. Read the ticket with get_ticket, then give its priority and why.", inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket], llm: { adapter: new AnthropicAdapter({ model: "claude-opus-5", client: anthropic }) }, }) export class Triage extends AgentContext {} ``` ```ts anthropic.example.ts // Stands in for the client from @anthropic-ai/sdk, which the Playground can't // use: there's no network and no API key. It has the one method FrontMCP calls, // messages.create(), records every request, and answers in the Messages API's // shape: first with a tool call, then with text. A real server passes // `new Anthropic()`, or uses `provider: "anthropic"`. /** Every request "Anthropic" received, oldest first. */ export const requests: any[] = []; export const anthropic = { messages: { async create(params: any): Promise { requests.push(structuredClone(params)); if (params.messages.length === 1) { return { content: [{ type: "tool_use", id: "toolu_01", name: "get_ticket", input: { id: "T-1" } }], stop_reason: "tool_use", usage: { input_tokens: 612, output_tokens: 48 }, }; } return { content: [{ type: "text", text: "High: the customer can't log in." }], stop_reason: "end_turn", usage: { input_tokens: 703, output_tokens: 12 }, }; }, }, }; ``` ```ts anthropic.test.ts import { test, expect } from "@frontmcp/testing"; import { requests } from "./anthropic.example"; test("the first request: instructions, input and tools", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(requests[0]).toEqual({ model: "claude-opus-5", max_tokens: 4096, stream: false, system: "You triage support tickets. Read the ticket with get_ticket, then give its priority and why.", messages: [{ role: "user", content: '{"ticketId":"T-1"}' }], tools: [ { name: "get_ticket", description: "Get a support ticket by its id.", input_schema: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: { id: { type: "string", description: "Ticket id, like T-1" } }, required: ["id"], }, }, ], }); }); test("the second request adds the tool call and its result", async ({ mcp }) => { const before = requests.length; await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(requests.length - before).toBe(2); expect(requests.at(-1).messages).toEqual([ { role: "user", content: '{"ticketId":"T-1"}' }, { role: "assistant", content: [{ type: "tool_use", id: "toolu_01", name: "get_ticket", input: { id: "T-1" } }] }, { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_01", content: '{"id":"T-1","title":"Cannot log in","status":"open"}' }], }, ]); }); test("the text reply is the agent's answer", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json()).toEqual({ response: "High: the customer can't log in." }); }); ``` `OpenAIAdapter` does the same for OpenAI's Chat Completions API. Its stand-in has `chat.completions.create()`: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts openai.test.ts active import { test, expect } from "@frontmcp/testing"; import { requests } from "./openai.example"; test("the instructions are the first message, and tools are functions", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(requests[0]).toMatchObject({ model: "gpt-5", stream: false, messages: [ { role: "system", content: "You triage support tickets. Read the ticket with get_ticket, then give its priority and why." }, { role: "user", content: '{"ticketId":"T-1"}' }, ], tools: [{ type: "function", function: { name: "get_ticket", description: "Get a support ticket by its id." } }], }); }); test("a tool result is a `tool` message", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(requests.at(-1).messages.slice(2)).toEqual([ { role: "assistant", content: null, tool_calls: [{ id: "call_01", type: "function", function: { name: "get_ticket", arguments: '{"id":"T-1"}' } }], }, { role: "tool", content: '{"id":"T-1","title":"Cannot log in","status":"open"}', tool_call_id: "call_01" }, ]); }); ``` ```ts triage.agent.ts import { Agent, AgentContext, OpenAIAdapter, Tool, ToolContext, z } from "@frontmcp/sdk"; import { openai } from "./openai.example"; @Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") } }) class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Agent({ name: "triage", description: "Triage a support ticket. Pass the ticket id.", systemInstructions: "You triage support tickets. Read the ticket with get_ticket, then give its priority and why.", inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket], llm: { adapter: new OpenAIAdapter({ model: "gpt-5", client: openai }) }, }) export class Triage extends AgentContext {} ``` ```ts openai.example.ts // Stands in for the client from the openai package, which the Playground can't // use. It records every request and answers in the Chat Completions shape. /** Every request "OpenAI" received, oldest first. */ export const requests: any[] = []; export const openai = { chat: { completions: { async create(params: any): Promise { requests.push(structuredClone(params)); const message = params.messages.some((m: any) => m.role === "tool") ? { role: "assistant", content: "High: the customer can't log in." } : { role: "assistant", content: null, tool_calls: [{ id: "call_01", type: "function", function: { name: "get_ticket", arguments: '{"id":"T-1"}' } }] }; return { choices: [{ message, finish_reason: message.content ? "stop" : "tool_calls" }], usage: { prompt_tokens: 540, completion_tokens: 20, total_tokens: 560 }, }; }, }, }, responses: { async create(): Promise { throw new Error("This example uses Chat Completions."); }, }, }; ``` | FrontMCP's prompt | Anthropic request | OpenAI request | | --- | --- | --- | | `system` | `system` | The first message, `role: "system"` | | A `user` message | `role: "user"` | `role: "user"` | | The model's tool calls | `tool_use` blocks in an `assistant` message | `tool_calls` on an `assistant` message, `arguments` as a JSON string | | A tool's result | A `tool_result` block in a `user` message | A `tool` message with `tool_call_id` | | The agent's tools | `tools`, each with `input_schema` | `tools`, each `{ type: "function", function }` | Coming back, a reply with tool calls becomes `finishReason: "tool_calls"`, and the loop runs them; anything else ends it. That includes a reply cut short by the token limit, which [the loop treats as the answer](https://frontmcp.dev/learn/connecting-a-real-model#cost-and-latency). ## When the provider fails Providers fail: they rate-limit you, have outages, or reject your key. An adapter retries a failed request before it gives up, `maxRetries` times, 3 by default, waiting 1 second, then 2, then 4. When it gives up, the agent's call fails with the provider's message. This example keeps the waits short with `maxRetries: 1`: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, OpenAIAdapter, z } from "@frontmcp/sdk"; import { openai } from "./openai.example"; @Agent({ name: "triage", description: "Triage a support ticket. Pass the ticket id.", inputSchema: { ticketId: z.string() }, llm: { adapter: new OpenAIAdapter({ model: "gpt-5", client: openai, maxRetries: 1 }) }, }) export class Triage extends AgentContext {} ``` ```ts openai.example.ts // Stands in for the openai client. The first request for T-1 is rate-limited, // and every request for T-2 is refused, as if the key were wrong. export const received: string[] = []; function fail(status: number, message: string) { return Object.assign(new Error(message), { status }); } export const openai = { chat: { completions: { async create(params: any): Promise { const ticket = JSON.parse(params.messages.at(-1).content).ticketId; received.push(ticket); if (ticket === "T-2") throw fail(401, "401 Incorrect API key provided: sk-proj-****. You can find your API key at https://platform.openai.com/account/api-keys."); if (received.filter((t) => t === ticket).length === 1) throw fail(429, "429 Rate limit reached for gpt-5"); return { choices: [{ message: { role: "assistant", content: `${ticket} is high priority.` }, finish_reason: "stop" }] }; }, }, }, responses: { async create(): Promise { throw new Error("This example uses Chat Completions."); }, }, }; ``` ```ts retries.test.ts import { test, expect } from "@frontmcp/testing"; import { received } from "./openai.example"; test("a rate-limited request is tried again a second later", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json()).toEqual({ response: "T-1 is high priority." }); expect(received.filter((t) => t === "T-1")).toHaveLength(2); expect(result.durationMs).toBeGreaterThanOrEqual(1_000); }); test("🚩 a wrong key is tried again too, and then fails the call", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" }); expect(received.filter((t) => t === "T-2")).toHaveLength(2); expect(result).toBeError("TOOL_EXECUTION_ERROR"); expect(result.text()).toMatch(/^Tool "invoke_triage" execution failed: 401 Incorrect API key provided/); }); ``` Retrying a rate limit is right: the second request worked. Retrying a wrong key isn't, because it will never work, but in 1.9.3 the adapter only gives up at once on a few messages, like `invalid api key` or `unauthorized`, and OpenAI's wording is `Incorrect API key provided`. With the default `maxRetries: 3`, a wrong key or a missing package costs every call seven seconds before it fails. Two more things add up: - **Retries are per request.** A loop that makes four requests can wait through retries four times, and all of it counts against the agent's [`execution.timeout`](https://frontmcp.dev/learn/giving-an-agent-tools#limiting-the-loop), 2 minutes by default. - **The client waits.** The client's model called one tool and is waiting for its answer. Keep `maxRetries` low for agents that clients call directly, and set `execution.timeout` below what your clients wait for a tool call. When the call fails, the development message above names the provider's error. In production, FrontMCP hides the message of a `TOOL_EXECUTION_ERROR`, so the client only learns that the call failed. ## Cost and latency Each turn of the loop is one request to the provider, and each request carries everything so far: the system instructions, every tool's schema, the input, and every tool call and result. So the input grows with every turn, and you pay for all of it every time. The adapter reports what each request used, as `usage`, and the loop drops it. To see it, wrap the adapter in one of your own, or override the agent's [`completion()`](https://frontmcp.dev/learn/giving-an-agent-tools#each-request-and-each-tool-call), as the [last challenge](https://frontmcp.dev/learn/connecting-a-real-model#try-some-challenges) does: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, AnthropicAdapter, Tool, ToolContext, z } from "@frontmcp/sdk"; import type { AgentCompletion, AgentLlmAdapter } from "@frontmcp/sdk"; import { anthropic } from "./anthropic.example"; /** What each request to the model used, oldest first. */ export const usage: AgentCompletion["usage"][] = []; function counted(inner: AgentLlmAdapter): AgentLlmAdapter { return { async completion(prompt, tools, options) { const reply = await inner.completion(prompt, tools, options); usage.push(reply.usage); return reply; }, }; } @Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") } }) class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open", body: "Since this morning nobody on our team can log in." }; } } @Agent({ name: "triage", description: "Triage a support ticket. Pass the ticket id.", systemInstructions: "You triage support tickets. Read the ticket with get_ticket, then give its priority and why.", inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket], llm: { adapter: counted(new AnthropicAdapter({ model: "claude-opus-5", client: anthropic, maxTokens: 12 })) }, }) export class Triage extends AgentContext {} ``` ```ts anthropic.example.ts // Stands in for the Anthropic client. It counts a token as four characters of // the request, and cuts its answer off at max_tokens, as a real model does. export const anthropic = { messages: { async create(params: any): Promise { const input_tokens = Math.ceil(JSON.stringify(params).length / 4); if (params.messages.length === 1) { return { content: [{ type: "tool_use", id: "toolu_01", name: "get_ticket", input: { id: "T-1" } }], stop_reason: "tool_use", usage: { input_tokens, output_tokens: 40 }, }; } const words = "High priority: nobody at the customer can log in, so their whole team is blocked.".split(" "); const cut = words.length > params.max_tokens; return { content: [{ type: "text", text: words.slice(0, params.max_tokens).join(" ") }], stop_reason: cut ? "max_tokens" : "end_turn", usage: { input_tokens, output_tokens: Math.min(words.length, params.max_tokens) }, }; }, }, }; ``` ```ts cost.test.ts import { test, expect } from "@frontmcp/testing"; import { usage } from "./triage.agent"; test("each request reports its usage, and the second is bigger", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); const [first, second] = usage; expect(first).toEqual({ promptTokens: expect.any(Number), completionTokens: 40, totalTokens: expect.any(Number) }); expect(second!.promptTokens).toBeGreaterThan(first!.promptTokens); }); test("🚩 an answer cut off at `maxTokens` is still the answer", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ response: "High priority: nobody at the customer can log in, so their whole" }); }); ``` `maxTokens` caps each reply, and this example sets it far too low on purpose. The provider stopped mid-sentence, and FrontMCP's loop took the half sentence as the answer: it ends at any reply that isn't a tool call, including one that ran out of tokens, and says nothing about it. Leave `maxTokens` at the default, or raise it; lower it only when you know how long answers are. What makes an agent cost more, and take longer: - **The number of requests.** Every turn is a request, and the client waits for all of them. `execution.maxIterations` caps it, [10 by default](https://frontmcp.dev/learn/giving-an-agent-tools#limiting-the-loop). - **What each request repeats.** Instructions, tool descriptions and schemas are sent every turn. Give an agent the tools its task needs, not every tool you have. - **What tools return.** Every result stays in the conversation until the end. A tool an agent uses should return what the model needs, not the whole record: the same advice as for [shaping tool results](https://frontmcp.dev/learn/shaping-tool-results), and it matters more here, because the result is paid for again on every later turn. - **Where it runs.** An agent spends your key on behalf of whoever calls it. Put agents behind [authentication](https://frontmcp.dev/learn/authenticating-clients) and [rate limits](https://frontmcp.dev/learn/limiting-calls). ## The other direction: `connectOpenAI()` and friends FrontMCP also exports `connectOpenAI()`, `connectClaude()`, `connectLangChain()` and `connectVercelAI()`. Despite the names, they don't connect an agent to a model. They go the other way: they connect *your* code to a FrontMCP server, in-process, and hand you its tools in the shape that provider's API or library expects. They're for when your own program runs the model and the loop, and the FrontMCP server is only the tools. [Running FrontMCP Anywhere](https://frontmcp.dev/learn/running-frontmcp-anywhere#calling-your-tools-through-a-client-connect) shows them. | You want | Use | | --- | --- | | Your MCP server to run a model loop of its own, as a tool clients call | `@Agent` with `llm` | | Your own program to run the model loop, with a FrontMCP server's tools | `connectClaude()`, `connectOpenAI()`, … | ## Recap - `llm: { provider: "anthropic" | "openai", model, apiKey: { env } }` connects an agent to a real model. Install `@anthropic-ai/sdk` or `openai`; FrontMCP loads it on the first request. - `apiKey: { env }` is read when the server starts, and a missing variable stops the server from starting. Keep keys out of your source. - `AnthropicAdapter` and `OpenAIAdapter` are what `provider` builds. Build one yourself for `api: "responses"`, `baseUrl`, your own `client`, or `maxRetries`. - Adapters retry a failed request 3 times, waiting 1, 2 and 4 seconds, including failures that can't succeed, like a wrong key. A call that still fails is a `TOOL_EXECUTION_ERROR`. - Every turn is a request carrying the whole conversation. Wrap the adapter, or override the agent's `completion()`, to see `usage`. A reply cut off at `maxTokens` becomes the answer, silently. - `connectOpenAI()` and friends connect your own code to a FrontMCP server; they aren't model adapters. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Fail fast when the provider is busy This agent is called directly by clients, and the provider is rate-limiting every request. With `maxRetries: 2`, each call waits three seconds before it fails. Make a rate-limited call fail at once, after a single request. Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, OpenAIAdapter, z } from "@frontmcp/sdk"; import { openai } from "./openai.example"; @Agent({ name: "triage", description: "Triage a support ticket. Pass the ticket id.", inputSchema: { ticketId: z.string() }, llm: { adapter: new OpenAIAdapter({ model: "gpt-5", client: openai, maxRetries: 2 }) }, }) export class Triage extends AgentContext {} ``` **Solution file (alternative to the starter):** ```ts triage.agent.ts solution import { Agent, AgentContext, OpenAIAdapter, z } from "@frontmcp/sdk"; import { openai } from "./openai.example"; @Agent({ name: "triage", description: "Triage a support ticket. Pass the ticket id.", inputSchema: { ticketId: z.string() }, llm: { adapter: new OpenAIAdapter({ model: "gpt-5", client: openai, maxRetries: 0 }) }, }) export class Triage extends AgentContext {} ``` ```ts openai.example.ts // Stands in for the openai client, on a bad day: every request is rate-limited. export let requests = 0; export const openai = { chat: { completions: { async create(): Promise { requests++; throw Object.assign(new Error("429 Rate limit reached for gpt-5"), { status: 429 }); }, }, }, responses: { async create(): Promise { throw new Error("This example uses Chat Completions."); }, }, }; ``` **Hidden fixture/test file (runs with the example):** ```ts fast.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { requests } from "./openai.example"; test("the call fails with the provider's message", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result).toBeError("TOOL_EXECUTION_ERROR"); expect(result.text()).toContain("429 Rate limit reached"); }); test("it fails within half a second", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.durationMs).toBeLessThan(500); }); test("the provider gets one request per call", async ({ mcp }) => { const before = requests; await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(requests - before).toBe(1); }); ``` **Hint:** The waits come from the adapter, not from the agent's `execution` options. Count how many requests `maxRetries: 2` makes. **Solution:** `maxRetries` is how many times the adapter tries again after the first request, so `0` means one request and no waiting. The client's model gets the failure straight away and can tell the user, instead of holding the call for 1 + 2 seconds of retries that, during an outage, won't help. Jobs and other background work can afford more retries than a call someone is waiting on. ### Challenge: Report the tokens each call used Support wants to see what triage costs. Add `tokens` to the agent's result: the total tokens, input and output, of every request that call made, even when two calls run at the same time. The model's answer should still be in `response`. Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, AnthropicAdapter, Tool, ToolContext, z } from "@frontmcp/sdk"; import { anthropic } from "./anthropic.example"; @Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string() } }) class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Agent({ name: "triage", description: "Triage a support ticket. Pass the ticket id.", systemInstructions: "You triage support tickets. Read the ticket with get_ticket, then give its priority and why.", inputSchema: { ticketId: z.string() }, tools: [GetTicket], llm: { adapter: new AnthropicAdapter({ model: "claude-opus-5", client: anthropic }) }, }) export class Triage extends AgentContext {} ``` **Solution file (alternative to the starter):** ```ts triage.agent.ts solution import { Agent, AgentContext, AnthropicAdapter, Tool, ToolContext, z } from "@frontmcp/sdk"; import type { AgentCompletion, AgentCompletionOptions, AgentPrompt, AgentToolDefinition } from "@frontmcp/sdk"; import { anthropic } from "./anthropic.example"; @Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string() } }) class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Agent({ name: "triage", description: "Triage a support ticket. Pass the ticket id.", systemInstructions: "You triage support tickets. Read the ticket with get_ticket, then give its priority and why.", inputSchema: { ticketId: z.string() }, tools: [GetTicket], llm: { adapter: new AnthropicAdapter({ model: "claude-opus-5", client: anthropic }) }, }) export class Triage extends AgentContext { private tokens = 0; protected async completion(prompt: AgentPrompt, tools?: AgentToolDefinition[], options?: AgentCompletionOptions): Promise { const reply = await super.completion(prompt, tools, options); this.tokens += reply.usage?.totalTokens ?? 0; return reply; } async execute(input: { ticketId: string }) { const answer = await super.execute(input); return { ...answer, tokens: this.tokens }; } } ``` ```ts anthropic.example.ts // Stands in for the Anthropic client: a tool call, then an answer, with the // usage a real response reports. export const anthropic = { messages: { async create(params: any): Promise { if (params.messages.length === 1) { return { content: [{ type: "tool_use", id: "toolu_01", name: "get_ticket", input: { id: "T-1" } }], stop_reason: "tool_use", usage: { input_tokens: 600, output_tokens: 40 }, }; } return { content: [{ type: "text", text: "High: the customer can't log in." }], stop_reason: "end_turn", usage: { input_tokens: 700, output_tokens: 15 }, }; }, }, }; ``` **Hidden fixture/test file (runs with the example):** ```ts tokens.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("the result has `tokens`: 600 + 40 + 700 + 15", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json().tokens).toBe(1355); }); test("it's counted per call, not in total", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json().tokens).toBe(1355); }); test("two calls at once count their own", async ({ mcp }) => { const [a, b] = await Promise.all([mcp.tools.call("invoke_triage", { ticketId: "T-1" }), mcp.tools.call("invoke_triage", { ticketId: "T-1" })]); expect([a.json().tokens, b.json().tokens]).toEqual([1355, 1355]); }); test("the answer is still in `response`", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json().response).toBe("High: the customer can't log in."); }); ``` **Hint:** The loop drops each reply's `usage`, so catch it on the way, in a method the loop calls for every request. Keep the count where each call has its own, and add it to the result where the agent's result is made. **Solution:** The loop calls the agent's `completion()` for every request, so an override sees every reply, and adds up `usage.totalTokens`, which `AnthropicAdapter` fills in from the response's `input_tokens` and `output_tokens`. FrontMCP creates the agent's class for each call, so `this.tokens` belongs to one call, even when two run at once. Overriding `execute()` adds it to the result after `super.execute(input)`. A wrapping adapter, as in [Cost and latency](https://frontmcp.dev/learn/connecting-a-real-model#cost-and-latency), sees the same replies, but it's shared by every call, so it can't tell two calls at once apart. --- # Agents That Call Agents > How one FrontMCP agent hands a task to another: nested agents, swarm handoffs, this.callTool() from execute(), handling failures, and the handoff depth limit. Source: https://frontmcp.dev/learn/agents-that-call-agents [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 The triage agent reads a ticket and decides who should handle it. When the ticket is about an invoice, the best one to answer it is another agent: a billing agent, with billing's tools and billing's rules. This lesson connects the two on one FrontMCP server, in two ways: by letting triage's model call billing, and by handing the ticket to billing from triage's own code, where your code decides. **You will learn** - What two agents on one server see of each other, and what the client sees - How `agents: [...]` and `swarm` let one agent's model call another - How to hand a task to another agent from `execute()` with `this.callTool()` - What to do when the other agent fails - How FrontMCP stops handoffs that never end, and how to stop them sooner ## Two agents on one server Billing questions need an invoice tool and billing's rules, which triage shouldn't carry around. So the help desk gets a second agent, `billing`, with its own tool and its own instructions, registered on the app next to `triage`. As in the rest of this chapter, `model.example.ts` stands in for the models, which the Playground can't reach; with two agents, it has one model for each. Open the **Tests** tab: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-2" } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { BillingAgent, TriageAgent } from "./agents"; @App({ id: "help-desk", name: "Help Desk", agents: [TriageAgent, BillingAgent] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts agents.ts import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { billingModel, triageModel } from "./model.example"; import { GetInvoice, GetTicket } from "./tools"; @Agent({ name: "triage", description: "Read a support ticket and say who should handle it.", systemInstructions: "Read the ticket with get_ticket, then say who should handle it.", inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket], llm: { adapter: triageModel }, }) export class TriageAgent extends AgentContext {} @Agent({ name: "billing", description: "Answer a customer's question about an invoice or a charge.", systemInstructions: "Look the invoice up with get_invoice. Refund duplicate charges; never refund more than was charged.", inputSchema: { question: z.string().describe("The customer's question, with the invoice number") }, tools: [GetInvoice], llm: { adapter: billingModel }, }) export class BillingAgent extends AgentContext {} ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets: Record = { "T-1": "I can't log in since this morning.", "T-2": "I was charged twice for invoice INV-7.", }; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, text: tickets[id] ?? "" }; } } @Tool({ name: "get_invoice", description: "Get an invoice, with how many times it was charged.", inputSchema: { id: z.string() } }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, amount: "49 EUR", charges: 2 }; } } ``` ```ts model.example.ts // Stand-ins for the models the two agents would call. The Playground can't reach a // real model, so each one follows a script: on the first turn it calls its tool, and // on the second it answers from the tool's result. `seen` records the tools each // model was offered. A real server doesn't need this file: see Connecting a Real Model. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const seen: Record = { triage: [], billing: [] }; export const triageModel: AgentLlmAdapter = { async completion(prompt, tools = []) { seen.triage.push(tools.map((tool) => tool.name)); const last = prompt.messages.at(-1)!; if (last.role === "user") { const { ticketId } = JSON.parse(last.content!); return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c1", name: "get_ticket", arguments: { id: ticketId } }] }; } const ticket = JSON.parse(last.content!); if (/invoice|charge/i.test(ticket.text)) { return { content: `${ticket.id} is a billing question: ask the billing agent about it.`, finishReason: "stop" }; } return { content: `${ticket.id} is a login problem: the accounts team should take it.`, finishReason: "stop" }; }, }; export const billingModel: AgentLlmAdapter = { async completion(prompt, tools = []) { seen.billing.push(tools.map((tool) => tool.name)); const last = prompt.messages.at(-1)!; if (last.role === "user") { const invoiceId = last.content!.match(/INV-\d+/)?.[0]; return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c1", name: "get_invoice", arguments: { id: invoiceId } }] }; } const invoice = JSON.parse(last.content!); return { content: `${invoice.id} was charged twice: refund the second charge of ${invoice.amount}.`, finishReason: "stop" }; }, }; ``` ```ts agents.test.ts import { test, expect } from "@frontmcp/testing"; import { seen } from "./model.example"; test("the client sees both agents, and none of their tools", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t) => t.name).sort(); expect(names).toEqual(["invoke_billing", "invoke_triage"]); }); test("the client's model can chain the two", async ({ mcp }) => { const triage = await mcp.tools.call("invoke_triage", { ticketId: "T-2" }); expect(triage.json()).toEqual({ response: "T-2 is a billing question: ask the billing agent about it." }); const billing = await mcp.tools.call("invoke_billing", { question: "I was charged twice for invoice INV-7." }); expect(billing.json()).toEqual({ response: "INV-7 was charged twice: refund the second charge of 49 EUR." }); }); test("each model is offered only its own agent's tools", async () => { expect(seen.triage).toEqual([["get_ticket"], ["get_ticket"]]); expect(seen.billing).toEqual([["get_invoice"], ["get_invoice"]]); }); ``` Each agent is a tool for the client, `invoke_triage` and `invoke_billing`, and a closed room inside: triage's model is offered `get_ticket` and nothing else, billing's is offered `get_invoice`. Neither model knows the other agent exists. So the client's model does the connecting. It calls `invoke_triage`, reads that T-2 is a billing question, and calls `invoke_billing` with the customer's words. That's often enough, and it's the simplest arrangement. It costs a round trip through the client's model, though, and only works if that model thinks of it. For the help desk, a ticket should go in and an answer come out, so the next step is to let triage ask billing itself. ## Letting triage's model call billing `@Agent({ agents })` lists the agents an agent may call. Here billing moves into triage's `agents`, and out of the app's: it's triage's helper now, not the client's. Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-2" } } ``` ```ts agents.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { billingModel, triageModel } from "./model.example"; import { GetInvoice, GetTicket } from "./tools"; @Agent({ name: "billing", description: "Answer a customer's question about an invoice or a charge.", systemInstructions: "Look the invoice up with get_invoice. Refund duplicate charges; never refund more than was charged.", inputSchema: { question: z.string().describe("The customer's question, with the invoice number") }, tools: [GetInvoice], llm: { adapter: billingModel }, }) export class BillingAgent extends AgentContext {} @Agent({ name: "triage", description: "Read a support ticket and answer it, asking the billing agent about invoices and charges.", systemInstructions: "Read the ticket with get_ticket. Ask the billing agent about invoices and charges, and pass on its answer.", inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket], agents: [BillingAgent], // triage's model may call billing llm: { adapter: triageModel }, }) export class TriageAgent extends AgentContext {} ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { TriageAgent } from "./agents"; @App({ id: "help-desk", name: "Help Desk", agents: [TriageAgent] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets: Record = { "T-1": "I can't log in since this morning.", "T-2": "I was charged twice for invoice INV-7.", }; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, text: tickets[id] ?? "" }; } } @Tool({ name: "get_invoice", description: "Get an invoice, with how many times it was charged.", inputSchema: { id: z.string() } }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, amount: "49 EUR", charges: 2 }; } } ``` ```ts model.example.ts // Stand-ins for the two agents' models. Triage's reads the ticket, asks billing // about charges, and passes billing's answer on; billing's looks the invoice up. // `seen` records the tools each model was offered. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const seen: Record = { triage: [], billing: [] }; export const triageModel: AgentLlmAdapter = { async completion(prompt, tools = []) { seen.triage.push(tools.map((tool) => tool.name)); const last = prompt.messages.at(-1)!; if (last.role === "user") { const { ticketId } = JSON.parse(last.content!); return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c1", name: "get_ticket", arguments: { id: ticketId } }] }; } if (last.name === "get_ticket") { const ticket = JSON.parse(last.content!); if (!/invoice|charge/i.test(ticket.text)) { return { content: `${ticket.id} is a login problem: the accounts team should take it.`, finishReason: "stop" }; } return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c2", name: "invoke_billing", arguments: { question: ticket.text } }] }; } return { content: JSON.parse(last.content!).response, finishReason: "stop" }; }, }; export const billingModel: AgentLlmAdapter = { async completion(prompt, tools = []) { seen.billing.push(tools.map((tool) => tool.name)); const last = prompt.messages.at(-1)!; if (last.role === "user") { const invoiceId = last.content!.match(/INV-\d+/)?.[0]; return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c1", name: "get_invoice", arguments: { id: invoiceId } }] }; } const invoice = JSON.parse(last.content!); return { content: `${invoice.id} was charged twice: refund the second charge of ${invoice.amount}.`, finishReason: "stop" }; }, }; ``` ```ts nested.test.ts import { test, expect } from "@frontmcp/testing"; import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { BillingAgent } from "./agents"; import { seen, triageModel } from "./model.example"; test("a billing ticket comes back with billing's answer", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" }); expect(result.json()).toEqual({ response: "INV-7 was charged twice: refund the second charge of 49 EUR." }); }); test("triage's model is offered billing as a tool, and billing's model its own", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { ticketId: "T-2" }); expect(seen.triage[0]).toEqual(["get_ticket", "invoke_billing"]); expect(seen.billing[0]).toEqual(["get_invoice"]); }); test("the client sees triage only", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["invoke_triage"]); expect(await mcp.tools.call("invoke_billing", { question: "Was INV-7 charged twice?" })).toBeError("TOOL_NOT_FOUND"); }); test("an agent class in another agent's `tools` is refused when the agent is declared", () => { expect(() => { @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [BillingAgent as never], llm: { adapter: triageModel } }) class Triage extends AgentContext {} }).toThrow("tools items must be annotated with @Tool()"); }); ``` Triage's model is offered a third tool now, `invoke_billing`, with billing's `description` and input. When it calls it, FrontMCP runs billing's whole loop, with billing's model, tools and instructions, and hands billing's answer back to triage's model as the call's result. The client made one call. It can't call billing itself: an agent in another agent's `agents` belongs to that agent. List agents in `agents`, not in `tools`, which only takes tools, as the last test shows. When billing should stay a tool of the server, which clients can call too, register both agents on the app, side by side, and give triage `swarm: { canSeeOtherAgents: true }`. Its model is then offered the app's other agents, and `visibleAgents: ["billing"]` narrows them to the ones you name. The [`@Agent` reference](https://frontmcp.dev/reference/sdk/agent#calling-other-agents) shows both. Changed in 1.9: before, FrontMCP accepted `agents` and `swarm` on an `@Agent` and ignored them, so triage's model was offered `get_ticket` and nothing else. > **Pitfall: The model decides whether to call billing** > > Triage's instructions ask its model to ask billing about charges, and this stand-in always does. A real model usually will, and sometimes won't: it may answer a billing question itself, or ask billing something else. When a billing ticket must reach billing, don't leave it to the model. Do it in code, as the next section does. ## Handing a task off from code An agent can call another the way it would call any tool on the server. It has [`this.callTool(name, args)`](https://frontmcp.dev/reference/sdk/call-tool), which runs a tool through the server's normal call flow, and to the server, an agent on the app is the tool `invoke_`. So billing goes back on the app, next to triage, and triage keeps its model loop and adds a step [after it, in `execute()`](https://frontmcp.dev/learn/giving-an-agent-tools#before-and-after-the-loop). Its instructions now ask the model to answer with a *handoff* when a ticket is about billing, and `execute()` acts on it: *[Illustration: A handoff from code. The MCP client makes one call, invoke_triage. Triage asks its model which team should take the ticket, and the model answers with a handoff to billing. Triage's execute() then calls this.callTool with invoke_billing; billing runs its own loop with its own model and tools and returns its answer as a CallToolResult; triage returns billing's answer to the client, which only ever saw the one call.]* Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-2" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { triageModel } from "./model.example"; import { GetTicket } from "./tools"; type Answer = { response?: string; handoff?: "billing"; question?: string }; @Agent({ name: "triage", description: "Read a support ticket and answer it, or hand billing questions to the billing agent.", systemInstructions: "Read the ticket with get_ticket, then say who should handle it. If it's about an invoice or a " + 'charge, answer only with {"handoff": "billing", "question": ""}.', inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket], llm: { adapter: triageModel }, }) export class TriageAgent extends AgentContext { async execute(input: { ticketId: string }) { const answer = (await super.execute(input)) as Answer; // triage's own model loop if (answer.handoff === "billing") { const result = await this.callTool("invoke_billing", { question: answer.question }); return result.structuredContent as Answer; } return answer; } } ``` ```ts billing.agent.ts import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { billingModel } from "./model.example"; @Agent({ name: "billing", description: "Answer a customer's question about an invoice or a charge.", inputSchema: { question: z.string().describe("The customer's question, with the invoice number") }, hideFromDiscovery: true, // triage is the way in llm: { adapter: billingModel }, }) export class BillingAgent extends AgentContext {} ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { BillingAgent } from "./billing.agent"; import { TriageAgent } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [TriageAgent, BillingAgent] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets: Record = { "T-1": "I can't log in since this morning.", "T-2": "I was charged twice for invoice INV-7.", }; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, text: tickets[id] ?? "" }; } } ``` ```ts model.example.ts // Stand-ins for the two agents' models. Triage's model follows its instructions: // billing tickets get a handoff. Billing's model answers at once here. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const triageModel: AgentLlmAdapter = { async completion(prompt) { const last = prompt.messages.at(-1)!; if (last.role === "user") { const { ticketId } = JSON.parse(last.content!); return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c1", name: "get_ticket", arguments: { id: ticketId } }] }; } const ticket = JSON.parse(last.content!); if (/invoice|charge/i.test(ticket.text)) { return { content: JSON.stringify({ handoff: "billing", question: ticket.text }), finishReason: "stop" }; } return { content: `${ticket.id} is a login problem: the accounts team should take it.`, finishReason: "stop" }; }, }; export const billingModel: AgentLlmAdapter = { async completion() { return { content: "INV-7 was charged twice: refund the second charge of 49 EUR.", finishReason: "stop" }; }, }; ``` ```ts handoff.test.ts import { test, expect } from "@frontmcp/testing"; test("a billing ticket comes back with billing's answer", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" }); expect(result.json()).toEqual({ response: "INV-7 was charged twice: refund the second charge of 49 EUR." }); }); test("any other ticket comes back with triage's answer", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json()).toEqual({ response: "T-1 is a login problem: the accounts team should take it." }); }); test("only triage is listed, but billing can still be called by name", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["invoke_triage"]); expect(await mcp.tools.call("invoke_billing", { question: "Was INV-7 charged twice?" })).toBeSuccessful(); }); ``` Read `execute()` from the top: 1. **`super.execute(input)`** runs triage's own loop: its model reads the ticket and answers. For T-2 the answer is the text `{"handoff": "billing", "question": "I was charged twice for invoice INV-7."}`, and FrontMCP turns a final answer that parses as JSON into an object, so `answer.handoff` is `"billing"`. 2. **`this.callTool("invoke_billing", { question })`** runs the billing agent, with its own model, tools and instructions, as if a client had called `invoke_billing`. 3. **`result.structuredContent`** is billing's answer, `{ response: "…" }`, and triage returns it as its own. The model decides *whether* to hand off, and the code does the handing. That split is the point: the model is good at reading a ticket, and the code makes sure a handoff goes to an agent that exists, with the input it expects, and that billing's answer reaches the client as billing gave it. The client sees one call, `invoke_triage`, and one answer. `this.callTool()` reaches agents on the app, and also the agents in triage's own `agents`: with billing nested in triage, as in the [last section](https://frontmcp.dev/learn/agents-that-call-agents#letting-triages-model-call-billing), `this.callTool("invoke_billing")` finds it too. So does [`this.invokeAgent("billing", input)`](https://frontmcp.dev/reference/sdk/agent#calling-other-agents), which returns billing's answer itself rather than a `CallToolResult`. (Changed in 1.9.2: before, `this.callTool()` didn't reach a nested agent, and answered `Tool "invoke_billing" not found`.) Now that triage hands billing questions off, the client doesn't need `invoke_billing`, so `billing.agent.ts` sets `hideFromDiscovery: true`, which takes it out of `tools/list`. It hides the agent; it doesn't lock it. The last test calls `invoke_billing` by name, and it works, so an agent whose work needs protecting still needs [authorization](https://frontmcp.dev/learn/authorizing-calls) like any tool. ## When the other agent fails When the agent you call fails, `this.callTool()` doesn't return an error result: it throws. Here the model provider behind billing is overloaded, which `model.example.ts` switches on with `provider.overloaded`. Open the **Tests** tab: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-2" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { triageModel } from "./model.example"; import { GetTicket } from "./tools"; type Answer = { response?: string; handoff?: "billing"; question?: string }; @Agent({ name: "triage", description: "Read a support ticket and answer it, or hand billing questions to the billing agent.", systemInstructions: "Read the ticket with get_ticket, then say who should handle it. If it's about an invoice or a " + 'charge, answer only with {"handoff": "billing", "question": ""}.', inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket], llm: { adapter: triageModel }, }) export class TriageAgent extends AgentContext { async execute(input: { ticketId: string }) { const answer = (await super.execute(input)) as Answer; if (answer.handoff !== "billing") return answer; try { const result = await this.callTool("invoke_billing", { question: answer.question }); return result.structuredContent as Answer; } catch (error) { this.logger.warn(`Billing couldn't answer ${input.ticketId}: ${(error as Error).message}`); return { response: `${input.ticketId} is a billing question, and the billing agent can't answer right now. A person from billing will pick it up.` }; } } } ``` ```ts billing.agent.ts import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { billingModel } from "./model.example"; @Agent({ name: "billing", description: "Answer a customer's question about an invoice or a charge.", inputSchema: { question: z.string().describe("The customer's question, with the invoice number") }, hideFromDiscovery: true, llm: { adapter: billingModel }, }) export class BillingAgent extends AgentContext {} ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { BillingAgent } from "./billing.agent"; import { TriageAgent } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [TriageAgent, BillingAgent] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets: Record = { "T-1": "I can't log in since this morning.", "T-2": "I was charged twice for invoice INV-7.", }; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, text: tickets[id] ?? "" }; } } ``` ```ts model.example.ts // Stand-ins for the two agents' models, as in the example above. // `provider.overloaded` makes billing's model fail, as a real provider does when it's busy. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const provider = { overloaded: true }; export const triageModel: AgentLlmAdapter = { async completion(prompt) { const last = prompt.messages.at(-1)!; if (last.role === "user") { const { ticketId } = JSON.parse(last.content!); return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c1", name: "get_ticket", arguments: { id: ticketId } }] }; } const ticket = JSON.parse(last.content!); if (/invoice|charge/i.test(ticket.text)) { return { content: JSON.stringify({ handoff: "billing", question: ticket.text }), finishReason: "stop" }; } return { content: `${ticket.id} is a login problem: the accounts team should take it.`, finishReason: "stop" }; }, }; export const billingModel: AgentLlmAdapter = { async completion() { if (provider.overloaded) throw new Error("The model provider is overloaded. Try again in 30 seconds."); return { content: "INV-7 was charged twice: refund the second charge of 49 EUR.", finishReason: "stop" }; }, }; ``` ```ts failure.test.ts import { test, expect } from "@frontmcp/testing"; import { provider } from "./model.example"; test("triage answers anyway when billing fails", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ response: "T-2 is a billing question, and the billing agent can't answer right now. A person from billing will pick it up.", }); }); test("billing's answer comes through when billing is up", async ({ mcp }) => { provider.overloaded = false; const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" }); expect(result.json()).toEqual({ response: "INV-7 was charged twice: refund the second charge of 49 EUR." }); }); ``` The error's message is `Tool "invoke_billing" execution failed: ` followed by billing's own error. Take the `try` out and run the tests: triage's call fails too, and the client gets `Tool "invoke_triage" execution failed: Tool "invoke_billing" execution failed: The model provider is overloaded…`, which tells the client's model nothing it can act on. So catch it. What triage answers instead is up to the help desk; here it says who will pick the ticket up, and logs the real error for whoever runs the server (open the **Logs** tab). ## Handoffs that never stop A handoff can lead to another handoff. The help desk has support tiers, and `escalate` passes a ticket up one tier at a time, calling itself, until a tier's model can solve it. Only tier 4 can solve T-5. FrontMCP limits how many calls from one agent to another a chain may make, with `swarm.maxCallDepth`, 3 by default. Here it's 2: Initial call configuration: ```js { tool: "invoke_escalate", arguments: { ticketId: "T-5", tier: 1 } } ``` ```ts escalate.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { tierModel } from "./model.example"; type Answer = { response?: string; escalate?: boolean }; @Agent({ name: "escalate", description: "Work on a ticket at one support tier, and pass it up a tier if this one can't solve it.", systemInstructions: 'Try to solve the ticket. If you can\'t, answer only with {"escalate": true}.', inputSchema: { ticketId: z.string().describe("Ticket id, like T-5"), tier: z.number().int().min(1).describe("The support tier working on it, from 1"), }, swarm: { maxCallDepth: 2 }, llm: { adapter: tierModel }, }) export class EscalateAgent extends AgentContext { async execute(input: { ticketId: string; tier: number }) { const answer = (await super.execute(input)) as Answer; if (answer.escalate) { const result = await this.callTool("invoke_escalate", { ticketId: input.ticketId, tier: input.tier + 1 }); return result.structuredContent as Answer; } return answer; } } ``` ```ts model.example.ts // A stand-in for the model at each tier: only tier 4 can solve T-5, and every tier // below escalates. `tiers` records which tiers worked on a ticket. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const tiers: number[] = []; export const tierModel: AgentLlmAdapter = { async completion(prompt) { const { ticketId, tier } = JSON.parse(prompt.messages[0].content!); tiers.push(tier); if (tier < 4) return { content: JSON.stringify({ escalate: true }), finishReason: "stop" }; return { content: `Tier ${tier} solved ${ticketId}: the customer's SSO certificate had expired.`, finishReason: "stop" }; }, }; ``` ```ts escalate.test.ts import { test, expect } from "@frontmcp/testing"; import { tiers } from "./model.example"; test("the ticket stops at tier 3, and the call fails", async ({ mcp }) => { const result = await mcp.tools.call("invoke_escalate", { ticketId: "T-5", tier: 1 }); expect(result).toBeError("AGENT_CALL_DEPTH_EXCEEDED"); expect(result.text()).toBe( 'Agent "escalate" can\'t be called from "escalate" -> "escalate" -> "escalate": that would be agent call 3, deeper than maxCallDepth 2', ); expect(tiers).toEqual([1, 2, 3]); }); ``` Tier 1 called tier 2, and tier 2 called tier 3: two calls from one agent to another. Tier 3's call to tier 4 would have been the third, so FrontMCP refused it, with `AGENT_CALL_DEPTH_EXCEEDED`. Tier 3's `this.callTool()` threw, and so did tier 2's and tier 1's, so the client's whole call failed, with the message above. The limit counts every way one agent calls another: a model's call, `this.invokeAgent()`, and `this.callTool("invoke_")`, as here. Changed in 1.9: before, `maxCallDepth` wasn't enforced, and the ticket went up all four tiers. The limit keeps a chain from running forever, whether an agent calls itself or two agents hand a ticket back and forth ("that's billing", "that's not billing"). But it stops the chain by failing the call, after every tier below it has paid for its model call, and the client gets an error instead of an answer. With the default of 3, T-5 would have reached tier 4 and been solved; a ticket no tier can solve would fail the same way at tier 4. So write a limit of your own, that ends the chain with an answer: - **Hand off in one direction.** Triage hands off to billing; billing answers, and never hands back. A helper agent that can't answer says so, and the agent that called it decides what happens next. - **Count the steps when an agent calls itself.** `escalate` knows its tier, so it can stop at a fixed one and hand the ticket to a person. The [second challenge](https://frontmcp.dev/learn/agents-that-call-agents#try-some-challenges) adds that check. The [Research Assistant](https://frontmcp.dev/examples/research-assistant) example is a coordinator that calls a narrower agent, checks what comes back, and refuses an answer that cites something it wasn't given. ## Recap - Agents registered on the same app don't see each other: each agent's model is offered only its own tools. The client sees each agent as `invoke_`, and its model can chain them. - `agents: [...]` on an `@Agent` offers its model those agents as `invoke_` tools, and keeps them from the client. `swarm: { canSeeOtherAgents: true }` offers the app's other agents instead. Either way, the model decides whether to call them. (Before 1.9, both were ignored.) - An agent hands a task to another from `execute()`: `super.execute(input)` runs its own model, and `this.callTool("invoke_", args)` runs the other agent, whose answer is the result's `structuredContent`. Let the model decide whether to hand off, and the code do it. - `hideFromDiscovery: true` takes a helper agent out of `tools/list`; a client can still call it by name. - When the other agent fails, `this.callTool()` throws. Catch it, and answer something the client's model can use. - `swarm.maxCallDepth`, 3 by default, fails a chain of agent calls that goes deeper, by failing the client's whole call. Hand off in one direction, or count the steps, so a chain ends with an answer first. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Hand off to the right specialist The help desk has a second specialist now: `accounts`, for login problems. Triage's model answers with `{"handoff": "billing"}` or `{"handoff": "accounts"}` and the customer's question, but `execute()` only knows about billing. Make triage hand each ticket to the agent its model names. Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { triageModel } from "./model.example"; import { GetTicket } from "./tools"; type Answer = { response?: string; handoff?: "billing" | "accounts"; question?: string }; @Agent({ name: "triage", description: "Read a support ticket and hand it to the specialist agent who should answer it.", systemInstructions: 'Read the ticket with get_ticket. Answer only with {"handoff": "billing" or "accounts", "question": ""}.', inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket], llm: { adapter: triageModel }, }) export class TriageAgent extends AgentContext { async execute(input: { ticketId: string }) { const answer = (await super.execute(input)) as Answer; if (answer.handoff === "billing") { const result = await this.callTool("invoke_billing", { question: answer.question }); return result.structuredContent as Answer; } return answer; } } ``` **Solution file (alternative to the starter):** ```ts triage.agent.ts solution import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { triageModel } from "./model.example"; import { GetTicket } from "./tools"; type Answer = { response?: string; handoff?: "billing" | "accounts"; question?: string }; const specialists = { billing: "invoke_billing", accounts: "invoke_accounts" }; @Agent({ name: "triage", description: "Read a support ticket and hand it to the specialist agent who should answer it.", systemInstructions: 'Read the ticket with get_ticket. Answer only with {"handoff": "billing" or "accounts", "question": ""}.', inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, tools: [GetTicket], llm: { adapter: triageModel }, }) export class TriageAgent extends AgentContext { async execute(input: { ticketId: string }) { const answer = (await super.execute(input)) as Answer; const tool = answer.handoff && specialists[answer.handoff]; if (tool) { const result = await this.callTool(tool, { question: answer.question }); return result.structuredContent as Answer; } return answer; } } ``` ```ts specialists.agent.ts import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { accountsModel, billingModel } from "./model.example"; @Agent({ name: "billing", description: "Answer a customer's question about an invoice or a charge.", inputSchema: { question: z.string() }, llm: { adapter: billingModel }, }) export class BillingAgent extends AgentContext {} @Agent({ name: "accounts", description: "Help a customer who can't log in.", inputSchema: { question: z.string() }, llm: { adapter: accountsModel }, }) export class AccountsAgent extends AgentContext {} ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { AccountsAgent, BillingAgent } from "./specialists.agent"; import { TriageAgent } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [TriageAgent, BillingAgent, AccountsAgent] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets: Record = { "T-1": "I can't log in since this morning.", "T-2": "I was charged twice for invoice INV-7.", }; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, text: tickets[id] ?? "" }; } } ``` ```ts model.example.ts // Stand-ins for the three agents' models. Triage hands billing questions to // billing and everything else to accounts; the specialists answer at once. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const triageModel: AgentLlmAdapter = { async completion(prompt) { const last = prompt.messages.at(-1)!; if (last.role === "user") { const { ticketId } = JSON.parse(last.content!); return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c1", name: "get_ticket", arguments: { id: ticketId } }] }; } const ticket = JSON.parse(last.content!); const handoff = /invoice|charge/i.test(ticket.text) ? "billing" : "accounts"; return { content: JSON.stringify({ handoff, question: ticket.text }), finishReason: "stop" }; }, }; export const billingModel: AgentLlmAdapter = { async completion() { return { content: "INV-7 was charged twice: refund the second charge of 49 EUR.", finishReason: "stop" }; }, }; export const accountsModel: AgentLlmAdapter = { async completion() { return { content: "The account was locked after 5 failed logins. Send a reset link.", finishReason: "stop" }; }, }; ``` **Hidden fixture/test file (runs with the example):** ```ts route.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("a login ticket gets the accounts agent's answer", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json()).toEqual({ response: "The account was locked after 5 failed logins. Send a reset link." }); }); test("a billing ticket still gets the billing agent's answer", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" }); expect(result.json()).toEqual({ response: "INV-7 was charged twice: refund the second charge of 49 EUR." }); }); ``` **Hint:** Each specialist is the tool `invoke_` plus its name. Which of the two names comes from the model? **Solution:** The model's `handoff` names the specialist, and a small map turns it into the tool to call. You could build the name with `` `invoke_${answer.handoff}` ``, but the map keeps the model from choosing any tool on the server: a handoff to a name that isn't in it is ignored, and triage returns its model's answer as it is. ### Challenge: Stop escalating at tier 3 `escalate` passes a ticket up the support tiers until one solves it, and only FrontMCP's depth limit stops it, by failing the call. Tier 3 is the last tier with agents; above it are people. Change `execute()` so that when tier 3 can't solve a ticket, the call answers `{ "response": "No tier could solve T-5. An engineer will take it." }` (with the ticket's id) instead of escalating again. Initial call configuration: ```js { tool: "invoke_escalate", arguments: { ticketId: "T-5", tier: 1 } } ``` ```ts escalate.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { tierModel } from "./model.example"; type Answer = { response?: string; escalate?: boolean }; @Agent({ name: "escalate", description: "Work on a ticket at one support tier, and pass it up a tier if this one can't solve it.", systemInstructions: 'Try to solve the ticket. If you can\'t, answer only with {"escalate": true}.', inputSchema: { ticketId: z.string().describe("Ticket id, like T-5"), tier: z.number().int().min(1).describe("The support tier working on it, from 1"), }, llm: { adapter: tierModel }, }) export class EscalateAgent extends AgentContext { async execute(input: { ticketId: string; tier: number }) { const answer = (await super.execute(input)) as Answer; if (answer.escalate) { const result = await this.callTool("invoke_escalate", { ticketId: input.ticketId, tier: input.tier + 1 }); return result.structuredContent as Answer; } return answer; } } ``` **Solution file (alternative to the starter):** ```ts escalate.agent.ts solution import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { tierModel } from "./model.example"; type Answer = { response?: string; escalate?: boolean }; const LAST_TIER = 3; @Agent({ name: "escalate", description: "Work on a ticket at one support tier, and pass it up a tier if this one can't solve it.", systemInstructions: 'Try to solve the ticket. If you can\'t, answer only with {"escalate": true}.', inputSchema: { ticketId: z.string().describe("Ticket id, like T-5"), tier: z.number().int().min(1).describe("The support tier working on it, from 1"), }, llm: { adapter: tierModel }, }) export class EscalateAgent extends AgentContext { async execute(input: { ticketId: string; tier: number }) { const answer = (await super.execute(input)) as Answer; if (!answer.escalate) return answer; if (input.tier >= LAST_TIER) { return { response: `No tier could solve ${input.ticketId}. An engineer will take it.` }; } const result = await this.callTool("invoke_escalate", { ticketId: input.ticketId, tier: input.tier + 1 }); return result.structuredContent as Answer; } } ``` ```ts model.example.ts // A stand-in for the model at each tier. Tier 2 can solve T-4; no tier can solve // T-5, so it escalates until FrontMCP's depth limit fails the call. `tiers` records which tiers worked. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const tiers: number[] = []; export const tierModel: AgentLlmAdapter = { async completion(prompt) { const { ticketId, tier } = JSON.parse(prompt.messages[0].content!); tiers.push(tier); if (ticketId === "T-4" && tier === 2) { return { content: "Tier 2 solved T-4: the customer's cache needed clearing.", finishReason: "stop" }; } return { content: JSON.stringify({ escalate: true }), finishReason: "stop" }; }, }; ``` **Hidden fixture/test file (runs with the example):** ```ts escalate.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { tiers } from "./model.example"; test("a ticket no tier can solve stops at tier 3", async ({ mcp }) => { const result = await mcp.tools.call("invoke_escalate", { ticketId: "T-5", tier: 1 }); expect(result.json()).toEqual({ response: "No tier could solve T-5. An engineer will take it." }); expect(tiers).toEqual([1, 2, 3]); }); test("a ticket tier 2 can solve still gets tier 2's answer", async ({ mcp }) => { const result = await mcp.tools.call("invoke_escalate", { ticketId: "T-4", tier: 1 }); expect(result.json()).toEqual({ response: "Tier 2 solved T-4: the customer's cache needed clearing." }); }); ``` **Hint:** `execute()` knows its tier from its input. Check it after the model has answered, and before calling the next tier. **Solution:** Each call knows its tier, so the tier that would escalate past 3 answers instead of calling `invoke_escalate` again. The check comes after the model's answer, so tier 3 still gets its chance to solve the ticket, and before the call, so tier 4 never starts. Without it, T-5 climbs to tier 4, whose call to tier 5 is past the default `maxCallDepth` of 3, so the whole call fails with `AGENT_CALL_DEPTH_EXCEEDED`, after four paid model calls and with no answer for the client. --- # Teaching the Model Skills > Write a FrontMCP skill with @Skill, how clients find and load it via skill:// resources and skills/search, and when to use a skill instead of an agent or prompt. Source: https://frontmcp.dev/learn/teaching-the-model-skills [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Some of what a support agent knows isn't a single action but a procedure: to refund a duplicate charge, read the invoice, refund every charge after the first and never the first, then tell the customer how long it takes. The tools for each step already exist. What the client's model lacks is the order and the rules. A **skill** is that procedure, written down: instructions, the tools they use, and when to use them, served by your FrontMCP server for the client's model to read and follow itself. This lesson writes one, and shows how a client finds and reads it. **You will learn** - How to write a skill with `@Skill`: description, instructions, tools, parameters and examples - What a skill adds to your server, and how a client finds and reads it - How to search for and load skills with FrontMCP's `skills/search` and `skills/load` - Where a skill's instructions can come from: inline, a file or a URL - When to write a skill, an agent or a prompt ## Writing a skill The help desk has the three tools a duplicate-charge refund needs. Here's the procedure that ties them together. Open the **Tests** tab: Initial call configuration: ```js { resource: "skill://refund-duplicate-charge/SKILL.md" } ``` ```ts refund.skill.ts active import { Skill, SkillContext } from "@frontmcp/sdk"; @Skill({ name: "refund-duplicate-charge", description: "Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.", instructions: `1. Read the invoice with get_invoice. Refund only if it has more than one charge. 2. Refund every charge after the first with refund_charge. Never refund the first charge. 3. Reply on the ticket with reply_to_ticket: say how much was refunded, and that it takes 3 to 5 business days.`, tools: [ "get_invoice", { name: "refund_charge", purpose: "Refund one duplicate charge", required: true }, "reply_to_ticket", ], parameters: [ { name: "ticketId", description: "The customer's ticket, like T-2", required: true }, { name: "invoiceId", description: "The invoice that was charged twice, like INV-7", required: true }, ], examples: [ { scenario: "A customer writes on T-2 that INV-7 was charged twice", parameters: { ticketId: "T-2", invoiceId: "INV-7" }, expectedOutcome: "The second charge is refunded, and T-2 says so", }, ], }) export class RefundDuplicateCharge extends SkillContext {} ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { RefundDuplicateCharge } from "./refund.skill"; import { GetInvoice, RefundCharge, ReplyToTicket } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [GetInvoice, RefundCharge, ReplyToTicket], skills: [RefundDuplicateCharge], }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; export const refunded: string[] = []; export const replies: string[] = []; @Tool({ name: "get_invoice", description: "Get an invoice, with its charges.", inputSchema: { id: z.string() } }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, charges: [{ id: "ch_1", amount: "49 EUR" }, { id: "ch_2", amount: "49 EUR" }] }; } } @Tool({ name: "refund_charge", description: "Refund one charge.", inputSchema: { chargeId: z.string() } }) export class RefundCharge extends ToolContext { async execute({ chargeId }: { chargeId: string }) { refunded.push(chargeId); return { refunded: chargeId }; } } @Tool({ name: "reply_to_ticket", description: "Reply to the customer on a ticket.", inputSchema: { ticketId: z.string(), message: z.string() } }) export class ReplyToTicket extends ToolContext { async execute({ ticketId, message }: { ticketId: string; message: string }) { replies.push(`${ticketId}: ${message}`); return { sent: true }; } } ``` ```ts skill.test.ts import { test, expect } from "@frontmcp/testing"; test("a skill adds no tools", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t) => t.name); expect(names).toEqual(["get_invoice", "refund_charge", "reply_to_ticket"]); }); test("it adds two resources: the skill's SKILL.md, and an index", async ({ mcp }) => { const resources = await mcp.resources.list(); expect(resources.map((r) => r.uri).sort()).toEqual(["skill://index.json", "skill://refund-duplicate-charge/SKILL.md"]); expect(resources.find((r) => r.uri.endsWith("SKILL.md"))).toMatchObject({ mimeType: "text/markdown", annotations: { audience: ["assistant"] }, }); }); test("SKILL.md is the skill's metadata, then its instructions", async ({ mcp }) => { const md = (await mcp.resources.read("skill://refund-duplicate-charge/SKILL.md")).text(); expect(md).toBe(`--- name: refund-duplicate-charge description: Refund a customer who was charged more than once for the same invoice, and tell them on their ticket. tools: - get_invoice - name: refund_charge purpose: Refund one duplicate charge required: true - reply_to_ticket parameters: - name: ticketId description: The customer's ticket, like T-2 required: true type: string - name: invoiceId description: The invoice that was charged twice, like INV-7 required: true type: string examples: - scenario: A customer writes on T-2 that INV-7 was charged twice parameters: ticketId: T-2 invoiceId: INV-7 expectedOutcome: The second charge is refunded, and T-2 says so --- 1. Read the invoice with get_invoice. Refund only if it has more than one charge. 2. Refund every charge after the first with refund_charge. Never refund the first charge. 3. Reply on the ticket with reply_to_ticket: say how much was refunded, and that it takes 3 to 5 business days. `); }); ``` A skill is a class, like a tool, with no code of its own: - **`name`** is its id, in kebab-case: lowercase letters, numbers and hyphens. - **`description`** is what a model reads when it decides whether this skill fits the task, so say when to use it. - **`instructions`** are the procedure. Write them for a model that has never seen your help desk: the steps in order, the tool for each, and the rules it mustn't break. - **`tools`** names the tools the procedure uses. A name is enough; `{ name, purpose, required }` adds why the tool is there and whether the skill works without it. - **`parameters`** and **`examples`** say what the model needs to know before it starts, and show one case from start to end. - **`skills: [...]`** on `@App` registers it. **`SkillContext`** is the base class, and the class body stays empty. The first test is the one to notice: a skill adds no tools. It doesn't *do* anything. FrontMCP serves it as a document, `SKILL.md`: the metadata as YAML, then the instructions. That's the format of the [Agent Skills](https://agentskills.io/specification) standard, so a model that knows skills from elsewhere knows how to read it. > **Note** > > `tools` also takes tool classes, `tools: [GetInvoice]`, or `{ tool: GetInvoice, purpose }`, and FrontMCP lists each by the tool's name. A class can't leave a skill behind when the tool is renamed, the way a name in a string can. The [search and load](https://frontmcp.dev/learn/teaching-the-model-skills#searching-and-loading-skills) example has one. Changed in 1.9: before, a tool class stopped the server from starting. ## How a client finds a skill A skill reaches the client as [resources](https://frontmcp.dev/learn/exposing-data-with-resources), following the proposed MCP skills extension (SEP-2640): `skill://index.json` lists every skill with its description and address, and `skill:///SKILL.md` is each one. A client that supports skills reads the index, picks a skill by its description, reads its `SKILL.md`, and then its model follows the steps with your tools. This test plays that client: *[Illustration: How a client uses a skill. The MCP client reads skill://index.json from your server and gets each skill's name and description. It picks refund-duplicate-charge by its description and reads skill://refund-duplicate-charge/SKILL.md, getting the instructions, which it puts in its model's context. The client's model then calls get_invoice, refund_charge and reply_to_ticket on your server. The server only answered reads and tool calls; it never ran the skill.]* Initial call configuration: ```js { resource: "skill://index.json" } ``` ```ts client.test.ts active import { test, expect } from "@frontmcp/testing"; import { refunded, replies } from "./tools"; test("a client finds the skill, reads it, and follows it", async ({ mcp }) => { // 1. Read the index, and pick the skill whose description fits the task. const index = (await mcp.resources.read("skill://index.json")).json(); const skill = index.skills.find((s: { description?: string }) => s.description?.includes("charged more than once")); expect(skill).toEqual({ type: "skill-md", name: "refund-duplicate-charge", description: "Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.", url: "skill://refund-duplicate-charge/SKILL.md", }); // 2. Read its SKILL.md. const md = (await mcp.resources.read(skill.url)).text(); expect(md).toContain("Never refund the first charge."); // 3. Follow the steps with the server's tools, as the model would. const invoice = (await mcp.tools.call("get_invoice", { id: "INV-7" })).json(); for (const charge of invoice.charges.slice(1)) { await mcp.tools.call("refund_charge", { chargeId: charge.id }); } await mcp.tools.call("reply_to_ticket", { ticketId: "T-2", message: "We refunded the duplicate charge of 49 EUR. It takes 3 to 5 business days." }); expect(refunded).toEqual(["ch_2"]); expect(replies).toHaveLength(1); }); test("the server says it has skills, and its instructions list them", async ({ mcp }) => { const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" })) as any; expect(result.capabilities.extensions).toEqual({ "io.modelcontextprotocol/tasks": {}, "io.modelcontextprotocol/skills": {}, }); expect(result.instructions).toBe( "Help desk tools.\n\n---\n\nAvailable skills (read the `skill://index.json` resource for each skill's `SKILL.md` URI):\n\n" + "- **refund-duplicate-charge**: Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.", ); }); ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { RefundDuplicateCharge } from "./refund.skill"; import { GetInvoice, RefundCharge, ReplyToTicket } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [GetInvoice, RefundCharge, ReplyToTicket], skills: [RefundDuplicateCharge], }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, instructions: "Help desk tools.", apps: [HelpDeskApp], }) export default class Server {} ``` ```ts refund.skill.ts import { Skill, SkillContext } from "@frontmcp/sdk"; @Skill({ name: "refund-duplicate-charge", description: "Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.", instructions: `1. Read the invoice with get_invoice. Refund only if it has more than one charge. 2. Refund every charge after the first with refund_charge. Never refund the first charge. 3. Reply on the ticket with reply_to_ticket: say how much was refunded, and that it takes 3 to 5 business days.`, tools: ["get_invoice", { name: "refund_charge", purpose: "Refund one duplicate charge", required: true }, "reply_to_ticket"], }) export class RefundDuplicateCharge extends SkillContext {} ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; export const refunded: string[] = []; export const replies: string[] = []; @Tool({ name: "get_invoice", description: "Get an invoice, with its charges.", inputSchema: { id: z.string() } }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, charges: [{ id: "ch_1", amount: "49 EUR" }, { id: "ch_2", amount: "49 EUR" }] }; } } @Tool({ name: "refund_charge", description: "Refund one charge.", inputSchema: { chargeId: z.string() } }) export class RefundCharge extends ToolContext { async execute({ chargeId }: { chargeId: string }) { refunded.push(chargeId); return { refunded: chargeId }; } } @Tool({ name: "reply_to_ticket", description: "Reply to the customer on a ticket.", inputSchema: { ticketId: z.string(), message: z.string() } }) export class ReplyToTicket extends ToolContext { async execute({ ticketId, message }: { ticketId: string; message: string }) { replies.push(`${ticketId}: ${message}`); return { sent: true }; } } ``` The skill never runs. The client's model reads it and does the work, with your tools and its own tokens, and FrontMCP checks each tool call as it would any other: the skill's rules are advice to the model, not limits on the tools. "Never refund the first charge" is only as strong as the model that reads it, so a rule that must hold belongs in `refund_charge` itself. The second test is how a client learns the server has skills. `server/discover` lists the extension `io.modelcontextprotocol/skills`, and a client that knows it looks for `skill://index.json`. A client that doesn't know it still sees the `SKILL.md` resources in `resources/list`, marked for the assistant. And FrontMCP lists every skill in the server's `instructions`, after your own: its name, its description, and where to read it, so the model knows the skills are there before it reads anything. (Changed in 1.9.3: before, under 2026-07-28, the `instructions` had nothing about skills, and you had to write a sentence there pointing the model at the index.) ## Searching and loading skills FrontMCP also answers requests of its own for skills: `skills/search` finds skills by the words in a task, and `skills/load` returns skills ready to use. They're JSON-RPC methods, not tools, so they're for clients written to use them, like your own. FrontMCP's docs call them `searchSkills` and `loadSkill` tools; in 1.9.3 they aren't in `tools/list`. Here a second skill names a tool the server doesn't have: ```ts search.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { byClassConfig, strictConfig } from "./other-servers"; test("skills/search finds skills by the words in a task", async ({ mcp }) => { const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/search", params: { query: "customer was charged twice" }, })) as any; expect(result.skills).toEqual([ expect.objectContaining({ name: "refund-duplicate-charge", score: expect.any(Number), source: "local" }), ]); expect(result.guidance).toBe("Found 1 matching skill(s). Use skills/load with skill IDs to load full content."); }); test("skills/load returns instructions, and which tools are missing", async ({ mcp }) => { const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 2, method: "skills/load", params: { skillIds: ["refund-duplicate-charge", "escalate-outage"] }, })) as any; const [refund, outage] = result.skills; expect(refund).toMatchObject({ isComplete: true, missingTools: [] }); expect(refund.instructions).toContain("Never refund the first charge."); expect(outage).toMatchObject({ isComplete: false, availableTools: ["get_invoice"], missingTools: ["page_on_call"], }); expect(outage.tools).toEqual([ { name: "get_invoice", available: true, inputSchema: expect.objectContaining({ type: "object" }) }, { name: "page_on_call", purpose: "Wake the on-call engineer", available: false }, ]); expect(outage.formattedContent).toMatch(/^# Skill: escalate-outage\n/); expect(result.summary.combinedWarnings).toEqual([ 'Skill "escalate-outage" references missing tools: page_on_call. Some functionality may be limited.', ]); }); test('`toolValidation: "strict"` stops a server that lacks a tool', async () => { await expect(FrontMcpInstance.createDirect(strictConfig)).rejects.toThrow( "Skill 'escalate-outage' failed tool validation: missing tools [page_on_call]", ); }); test("a tool class in `tools` works like its name", async () => { const server = await FrontMcpInstance.createDirect(byClassConfig); try { const { contents } = await server.readResource("skill://check-invoice/SKILL.md"); expect(contents[0].text).toContain("tools:\n - get_invoice\n - name: refund_charge\n purpose: Refund one duplicate charge\n"); } finally { await server.dispose(); } }); ``` ```ts skills.ts import { Skill, SkillContext } from "@frontmcp/sdk"; @Skill({ name: "refund-duplicate-charge", description: "Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.", instructions: `1. Read the invoice with get_invoice. Refund only if it has more than one charge. 2. Refund every charge after the first with refund_charge. Never refund the first charge. 3. Reply on the ticket with reply_to_ticket: say how much was refunded, and that it takes 3 to 5 business days.`, tools: ["get_invoice", { name: "refund_charge", purpose: "Refund one duplicate charge", required: true }], }) export class RefundDuplicateCharge extends SkillContext {} @Skill({ name: "escalate-outage", description: "Escalate a ticket that reports an outage to the on-call engineer.", instructions: "1. Check the customer's invoices with get_invoice to see their plan.\n2. Page the on-call engineer with page_on_call.", tools: ["get_invoice", { name: "page_on_call", purpose: "Wake the on-call engineer", required: true }], }) export class EscalateOutage extends SkillContext {} ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { EscalateOutage, RefundDuplicateCharge } from "./skills"; import { GetInvoice, RefundCharge } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [GetInvoice, RefundCharge], skills: [RefundDuplicateCharge, EscalateOutage], }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts other-servers.ts import { App, Skill, SkillContext } from "@frontmcp/sdk"; import { GetInvoice, RefundCharge } from "./tools"; @Skill({ name: "escalate-outage", description: "Escalate a ticket that reports an outage to the on-call engineer.", instructions: "1. Check the customer's invoices with get_invoice to see their plan.\n2. Page the on-call engineer with page_on_call.", tools: ["get_invoice", "page_on_call"], toolValidation: "strict", }) class StrictEscalateOutage extends SkillContext {} @App({ id: "strict", name: "Strict", tools: [GetInvoice], skills: [StrictEscalateOutage] }) class StrictApp {} export const strictConfig = { info: { name: "strict", version: "1.0.0" }, apps: [StrictApp] }; @Skill({ name: "check-invoice", description: "Check an invoice for duplicate charges, and refund them.", instructions: "Read the invoice with get_invoice. Refund every charge after the first with refund_charge.", tools: [GetInvoice, { tool: RefundCharge, purpose: "Refund one duplicate charge" }], }) class CheckInvoice extends SkillContext {} @App({ id: "by-class", name: "By class", tools: [GetInvoice, RefundCharge], skills: [CheckInvoice] }) class ByClassApp {} export const byClassConfig = { info: { name: "by-class", version: "1.0.0" }, apps: [ByClassApp] }; ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_invoice", description: "Get an invoice, with its charges.", inputSchema: { id: z.string() } }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, charges: [{ id: "ch_1", amount: "49 EUR" }, { id: "ch_2", amount: "49 EUR" }] }; } } @Tool({ name: "refund_charge", description: "Refund one charge.", inputSchema: { chargeId: z.string() } }) export class RefundCharge extends ToolContext { async execute({ chargeId }: { chargeId: string }) { return { refunded: chargeId }; } } ``` `skills/load` answers with more than `SKILL.md`: each tool the skill names, with `available` and, if it's there, its input schema; `availableTools` and `missingTools`; `isComplete`, false when a named tool is missing; and `formattedContent`, all of it as one markdown document a client can hand its model. A skill that names a missing tool is still served and still loads, and the server starts anyway, so check `missingTools` in your tests, as the [first challenge](https://frontmcp.dev/learn/teaching-the-model-skills#try-some-challenges) does. Or set `toolValidation: "strict"` on the skill: then a missing tool stops the server from starting, as the third test shows. (Changed in 1.9: before, `"strict"` didn't stop it.) The last test names the tools by class. ## Where instructions come from Instructions can be written inline, as so far, or kept in a markdown file next to the code: `{ file: "./refund.md" }`, relative to the file that declares the skill. Long procedures are easier to write and review as markdown. The Playground has no files, so here's what it looks like on a real server: ```ts refund.skill.ts @Skill({ name: "refund-duplicate-charge", description: "Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.", instructions: { file: "./refund-duplicate-charge.md" }, tools: ["get_invoice", "refund_charge", "reply_to_ticket"], }) export class RefundDuplicateCharge extends SkillContext {} ``` The third source is a URL, for procedures that live somewhere else, like the support team's handbook. `docs.example.ts` stands in for that site, as `billing.example.ts` does for billing in [Calling Other Services](https://frontmcp.dev/learn/calling-other-services): Initial call configuration: ```js { resource: "skill://refund-duplicate-charge/SKILL.md" } ``` ```ts refund.skill.ts active import { Skill, SkillContext } from "@frontmcp/sdk"; @Skill({ name: "refund-duplicate-charge", description: "Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.", instructions: { url: "https://handbook.help-desk.example/refunds/duplicate-charge.md" }, tools: ["get_invoice", "refund_charge", "reply_to_ticket"], }) export class RefundDuplicateCharge extends SkillContext {} ``` ```ts docs.example.ts // Stands in for the support handbook at https://handbook.help-desk.example, which the // Playground can't reach. It answers requests for that host in-process, records them, // and passes everything else to the real fetch. A real server doesn't need this file. /** Every handbook page that was fetched, oldest first. */ export const fetched: string[] = []; const pages: Record = { "/refunds/duplicate-charge.md": "1. Read the invoice with get_invoice.\n2. Refund every charge after the first with refund_charge.\n3. Tell the customer with reply_to_ticket.\n", }; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "handbook.help-desk.example") return realFetch(input, init); fetched.push(url.pathname); const page = pages[url.pathname]; return page ? new Response(page, { headers: { "content-type": "text/markdown" } }) : new Response("Not found", { status: 404 }); }; ``` ```ts main.ts import "./docs.example"; import { App, FrontMcp } from "@frontmcp/sdk"; import { RefundDuplicateCharge } from "./refund.skill"; @App({ id: "help-desk", name: "Help Desk", skills: [RefundDuplicateCharge] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts url.test.ts import { test, expect } from "@frontmcp/testing"; import { fetched } from "./docs.example"; test("the page is fetched when the server starts, and then kept", async ({ mcp }) => { expect(fetched).toEqual(["/refunds/duplicate-charge.md"]); const md = (await mcp.resources.read("skill://refund-duplicate-charge/SKILL.md")).text(); expect(md).toContain("2. Refund every charge after the first with refund_charge."); await mcp.resources.read("skill://refund-duplicate-charge/SKILL.md"); await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/load", params: { skillIds: ["refund-duplicate-charge"] } }); expect(fetched).toEqual(["/refunds/duplicate-charge.md"]); }); ``` FrontMCP fetches the page when the server starts, and then keeps it: three reads, one fetch. If the handbook is down then, the server still starts: it logs `Failed to load skill refund-duplicate-charge: …` and tries again the next time it needs the page. A page that changes later isn't picked up until the server restarts. A file is also read at startup; a missing one is logged then, and reading the skill fails with `ENOENT: no such file or directory`. ## Skill, agent or prompt? A skill, an [agent](https://frontmcp.dev/learn/your-first-agent) and a [prompt](https://frontmcp.dev/learn/reusable-prompts) all hand a model some instructions. What differs is whose model follows them, and who picks them: | | Skill | Agent | Prompt | | --- | --- | --- | --- | | The client sees | Resources, `skill://…/SKILL.md` | A tool, `invoke_` | An entry in `prompts/list` | | Who picks it | The client's model, from its description | The client's model, like any tool | The user, from a menu | | Who does the work | The client's model, with your tools | Your server's model, with the agent's own tools | The client's model | | Who pays for the model | The client | You | The client | | Use it when | The client's model can do the job if it knows the steps | The job needs its own model, tools the client shouldn't have, or should run the same way for every client | The user starts a task and fills in a form | The same procedure can be served both ways: the refund steps as a skill for clients whose model should do the work, and as an agent's `systemInstructions` for clients that would rather hand it off. Put the text in one constant, and give it to both. ## Recap - `@Skill({ name, description, instructions, tools, parameters, examples })` writes a procedure down, and `@App({ skills })` registers it. A skill adds no tools and runs no code: the client's model reads it and does the work. - FrontMCP serves each skill as `skill:///SKILL.md`, its metadata as YAML and then its instructions, and lists them all in `skill://index.json`. `server/discover` lists the extension `io.modelcontextprotocol/skills`, and FrontMCP adds a list of the skills to the server's `instructions`. - `skills/search` and `skills/load` are FrontMCP's own JSON-RPC requests, not tools. `skills/load` says which of a skill's tools are missing; a skill with missing tools still loads. - Name a skill's tools as strings, as `{ name, purpose, required }`, or by class (since 1.9). `toolValidation: "strict"` stops the server from starting when one is missing. - Instructions can be inline, `{ file }` or `{ url }`. Files and URLs are read when the server starts (a failure is logged, not fatal), and a URL is fetched once. - Write a skill when the client's model can do the job once it knows the steps, an agent when your server's model should do it, and a prompt when the user starts it. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Point the skill at the right tools `close-solved-ticket` loads, but `skills/load` says it's incomplete: one of the tools it names doesn't exist on the server. Find it, and fix the skill so every tool it names is available. Initial call configuration: ```js { resource: "skill://close-solved-ticket/SKILL.md" } ``` ```ts close.skill.ts active import { Skill, SkillContext } from "@frontmcp/sdk"; @Skill({ name: "close-solved-ticket", description: "Close a ticket the customer confirmed is solved, and thank them.", instructions: "1. Read the ticket with get_ticket and check the customer said it's solved.\n2. Thank them with reply_to_ticket.\n3. Close it with close_ticket.", tools: ["get_ticket", "reply_to_ticket", "close_tickets"], }) export class CloseSolvedTicket extends SkillContext {} ``` **Solution file (alternative to the starter):** ```ts close.skill.ts solution import { Skill, SkillContext } from "@frontmcp/sdk"; @Skill({ name: "close-solved-ticket", description: "Close a ticket the customer confirmed is solved, and thank them.", instructions: "1. Read the ticket with get_ticket and check the customer said it's solved.\n2. Thank them with reply_to_ticket.\n3. Close it with close_ticket.", tools: ["get_ticket", "reply_to_ticket", "close_ticket"], }) export class CloseSolvedTicket extends SkillContext {} ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { CloseSolvedTicket } from "./close.skill"; import { CloseTicket, GetTicket, ReplyToTicket } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, ReplyToTicket, CloseTicket], skills: [CloseSolvedTicket], }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get a ticket.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, text: "Thanks, it works again!" }; } } @Tool({ name: "reply_to_ticket", description: "Reply to the customer on a ticket.", inputSchema: { ticketId: z.string(), message: z.string() } }) export class ReplyToTicket extends ToolContext { async execute() { return { sent: true }; } } @Tool({ name: "close_ticket", description: "Close a ticket.", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts tools.test.ts hidden import { test, expect } from "@frontmcp/testing"; const load = async (mcp: any) => (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/load", params: { skillIds: ["close-solved-ticket"] } })).result.skills[0]; test("the skill is complete: no tool is missing", async ({ mcp }) => { expect(await load(mcp)).toMatchObject({ isComplete: true, missingTools: [] }); }); test("it names get_ticket, reply_to_ticket and close_ticket", async ({ mcp }) => { expect((await load(mcp)).availableTools).toEqual(["get_ticket", "reply_to_ticket", "close_ticket"]); }); ``` **Hint:** Compare the names in the skill's `tools` with the `name` of each tool in `tools.ts`. `skills/load` would name the missing one in `missingTools`. **Solution:** The skill named `close_tickets`, and the tool is `close_ticket`. FrontMCP serves the skill anyway, so a typo like this reaches clients unless something checks `missingTools`, or the skill sets `toolValidation: "strict"`. A test like the hidden one catches it the moment a tool is renamed. ### Challenge: Say what the skill needs A model that loads `close-solved-ticket` has to guess which ticket to close. Add a required parameter `ticketId`, described as `The ticket to close, like T-2`, and an example: the scenario `The customer on T-2 writes that it works again`, with `ticketId` `T-2`, and the expected outcome `T-2 is thanked and closed`. Initial call configuration: ```js { resource: "skill://close-solved-ticket/SKILL.md" } ``` ```ts close.skill.ts active import { Skill, SkillContext } from "@frontmcp/sdk"; @Skill({ name: "close-solved-ticket", description: "Close a ticket the customer confirmed is solved, and thank them.", instructions: "1. Read the ticket with get_ticket and check the customer said it's solved.\n2. Thank them with reply_to_ticket.\n3. Close it with close_ticket.", tools: ["get_ticket", "reply_to_ticket", "close_ticket"], }) export class CloseSolvedTicket extends SkillContext {} ``` **Solution file (alternative to the starter):** ```ts close.skill.ts solution import { Skill, SkillContext } from "@frontmcp/sdk"; @Skill({ name: "close-solved-ticket", description: "Close a ticket the customer confirmed is solved, and thank them.", instructions: "1. Read the ticket with get_ticket and check the customer said it's solved.\n2. Thank them with reply_to_ticket.\n3. Close it with close_ticket.", tools: ["get_ticket", "reply_to_ticket", "close_ticket"], parameters: [{ name: "ticketId", description: "The ticket to close, like T-2", required: true }], examples: [ { scenario: "The customer on T-2 writes that it works again", parameters: { ticketId: "T-2" }, expectedOutcome: "T-2 is thanked and closed", }, ], }) export class CloseSolvedTicket extends SkillContext {} ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { CloseSolvedTicket } from "./close.skill"; @App({ id: "help-desk", name: "Help Desk", skills: [CloseSolvedTicket] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` **Hidden fixture/test file (runs with the example):** ```ts params.test.ts hidden import { test, expect } from "@frontmcp/testing"; const read = async (mcp: any) => (await mcp.resources.read("skill://close-solved-ticket/SKILL.md")).text(); test("SKILL.md lists `ticketId` as a required parameter", async ({ mcp }) => { expect(await read(mcp)).toContain(`parameters: - name: ticketId description: The ticket to close, like T-2 required: true type: string`); }); test("SKILL.md has the example", async ({ mcp }) => { expect(await read(mcp)).toContain(`examples: - scenario: The customer on T-2 writes that it works again parameters: ticketId: T-2 expectedOutcome: T-2 is thanked and closed`); }); ``` **Hint:** Both are arrays on `@Skill`. A parameter is `{ name, description, required }`; an example is `{ scenario, parameters, expectedOutcome }`. **Solution:** `parameters` and `examples` go into the front matter of `SKILL.md`, which is what a client's model reads before it starts. A parameter's `type` defaults to `string`, which is why the check expects `type: string` though the solution doesn't set it. The example's `parameters` are the values for that case, so a model sees what a real `ticketId` looks like. --- # Tools with a UI > How a FrontMCP tool gives its result a widget, an HTML page a host shows next to the answer. Writing one with a template, choosing how it's served, and building it in React, with what works today. Source: https://frontmcp.dev/learn/tools-with-a-ui [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Every tool so far answers the model, and the model tells the person what it found. Some results are for the person more than the model: a ticket they want to see at a glance, a queue they want to sort, a button they'd rather press than type "close it". A FrontMCP tool's `ui` option gives its result a **widget**, an HTML page that a host able to show one renders next to the answer, while the model reads the same result as before. The widget can call your tools through the host, follow its theme and fit its frame. This chapter builds widgets for the help desk, and points out what a widget can't do there yet. **In this chapter** - [How to give a tool a widget, and make it call a tool back](https://frontmcp.dev/learn/your-first-widget) - [How a widget reaches the host, what it may load, and what data it carries](https://frontmcp.dev/learn/choosing-how-a-widget-is-served) - [How to build a widget as a React component](https://frontmcp.dev/learn/building-widgets-with-react) ## Your first widget `ui.template` is a function FrontMCP calls after every call, with the call's input and output. What it returns, built with `ctx.helpers.html` so every value is escaped, becomes the page. The result's `content` and `structuredContent` don't change, and the page travels next to them, in `_meta["ui/html"]`. The Playground's **Widget** tab shows the page as an MCP Apps host would. Press **Close ticket**: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts get-ticket.tool.ts active import { PublicMcpError, Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk"; import { tickets, type Ticket } from "./tickets"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id, with its status and customer.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, ui: { template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`

${ctx.output.id} · ${ctx.output.customer}

${ctx.output.title}

Status: ${ctx.output.status}

`, }, }) 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}.`)); return ticket; } } ``` ```ts close-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @Tool({ name: "close_ticket", description: "Close a support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class CloseTicket 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}.`)); if (ticket.status === "closed") this.fail(new PublicMcpError(`${id} is already closed.`)); ticket.status = "closed"; return { id, status: ticket.status }; } } ``` ```ts tickets.ts export type Ticket = { id: string; title: string; status: "open" | "closed"; customer: string }; export const tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", status: "open", customer: "Acme Corp" }, { id: "T-2", title: "Invoice total is wrong", status: "closed", customer: "Globex" }, ]; ``` ```ts widget.test.ts import { test, expect } from "@frontmcp/testing"; test("the model's result is unchanged", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result.json()).toEqual({ id: "T-1", title: "Cannot log in", status: "open", customer: "Acme Corp" }); }); test("the page travels next to it", async ({ mcp }) => { const html: string = (await mcp.tools.call("get_ticket", { id: "T-1" })).raw._meta["ui/html"]; expect(html).toContain("

Cannot log in

"); }); ``` The button's click goes from the page's bridge script to the host, and from the host to your server as an ordinary `tools/call`: the **Wire** tab has it, tagged **widget**. ### [Your First Widget](https://frontmcp.dev/learn/your-first-widget) Learn when a result deserves a widget, how to write a template and why to escape with `html`, what a host receives, how a widget calls tools, and how to follow the host's theme, fit the frame and open links. ## Choosing how a widget is served By default a widget is served **inline**: every result carries the whole page, about 38 KB, rendered with that call's data. A **static** widget is rendered once, when the server starts, and served as the resource `ui://widget/.html`. The host reads it once, then hands the page each result, which the page draws itself: Initial call configuration: ```js { tool: "list_queue", arguments: {} } ``` ```ts list-queue.tool.ts active import { Tool, ToolContext, type TemplateContext } from "@frontmcp/sdk"; type Queue = { tickets: { id: string; title: string }[] }; @Tool({ name: "list_queue", description: "List the open support tickets, most urgent first.", inputSchema: {}, ui: { servingMode: "static", // Runs once, at startup, with ctx.output = {}. template: (ctx: TemplateContext<{}, Partial>) => ctx.helpers.html`

Open tickets

    `, }, }) export class ListQueue extends ToolContext { async execute(): Promise { return { tickets: [{ id: "T-1", title: "Cannot log in" }, { id: "T-3", title: "Login link expired" }] }; } } ``` ```ts static.test.ts import { test, expect } from "@frontmcp/testing"; test("results carry no page", async ({ mcp }) => { const result = await mcp.tools.call("list_queue", {}); expect(Object.keys(result.raw._meta).filter((k) => k.startsWith("ui/"))).toEqual([]); }); test("the resource is the page", async ({ mcp }) => { expect((await mcp.resources.read("ui://widget/list_queue.html")).text()).toContain('
      '); }); ``` Those are the two real choices: `"hybrid"` is static with a small extra object for a few platforms, and the URL modes are served inline, and the server warns about both at startup. A server that serves every widget one way says so once, with `@FrontMcp({ ui: { servingMode: "static" } })` or `@App({ ui: { servingMode } })`, and a tool's own `servingMode` still wins. Two more things matter wherever the page comes from. The page's own Content Security Policy lets it reach a few CDNs and the origins the tool lists in `ui.csp`, so list the ones the widget calls. And with an `outputSchema`, the widget gets only the fields it declares, as the model does: declare every field the widget shows. ### [Choosing How a Widget Is Served](https://frontmcp.dev/learn/choosing-how-a-widget-is-served) Learn what inline and static widgets cost, how a static widget reads each result and why it reads `structuredContent`, how to set one mode for every tool, what every serving mode does, what the widget's Content Security Policy allows, and why the widget's data has to be declared in `outputSchema`. ## Building widgets with React A widget with state, like a queue with filters and a Close button on each row, is easier as a React component than as a script in a string. Point `ui.template` at a `.tsx` file and FrontMCP bundles it, and mounts the default export with the call's data. Hooks from `@frontmcp/ui/react` read the result and call tools through the host: ```ts list-queue.tool.ts ui: { template: { file: join(__dirname, "queue.widget.tsx") } }, ``` ```tsx queue.widget.tsx type Ticket = { id: string; title: string; priority: "high" | "normal" | "low" }; type Queue = { tickets: Ticket[] }; export default function QueueWidget({ output }: { output: Queue | null }) { if (!output?.tickets) return

      Loading the queue…

      ; return (
        {output.tickets.map((t) => (
      • {t.id} {t.title} ({t.priority})
      • ))}
      ); } ``` The Playground can't run this: FrontMCP bundles widget files from the file system, on a Node server, and the Playground runs FrontMCP in your browser. The lesson's widgets were run in Node projects with FrontMCP 1.9.4, CommonJS and ES module, and rendered in Chromium, and its Playgrounds cover what runs here: the tools, and the calls a widget makes. ### [Building Widgets with React](https://frontmcp.dev/learn/building-widgets-with-react) Learn what a project needs to bundle a widget file, what the component gets as props and why to check for the fields it needs, how to call tools with `useCallTool()` and handle each outcome, and how to handle the theme, the size and where React is loaded from. ## Template or React? | If the widget… | Write | | --- | --- | | Shows a result, maybe with a button or two that call tools | A template function, served inline | | Is large, or shows many results with one page | A template function, served static, that draws each result | | Has state: filters, per-row actions, loading and error states | A React component in a `.tsx` file, on a Node server | Whatever you choose, the result is still what the model reads, and many hosts show no widget at all. Make the tool useful without its page first. ## What's next? Start the chapter with [Your First Widget](https://frontmcp.dev/learn/your-first-widget). Every `ui` option is in the [Tool UI reference](https://frontmcp.dev/reference/ui), what each host and platform gets is on [Hosts and platforms](https://frontmcp.dev/reference/ui/hosts), and the React hooks and components are in [Widget components](https://frontmcp.dev/reference/ui/components). After this chapter, [Built-in Plugins and Adapters](https://frontmcp.dev/learn/built-in-plugins-and-adapters) covers the plugins that come with FrontMCP: caching, memory, approval, feature flags, CodeCall and OpenAPI. --- # Your First Widget > Give a FrontMCP tool's result a UI with the ui option: an HTML template rendered on every call, what the host receives, calling tools back, and theme and size. Source: https://frontmcp.dev/learn/your-first-widget [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A support agent asks about ticket T-1. The model calls `get_ticket`, reads the JSON and writes a sentence about it. That works for the model, but the person asking would rather see the ticket, with a button to close it, than read a paraphrase and type "close it" back. With FrontMCP, a tool's `ui` option gives its result a **widget**: an HTML page that a host able to show one renders next to the answer, while the model reads the same result as before. This lesson gives `get_ticket` a widget, looks at what the host receives, and makes the widget call a tool, follow the host's theme and fit its frame. **You will learn** - When a tool's result is worth a widget - How to add one with the `ui` option and an HTML template - What a host receives, and what the model still sees - How a widget calls a tool on your server - How to follow the host's theme and fit the widget's frame ## When a result deserves a UI Most tools don't need a widget. The model reads a result and tells the user what matters, and for "how many tickets are open?" that's all anyone wants. A widget earns its place when a person, not the model, is the one reading: | A widget helps with | Leave it out for | | --- | --- | | A record someone checks at a glance: a ticket, an invoice, a customer | Answers of a line or two | | A list to scan or sort, like a queue of tickets | Data only the model uses to decide what to call next | | An action the person should confirm with a click, like closing a ticket | Anything the model has to see: it never sees the page | Two things stay true for every tool with a widget. The model gets the same `content` and `structuredContent` it got without one, so the result still has to make sense as text. And many hosts can't show widgets at all: they ignore the page and use the result as it is. ## Adding a widget with `ui` Here is `get_ticket` with a `ui` option. The Playground below opens on its **Widget** tab, which works like a small host: it puts the page into a sandboxed frame, gives it the call's input and result, and passes the page's tool calls to the server. Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts get-ticket.tool.ts active import { PublicMcpError, Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk"; type Ticket = { id: string; title: string; status: "open" | "closed"; priority: "high" | "normal"; customer: string }; const tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", status: "open", priority: "high", customer: "Acme Corp" }, { id: "T-2", title: "Invoice total is wrong", status: "closed", priority: "normal", customer: "Globex" }, ]; @Tool({ name: "get_ticket", description: "Get one support ticket by its id, with its status, priority and customer.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, ui: { template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`

      ${ctx.output.id} · ${ctx.output.customer} · ${ctx.output.priority} priority

      ${ctx.output.title}

      Status: ${ctx.output.status}

      `, }, }) 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}.`)); return ticket; } } ``` ```ts widget.test.ts import { test, expect } from "@frontmcp/testing"; test("the model's result is the same as without ui", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result.json()).toEqual({ id: "T-1", title: "Cannot log in", status: "open", priority: "high", customer: "Acme Corp" }); }); test("tools/list says the tool has a widget", async ({ mcp }) => { const [tool] = await mcp.tools.list(); expect(tool._meta).toEqual({ ui: { resourceUri: "ui://widget/get_ticket.html" }, "frontmcp/type": "html" }); }); test("the result carries the page", async ({ mcp }) => { const { _meta } = (await mcp.tools.call("get_ticket", { id: "T-1" })).raw; expect(_meta["ui/type"]).toBe("html"); expect(_meta["ui/mimeType"]).toBe("text/html;profile=mcp-app"); expect(_meta["ui/html"]).toMatch(/^/); expect(_meta["ui/html"]).toContain("

      Cannot log in

      "); }); ``` `template` is a function that FrontMCP calls on every call, after `execute()`, with `ctx`: - **`ctx.output`** is what `execute()` returned, and **`ctx.input`** is the call's arguments. - **`ctx.helpers.html`** builds the markup. Every value you put in `${…}` is escaped, so a ticket title can't add tags to the page (more on this [below](https://frontmcp.dev/learn/your-first-widget#escaping-what-customers-wrote)). - **What it returns** becomes the page's ``. FrontMCP wraps it in a whole HTML document. Annotate `ctx` with `TemplateContext`. `template` accepts several kinds of value, so TypeScript can't infer the parameter's type, and under `strict` a bare `(ctx) => …` fails with `TS7006: Parameter 'ctx' implicitly has an 'any' type`. > **Pitfall: A React component isn't a template function** > > `template: TicketCard`, where `TicketCard` returns JSX, sends an empty page, and the server logs `ui.template is a React component reference, which cannot be bundled` when it starts: FrontMCP has no file to bundle, so the widget shows "Loading widget..." forever. A template function returns markup, as above. React widgets go in a `.tsx` file, and are a lesson of their own, [Building Widgets with React](https://frontmcp.dev/learn/building-widgets-with-react). ### What the host receives Open the **Tests** tab, or the **Wire** tab, to see what a client gets: - **In `tools/list`**, before any call, the tool carries `_meta.ui.resourceUri`, `ui://widget/get_ticket.html`. That's how a host knows the tool has a widget. - **In the `tools/call` result**, `content` and `structuredContent` are what they'd be without `ui`, `outputSchema` included (the [next lesson](https://frontmcp.dev/learn/choosing-how-a-widget-is-served#the-widget-gets-what-outputschema-declares) shows what that means for the page). That's the model's part. - **Next to them, in `_meta`**, is the page: `ui/html` holds a whole HTML document, `ui/type` is `"html"` and `ui/mimeType` is `"text/html;profile=mcp-app"`. The page is about 38 KB even for a card this small. Most of it is **the bridge**, a script FrontMCP puts in every page so the widget can talk to the host it runs in. The page also carries the call's data: a script at the top sets `window.__mcpToolOutput` to what `execute()` returned. ## Escaping what customers wrote Ticket titles are typed by customers, so they're text, not markup. Customers paste error messages, and error messages have angle brackets. A template has to keep the two apart: its own tags are markup, and the values it puts between them are text. Here are two templates for the same title. Only the second one shows a heading: ```ts // 🚩 A plain template string: FrontMCP can't tell your tags from the title, and escapes it all template: (ctx: TemplateContext<{ id: string }, Ticket>) => `

      ${ctx.output.title}

      `, // The page shows the text "

      Export fails with

      ", tags and all. // ✅ html keeps your markup and escapes every value you interpolate template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`

      ${ctx.output.title}

      `, //

      Export fails with <no data>

      : a heading with the title as the customer wrote it. ``` For the plain string, the server also logs a notice, `The UI template of tool "get_ticket" returned a plain string containing markup. Since 1.9.2, FrontMCP HTML-escapes plain string results by default, so it is shown as text.` Escaping the whole string is the safe choice: if FrontMCP rendered it as markup, a title like `

      `, }, }) 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}.`)); return ticket; } } ``` ```ts close-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @Tool({ name: "close_ticket", description: "Close a support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class CloseTicket 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}.`)); if (ticket.status === "closed") this.fail(new PublicMcpError(`${id} is already closed.`)); ticket.status = "closed"; return { id, status: ticket.status }; } } ``` ```ts tickets.ts export type Ticket = { id: string; title: string; status: "open" | "closed"; priority: "high" | "normal"; customer: string }; export const tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", status: "open", priority: "high", customer: "Acme Corp" }, { id: "T-2", title: "Invoice total is wrong", status: "closed", priority: "normal", customer: "Globex" }, ]; ``` ```ts close.test.ts import { test, expect } from "@frontmcp/testing"; test("the button carries the ticket's id", async ({ mcp }) => { const html: string = (await mcp.tools.call("get_ticket", { id: "T-1" })).raw._meta["ui/html"]; expect(html).toContain(``, }, }) 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}.`)); return ticket; } } ``` ```ts close-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @Tool({ name: "close_ticket", description: "Close a support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class CloseTicket 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}.`)); if (ticket.status === "closed") this.fail(new PublicMcpError(`${id} is already closed.`)); ticket.status = "closed"; return { id, status: ticket.status }; } } ``` ```ts tickets.ts export type Ticket = { id: string; title: string; status: "open" | "closed" }; export const tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", status: "open" }, { id: "T-3", title: "Login link expired", status: "open" }, ]; ``` **Hidden fixture/test file (runs with the example):** ```ts button.test.ts hidden import { test, expect } from "@frontmcp/testing"; // The arguments of the page's close_ticket button, as the bridge reads them. async function closeButtonArgs(mcp: any, id: string) { const html: string = (await mcp.tools.call("get_ticket", { id })).raw._meta["ui/html"]; const body = html.slice(html.indexOf("")); const tag = body.match(/]*data-tool-call=["']close_ticket["'][^>]*>/)?.[0]; if (!tag) return "no close_ticket button"; const raw = tag.match(/data-tool-args=(?:"([^"]*)"|'([^']*)')/); if (!raw) return "no data-tool-args"; return JSON.parse((raw[1] ?? raw[2]).replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&")); } test("T-1's widget has a button that calls `close_ticket` for T-1", async ({ mcp }) => { expect(await closeButtonArgs(mcp, "T-1")).toEqual({ id: "T-1" }); }); test("T-3's widget closes T-3, not T-1", async ({ mcp }) => { expect(await closeButtonArgs(mcp, "T-3")).toEqual({ id: "T-3" }); }); ``` **Hint:** The bridge reads two attributes on the button: the tool's name, and its arguments as JSON. Build the JSON from `ctx.output` so each ticket's button closes that ticket. **Solution:** `data-tool-call="close_ticket"` names the tool, and `data-tool-args="${JSON.stringify({ id: ctx.output.id })}"` gives it the id of the ticket on screen. `html` escapes the JSON's quotes, and the browser restores them when the bridge reads the attribute. Press the button in the Widget tab to see the call in the Wire tab. ### Challenge: Make the widget fit in The widget is a white card in a frame that's always 240 pixels tall, and in a dark host it stays white. Make it report its height to the host, and give the card colors that follow the host's light or dark theme. Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts get-ticket.tool.ts import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk"; type Ticket = { id: string; title: string; status: string }; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, ui: { template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`

      ${ctx.output.title}

      ${ctx.output.id} · ${ctx.output.status}

      `, }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } ``` **Solution file (alternative to the starter):** ```ts get-ticket.tool.ts solution import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk"; type Ticket = { id: string; title: string; status: string }; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, ui: { autoResize: true, template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`

      ${ctx.output.title}

      ${ctx.output.id} · ${ctx.output.status}

      `, }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts fit.test.ts hidden import { test, expect } from "@frontmcp/testing"; const page = async (mcp: any): Promise => (await mcp.tools.call("get_ticket", { id: "T-1" })).raw._meta["ui/html"]; test("`get_ticket` has a size hint, so the widget reports its height", async ({ mcp }) => { const [tool] = await mcp.tools.list(); const ui = tool._meta.ui; const hinted = ui.autoResize === true || ["preferredHeight", "minHeight", "maxHeight", "aspectRatio"].some((k) => k in ui); expect(hinted && ui.autoResize !== false).toBe(true); }); test("the card's colors come in a light and a dark version (`light-dark()`)", async ({ mcp }) => { expect(await page(mcp)).toMatch(/\.ticket\s*\{[^}]*light-dark\(/); }); test("the page sets no `color-scheme` of its own, so the bridge's applies", async ({ mcp }) => { const html = await page(mcp); const styles = html.slice(html.indexOf("")).match(/

      Open tickets

        ${ctx.output.tickets.map((t) => ctx.helpers.html`
      • ${t.id} ${t.title} (${t.priority})
      • `)}
      `, }, }) export class ListQueue extends ToolContext { async execute(): Promise { return { tickets: openTickets() }; } } ``` ```ts tickets.ts export type QueueItem = { id: string; title: string; priority: "high" | "normal" }; export type Queue = { tickets: QueueItem[] }; const tickets = [ { id: "T-1", title: "Cannot log in", priority: "high", status: "open" }, { id: "T-2", title: "Invoice total is wrong", priority: "normal", status: "closed" }, { id: "T-3", title: "Login link expired", priority: "normal", status: "open" }, ] as const; export function openTickets(): QueueItem[] { return tickets.filter((t) => t.status === "open").map(({ id, title, priority }) => ({ id, title, priority })); } ``` ```ts inline.test.ts import { test, expect } from "@frontmcp/testing"; test("every result carries a whole page", async ({ mcp }) => { const html: string = (await mcp.tools.call("list_queue", {})).raw._meta["ui/html"]; expect(html.length).toBeGreaterThan(25_000); expect(html).toContain("
    • T-1 Cannot log in (high)
    • "); }); test("the widget's resource is a page without the data", async ({ mcp }) => { await mcp.tools.call("list_queue", {}); const page = (await mcp.resources.read("ui://widget/list_queue.html")).text(); expect(page).toContain("window.__mcpToolOutput = null;"); expect(page).not.toContain("Cannot log in"); }); ``` Inline is the simplest mode. The page has the data in its markup before any script runs, and the line under the widget says where the Playground took it from: `_meta["ui/html"]`. It costs two things: 1. **Size.** About 38 KB goes out with every result, most of it the bridge script. When the widget calls the tool itself to refresh, the bridge marks the call as its own and the answer comes back without a page, but not under the OpenAI Apps SDK, which can't carry the mark: there every refresh brings the whole page again. 2. **Hosts that read the resource.** `tools/list` points every host at `ui://widget/list_queue.html`, and a host that loads the widget from there, as the MCP Apps extension describes, gets a page with the bridge and nothing else: FrontMCP only renders an inline template with a call's data, and a resource read has no call. ## Static: one page, the data from the host With `servingMode: "static"`, FrontMCP renders the template once, when the server starts, and serves that page as the resource. Results carry no page at all. The host reads the resource, then sends the page each call's result, and a script in the page draws it: Initial call configuration: ```js { tool: "list_queue", arguments: {} } ``` ```ts list-queue.tool.ts active import { Tool, ToolContext, type TemplateContext } from "@frontmcp/sdk"; import { openTickets, type Queue } from "./tickets"; @Tool({ name: "list_queue", description: "List the open support tickets, most urgent first.", inputSchema: {}, ui: { servingMode: "static", autoResize: true, // Rendered once, at startup: nothing here depends on a call. template: (ctx: TemplateContext<{}, Partial>) => ctx.helpers.html`

      Open tickets

        `, }, }) export class ListQueue extends ToolContext { async execute(): Promise { return { tickets: openTickets() }; } } ``` ```ts tickets.ts export type QueueItem = { id: string; title: string; priority: "high" | "normal" }; export type Queue = { tickets: QueueItem[] }; const tickets = [ { id: "T-1", title: "Cannot log in", priority: "high", status: "open" }, { id: "T-2", title: "Invoice total is wrong", priority: "normal", status: "closed" }, { id: "T-3", title: "Login link expired", priority: "normal", status: "open" }, ] as const; export function openTickets(): QueueItem[] { return tickets.filter((t) => t.status === "open").map(({ id, title, priority }) => ({ id, title, priority })); } ``` ```ts static.test.ts import { test, expect } from "@frontmcp/testing"; test("results carry no page", async ({ mcp }) => { const result = await mcp.tools.call("list_queue", {}); expect(Object.keys(result.raw._meta).filter((k) => k.startsWith("ui/"))).toEqual([]); expect(result.raw.structuredContent.tickets).toHaveLength(2); }); test("the resource is the page, rendered once with empty data", async ({ mcp }) => { const page = (await mcp.resources.read("ui://widget/list_queue.html")).text(); expect(page).toContain('
          '); expect(page).toContain("window.__mcpToolOutput = null;"); }); test("tools/list points hosts at it, as before", async ({ mcp }) => { const [tool] = await mcp.tools.list(); expect(tool._meta.ui.resourceUri).toBe("ui://widget/list_queue.html"); }); ``` The widget looks the same, but the line under it now says the page came from `resources/read ui://widget/list_queue.html`. Open the **Wire** tab: the Playground read the resource once, and the `tools/call` result has no `ui/html` in it. Here is what happened: 1. When the server started, FrontMCP called the template with `ctx.output` set to `{}` and kept the page. The page carries no data of its own: `window.__mcpToolOutput` is `null`, as the second test checks. 2. The call's result went out without a page. 3. The host read the tool's `resourceUri` and put that page in a frame. The Playground reads it once per server start, since it's the same for every call. 4. When the bridge had finished its handshake, the host sent the page the call's result, and the bridge fired `tool:result` with it. `event.detail.structuredContent` is the object `execute()` returned. The script builds the list with `textContent`, not `innerHTML`. `ctx.helpers.html` escaped what the template put in the page, but the data a static widget shows arrives later, in the browser, where nothing escapes it for you. > **Pitfall: A static template runs with empty data** > > At startup there's no call, so `ctx.output` is `{}`. A static template that reads deeper, like `ctx.output.tickets.map(…)`, throws. The server logs `Failed to compile static widget for tool "list_queue": Cannot read properties of undefined (reading 'map')`, starts anyway, and serves the empty page for that tool. Type the output as `Partial<…>` so TypeScript reminds you, and render anything that depends on a call from the host's result. > **Note** > > The bridge also has `FrontMcpBridge.getToolOutput()`. In an inline page it returns what `execute()` returned, and in a static page `null`, until an MCP Apps host sends the result. Then it returns the result's `structuredContent`, the same object the event carries. A result that failed has none, and the output is the error's message as a string, so a widget that shows failures checks for a missing `structuredContent`, as the Expense Dashboard's script does. Before 1.8.7 the output became the result's `content` array, `[{ type: "text", text: "{…}" }]`, and widgets had to read `getStructuredContent()` or the event instead. Choose static when the page is big, when the same widget shows many results, or when hosts that load widgets from the resource matter to you. Choose inline when the page is small and simplest wins, or when the widget needs the data before its scripts run. The [Expense Dashboard](https://frontmcp.dev/examples/expense-dashboard) is a static widget whose filters call the tool again for another month or team. ## One mode for every tool A server whose widgets are all static would need `servingMode: "static"` on every tool, and a tool that forgets it goes out inline. Set it once instead. `@FrontMcp({ ui: { servingMode: "static" } })` is the mode of every tool that doesn't choose its own, and `@App({ ui: { servingMode } })` is the mode of one app's tools. A tool's own `servingMode` wins, then its app's, then the server's. Here the help desk serves everything static, except a small badge that is cheap enough to send inline: Initial call configuration: ```js { tool: "list_queue", arguments: {} } ``` ```ts main.ts active import { App, FrontMcp, Tool, ToolContext, type TemplateContext } from "@frontmcp/sdk"; @Tool({ name: "list_queue", description: "List the open support tickets, most urgent first.", inputSchema: {}, ui: { template: (ctx: TemplateContext<{}, {}>) => ctx.helpers.html`

          Open tickets

          ` }, }) class ListQueue extends ToolContext { async execute() { return { open: 2 }; } } @Tool({ name: "team_stats", description: "How many tickets each agent closed this week.", inputSchema: {}, ui: { template: (ctx: TemplateContext<{}, {}>) => ctx.helpers.html`

          This week

          ` }, }) class TeamStats extends ToolContext { async execute() { return { closed: { sam: 4, nour: 6 } }; } } @Tool({ name: "ticket_badge", description: "A one-line badge for a ticket.", inputSchema: {}, ui: { servingMode: "inline", template: (ctx: TemplateContext<{}, { id: string }>) => ctx.helpers.html`${ctx.output.id}` }, }) class TicketBadge extends ToolContext { async execute() { return { id: "T-1" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [ListQueue, TeamStats, TicketBadge] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], ui: { servingMode: "static" }, }) export default class Server {} ``` ```ts defaults.test.ts import { test, expect } from "@frontmcp/testing"; async function uiKeys(mcp: any, name: string) { const result = await mcp.tools.call(name, {}); return Object.keys(result.raw._meta).filter((k) => k.startsWith("ui/")); } test("tools that don't choose are served static", async ({ mcp }) => { expect(await uiKeys(mcp, "list_queue")).toEqual([]); expect(await uiKeys(mcp, "team_stats")).toEqual([]); expect((await mcp.resources.read("ui://widget/team_stats.html")).text()).toContain("

          This week

          "); }); test("a tool's own servingMode wins", async ({ mcp }) => { expect(await uiKeys(mcp, "ticket_badge")).toEqual(["ui/html", "ui/type", "ui/mimeType"]); }); ``` A tool that takes `"static"` from the server is rendered at startup, like one that sets it, so the [pitfall above](https://frontmcp.dev/learn/choosing-how-a-widget-is-served#static-one-page-the-data-from-the-host) applies to it too: its template gets `ctx.output` set to `{}`. Both options take the same six values as a tool, and anything else stops the server at startup with `Invalid option: expected one of "auto"|"inline"|"static"|"hybrid"|"direct-url"|"custom-url"`. Before 1.9.4, `@FrontMcp` and `@App` accepted a `ui.servingMode` and ignored it. ## What the other modes do `servingMode` accepts six values, and FrontMCP handles them like this: | `servingMode` | The `tools/call` result | The resource | | --- | --- | --- | | `"auto"` (the default), `"inline"` | The page, rendered with this call's data | A page without data | | `"static"` | No page | The page, rendered once at startup | | `"hybrid"` | For clients detected as MCP Apps hosts (the `"ext-apps"` platform) and two other platforms, a small `ui/component` object with no HTML or code in it. For every other client, no page. | As `"static"` | | `"direct-url"`, `"custom-url"` | As `"inline"`: `directPath` and `customWidgetUrl` are ignored | As `"inline"` | So there are two real choices, inline and static. `"hybrid"` is static with an extra object for a few platforms, and the URL modes are inline. (One platform FrontMCP recognizes gets no widget in any mode.) The server says so when it starts: the **Logs** tab of the example below has a warning for `hybrid`, that it sends a reference and not the component code, and one for `direct-url`, that it's not implemented and the widget is served inline. FrontMCP guesses a client's platform from the name it sends. A client that declares the MCP Apps extension is `"ext-apps"` whatever its name, unless a mapping of yours says otherwise. The server below has a host that doesn't declare it, so it maps the host's name, `acme-desk`, to `"ext-apps"`. The tests call the tools as `acme-desk` and as another client: Initial call configuration: ```js { tool: "hybrid_queue", arguments: {} } ``` ```ts main.ts active import { App, FrontMcp, Tool, ToolContext, type TemplateContext } from "@frontmcp/sdk"; const heading = (ctx: TemplateContext<{}, {}>) => ctx.helpers.html`

          Open tickets

          `; @Tool({ name: "hybrid_queue", description: "Open tickets, served hybrid.", inputSchema: {}, ui: { template: heading, servingMode: "hybrid" } }) class HybridQueue extends ToolContext { async execute() { return { open: 2 }; } } @Tool({ name: "url_queue", description: "Open tickets, served from a URL.", inputSchema: {}, ui: { template: heading, servingMode: "direct-url", directPath: "/widgets/queue" }, }) class UrlQueue extends ToolContext { async execute() { return { open: 2 }; } } @App({ id: "help-desk", name: "Help Desk", tools: [HybridQueue, UrlQueue] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], transport: { platformDetection: { mappings: [{ pattern: "acme-desk", platform: "ext-apps" }] } }, }) export default class Server {} ``` ```ts modes.test.ts import { test, expect } from "@frontmcp/testing"; // Calls a tool as the client named `client`, and returns its result's ui/* keys. async function uiKeys(mcp: any, client: string, name: string) { const body = await mcp.raw.request({ method: "tools/call", params: { name, arguments: {}, _meta: { "io.modelcontextprotocol/clientInfo": { name: client, version: "1.0.0" } } }, }); return Object.fromEntries(Object.entries(body.result._meta).filter(([k]) => k.startsWith("ui/"))); } test("hybrid: an MCP Apps host gets a component object, with no page", async ({ mcp }) => { const keys = await uiKeys(mcp, "acme-desk", "hybrid_queue"); expect(keys["ui/component"]).toEqual({ type: "html", hash: expect.any(String), toolName: "hybrid_queue" }); expect(keys["ui/html"]).toBeUndefined(); }); test("hybrid: other clients get nothing at all, as with static", async ({ mcp }) => { expect(await uiKeys(mcp, "my-mcp-client", "hybrid_queue")).toEqual({}); }); test("direct-url: the page is in the result, and directPath is ignored", async ({ mcp }) => { const keys = await uiKeys(mcp, "my-mcp-client", "url_queue"); expect(keys["ui/html"]).toContain("

          Open tickets

          "); expect(JSON.stringify(keys)).not.toContain("/widgets/queue"); }); ``` The object holds no HTML and no code, so a host still needs the page from the resource to show anything. How FrontMCP recognizes a platform, and what each one gets, is on [Hosts and platforms](https://frontmcp.dev/reference/ui/hosts#how-frontmcp-recognizes-the-client). ## What a widget can load The page FrontMCP renders has a Content Security Policy of its own, in a `` tag. Unless the tool says otherwise, it allows scripts and styles written in the page or loaded from five CDNs (`cdn.jsdelivr.net`, `cdnjs.cloudflare.com`, `fonts.googleapis.com`, `fonts.gstatic.com` and `esm.sh`), images and fonts from those CDNs or `data:` URLs, and network requests to those CDNs only. So a widget can't `fetch()` your API until the tool lists it. Say the ticket widget should show a ticket's history, which lives at `https://api.helpdesk.example`. The tool's `ui.csp` option is where you list that origin, and FrontMCP puts it into the page's policy. The widget below prints the `connect-src` its own page ended up with: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts get-ticket.tool.ts active import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk"; type Ticket = { id: string; title: string }; const template = (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`

          ${ctx.output.title}

          This page lets fetch() reach:

          `; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, ui: { autoResize: true, // ✅ The widget may call the help desk's API itself csp: { connectDomains: ["https://api.helpdesk.example"] }, template, }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in" }; } } @Tool({ name: "get_ticket_plain", description: "The same tool, with no csp.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, ui: { template }, }) export class GetTicketPlain extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in" }; } } ``` ```ts csp.test.ts import { test, expect } from "@frontmcp/testing"; const connectSrc = async (mcp: any, name: string) => { const html: string = (await mcp.tools.call(name, { id: "T-1" })).raw._meta["ui/html"]; const policy = html.match(/Content-Security-Policy" content="([^"]*)"/)![1]; return policy.match(/connect-src ([^;]*)/)![1].split(" "); }; test("tools/list lists the origin for the host", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "get_ticket"); expect(tool._meta.ui.csp).toEqual({ connectDomains: ["https://api.helpdesk.example"] }); }); test("the page's own policy allows it, next to the CDNs", async ({ mcp }) => { expect(await connectSrc(mcp, "get_ticket")).toEqual([ "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.googleapis.com", "https://fonts.gstatic.com", "https://esm.sh", "https://api.helpdesk.example", ]); }); test("without csp, the page can reach the CDNs and nothing else", async ({ mcp }) => { expect(await connectSrc(mcp, "get_ticket_plain")).toEqual([ "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.googleapis.com", "https://fonts.gstatic.com", "https://esm.sh", ]); }); ``` With the origin listed, a `fetch("https://api.helpdesk.example/tickets/T-1/history")` in the page is allowed through. Without it, the browser blocks the request before it leaves: `fetch()` rejects with `Failed to fetch`, and the console says `Connecting to 'https://api.helpdesk.example/tickets/T-1/history' violates the following Content Security Policy directive: "connect-src https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://esm.sh". The action has been blocked.` This was checked in Chromium, with a local server standing in for the API. A few details of `ui.csp` matter: - **Secure origins count, and local ones.** `https://api.helpdesk.example`, a path like `https://api.helpdesk.example/v1`, a wildcard like `https://*.helpdesk.example` and a WebSocket like `wss://live.helpdesk.example` do, and so does `http://localhost:3000` while you develop. Plain `http://` to any other host doesn't: the server names it in a warning at startup and leaves it out of the page's policy. - **Both lists add to the CDNs**, which is why the test above sees six entries. `connectDomains` goes into `connect-src`. `resourceDomains` goes into the script, style, image and font sources, which is how an image from your own domain loads, and into `connect-src` too. - **The browser isn't the only one asking.** A host may build a policy of its own around the frame from the lists `csp` puts in `tools/list` and on the resource (`_meta.ui.csp` and `_meta["ui/csp"]`, with your keys and snake-case copies, and `_meta["openai/widgetCSP"]` for the OpenAI Apps SDK, from the moment the server starts). The page's policy is what FrontMCP controls; a host's is the host's. - **The API still has to allow the request.** The widget's page doesn't share your API's origin, so a `fetch()` needs the API's CORS headers (`Access-Control-Allow-Origin`), whatever the policy says. Changed in 1.9: `connectDomains` replaced the CDNs in `connect-src`, `esm.sh` apart, and only `https:` origins counted. When the API needs a secret, or doesn't send CORS headers, a tool is the better route: the widget asks the host to call one, and your server, which can reach the API, answers. Press **Show history**: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts get-ticket.tool.ts active import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk"; type Ticket = { id: string; title: string }; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, ui: { template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`

          ${ctx.output.title}

            `, }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in" }; } } ``` ```ts history.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket_history", description: "What happened to a support ticket, oldest first.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { readOnlyHint: true }, }) export class GetTicketHistory extends ToolContext { async execute({ id }: { id: string }) { // On a real server: this.fetch(`https://api.helpdesk.example/tickets/${id}/history`) return { events: [ { at: "09:12", what: `${id} opened by Acme Corp` }, { at: "09:40", what: "Assigned to Nour" }, ], }; } } ``` ```ts history.test.ts import { test, expect } from "@frontmcp/testing"; test("the history comes through a tool", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket_history", { id: "T-1" }); expect(result.json().events[0]).toEqual({ at: "09:12", what: "T-1 opened by Acme Corp" }); }); test("the widget never calls the API itself, so the tool lists no origin", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "get_ticket"); expect(tool._meta.ui.csp).toBeUndefined(); }); ``` Images follow `resourceDomains`: an image from your own domain loads once it's listed there. Without that, send small images as `data:` URLs, or use one of the CDNs. The [Tool UI reference](https://frontmcp.dev/reference/ui#content-security-policy) has the whole policy. ## The widget gets what `outputSchema` declares A widget shows the tool's result, and the result is also what the model reads. `outputSchema` keeps them tidy: fields it doesn't declare are dropped before the result goes out, from `structuredContent`, from the text the model reads and from the data the page gets. `get_ticket` below declares four fields and returns the whole row from its store, internal notes included. Open the **Tests** tab: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts get-ticket.tool.ts active import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk"; import { rows } from "./rows"; const Ticket = { id: z.string(), title: z.string(), status: z.enum(["open", "closed"]), priority: z.enum(["high", "normal"]) }; type Card = { id: string; title: string; status: string; priority: string }; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, outputSchema: Ticket, ui: { template: (ctx: TemplateContext<{ id: string }, Card>) => ctx.helpers.html`

            ${ctx.output.title}

            ${ctx.output.status}

            ` }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return rows[id]; // the whole row } } ``` ```ts rows.ts // Rows as the help desk's database returns them. export const rows: Record = { "T-1": { id: "T-1", title: "Cannot log in", status: "open", priority: "high", internalNotes: "Acme is late on two invoices" }, "T-2": { id: "T-2", title: "Invoice total is wrong", status: "closed", internalNotes: "Third time this month" }, }; ``` ```ts fields.test.ts import { test, expect } from "@frontmcp/testing"; test("the notes never reach the model", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result.raw.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open", priority: "high" }); expect(result.text()).not.toContain("Acme is late on two invoices"); }); test("or the page", async ({ mcp }) => { const html: string = (await mcp.tools.call("get_ticket", { id: "T-1" })).raw._meta["ui/html"]; expect(html).toContain("

            Cannot log in

            "); expect(html).not.toContain("internalNotes"); }); test("a declared field the row doesn't have fails the call", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-2" }); expect(result).toBeError("INVALID_OUTPUT"); expect(result.raw._meta["ui/html"]).toBeUndefined(); }); ``` The widget shows a title and a status, and the notes are nowhere: not in `structuredContent`, not in the text block the model reads, and not in the page, in `window.__mcpToolOutput`, where anyone who opens the frame's source could read them. `outputSchema` also still checks the fields it declares: `T-2` has no `priority`, so its call fails with `INVALID_OUTPUT`, as it would without `ui`. The other side of the same rule is that the widget gets nothing the schema leaves out. A template that reads `ctx.output.priority` shows nothing when `priority` isn't in `outputSchema`, even though the row has it. Declare every field the widget shows, and then the model gets it too: design one result that's right for both, and keep what neither should have out of it. Changed in 1.8.7: with `ui` set, FrontMCP used to skip this step. A tool with an `outputSchema` sent whatever `execute()` returned, notes included, to the model and into the page, and the fix was to return only the declared fields, like `Ticket.parse(row)` with a Zod object. That's no longer needed, and a widget that read a field the schema doesn't declare now gets `undefined` for it. ## Recap - Inline (the default) puts the page, rendered with the call's data, in every result: about 38 KB each time. Its resource is a page without data. - Static renders the page once at startup, with `ctx.output` set to `{}`, and serves it as the resource. The host sends each result, and the page draws it from the `tool:result` event's `structuredContent`, with `textContent`. - `@FrontMcp({ ui: { servingMode } })` sets the mode of every tool that doesn't choose its own, and `@App({ ui: { servingMode } })` that of one app's tools. The tool wins, then the app, then the server. - `"hybrid"` is static plus a code-free `ui/component` object for a few platforms, and `"direct-url"` and `"custom-url"` are inline. The server warns about all three at startup. - The page's own Content Security Policy allows the five CDNs, and the origins in `ui.csp`, secure or local: `connectDomains` for `fetch()`, `resourceDomains` for images, scripts and fonts. The API still needs CORS headers. A tool the widget calls avoids both. - `outputSchema` drops the fields it doesn't declare from the result, the text the model reads and the page, `ui` or not. Declare every field the widget shows. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Serve the queue once The queue widget is served inline, so every call carries a page. Serve it statically: results should carry no page, and the page the host reads should still show the heading and draw the tickets the host sends it. Initial call configuration: ```js { tool: "list_queue", arguments: {} } ``` ```ts list-queue.tool.ts import { Tool, ToolContext, type TemplateContext } from "@frontmcp/sdk"; type Queue = { tickets: { id: string; title: string }[] }; @Tool({ name: "list_queue", description: "List the open support tickets, most urgent first.", inputSchema: {}, ui: { template: (ctx: TemplateContext<{}, Queue>) => ctx.helpers.html`

            Open tickets

              ${ctx.output.tickets.map((t) => ctx.helpers.html`
            • ${t.id} ${t.title}
            • `)}
            `, }, }) export class ListQueue extends ToolContext { async execute(): Promise { return { tickets: [{ id: "T-1", title: "Cannot log in" }, { id: "T-3", title: "Login link expired" }] }; } } ``` **Solution file (alternative to the starter):** ```ts list-queue.tool.ts solution import { Tool, ToolContext, type TemplateContext } from "@frontmcp/sdk"; type Queue = { tickets: { id: string; title: string }[] }; @Tool({ name: "list_queue", description: "List the open support tickets, most urgent first.", inputSchema: {}, ui: { servingMode: "static", template: (ctx: TemplateContext<{}, Partial>) => ctx.helpers.html`

            Open tickets

              `, }, }) export class ListQueue extends ToolContext { async execute(): Promise { return { tickets: [{ id: "T-1", title: "Cannot log in" }, { id: "T-3", title: "Login link expired" }] }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts static.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`list_queue` results carry no page", async ({ mcp }) => { const result = await mcp.tools.call("list_queue", {}); expect(result).toBeSuccessful(); expect(Object.keys(result.raw._meta).filter((k) => k.startsWith("ui/"))).toEqual([]); }); test("the page the host reads has the heading", async ({ mcp }) => { const page = (await mcp.resources.read("ui://widget/list_queue.html")).text(); expect(page).toContain("

              Open tickets

              "); }); test("the page draws the tickets from the host's `structuredContent`", async ({ mcp }) => { const page = (await mcp.resources.read("ui://widget/list_queue.html")).text(); const body = page.slice(page.lastIndexOf("

              ")); expect(body).toContain("structuredContent"); }); ``` **Hint:** Setting `servingMode` is half of it. A static template runs once, at startup, with `ctx.output` set to `{}`: what does `ctx.output.tickets.map` do then? Draw the list in the browser instead, when the host sends the result. **Solution:** `servingMode: "static"` takes the page out of the results and serves it as the resource. The template no longer reads `ctx.output`, which is `{}` at startup, so it renders; before, it threw and the server served an empty page. The script listens for `tool:result` and builds the list from `event.detail.structuredContent` with `textContent`, so ticket titles stay text. ### Challenge: Show the priority The ticket widget shows a title, a status and a priority line, but the priority line is always empty, though every ticket has one. Make the widget show it, without sending the internal notes. Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-3" } } ``` ```ts get-ticket.tool.ts import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk"; import { rows } from "./rows"; const Ticket = z.object({ id: z.string(), title: z.string(), status: z.enum(["open", "closed"]) }); type Card = z.infer & { priority?: string }; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, outputSchema: Ticket, ui: { template: (ctx: TemplateContext<{ id: string }, Card>) => ctx.helpers.html`

              ${ctx.output.title}

              Status: ${ctx.output.status}

              Priority: ${ctx.output.priority}

              `, }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return rows[id]; } } ``` **Solution file (alternative to the starter):** ```ts get-ticket.tool.ts solution import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk"; import { rows } from "./rows"; const Ticket = z.object({ id: z.string(), title: z.string(), status: z.enum(["open", "closed"]), priority: z.enum(["high", "normal"]) }); type Ticket = z.infer; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, outputSchema: Ticket, ui: { template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`

              ${ctx.output.title}

              Status: ${ctx.output.status}

              Priority: ${ctx.output.priority}

              `, }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return rows[id]; } } ``` ```ts rows.ts export const rows: Record = { "T-1": { id: "T-1", title: "Cannot log in", status: "open", priority: "high", internalNotes: "Acme is late on two invoices" }, "T-3": { id: "T-3", title: "Login link expired", status: "open", priority: "normal", internalNotes: "Third time this month" }, }; ``` **Hidden fixture/test file (runs with the example):** ```ts fields.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("the widget shows the ticket's priority", async ({ mcp }) => { const html: string = (await mcp.tools.call("get_ticket", { id: "T-3" })).raw._meta["ui/html"]; expect(html).toContain("

              Priority: normal

              "); }); test("`priority` is in the result the model reads", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result.raw.structuredContent.priority).toBe("high"); }); test("the internal notes still go nowhere", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-3" }); expect(result.text()).not.toContain("Third time this month"); expect((result.raw._meta["ui/html"] as string)).not.toContain("Third time this month"); }); ``` **Hint:** The template reads `ctx.output.priority`, and the row has it. What decides which fields of the row reach the template? **Solution:** The template gets what the tool's `outputSchema` declares, and `Ticket` leaves `priority` out, so `ctx.output.priority` is `undefined` and `html` renders nothing for it. Adding `priority` to the schema puts it in the result, the text the model reads and the page's data, while `internalNotes`, which the schema still doesn't declare, goes nowhere. --- # Building Widgets with React > Build FrontMCP widgets as React components: bundling, props, @frontmcp/ui/react hooks, calling tools with useCallTool, theme and size. Runs on Node, not the Playground. Source: https://frontmcp.dev/learn/building-widgets-with-react [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A template function builds a page from a string, and for a card that's all it takes. A widget with state is different: a queue you can filter, buttons that show they're working, an error next to the row it belongs to. As a template, that becomes a script inside a string, with no types and every change to the page made by hand. FrontMCP can build a widget from a React component in a `.tsx` file instead. It bundles the file, mounts the component with the call's data, and `@frontmcp/ui/react` gives it hooks to read the result and call tools through the host. The Playground can't run React widgets: FrontMCP bundles a widget file from the file system, on a Node server, and the Playground runs FrontMCP in your browser ([below](https://frontmcp.dev/learn/building-widgets-with-react#pointing-a-tool-at-a-widget-file), it shows what happens). So the widget files in this lesson are code blocks, and each one was run for real: bundled by a FrontMCP 1.9.3 server on Node, and rendered in Chromium in an MCP Apps host like the Playground's. The Playgrounds run what does run here: the tools, and the calls widgets make to them. **You will learn** - When a widget is worth writing in React - How to point a tool at a `.tsx` file, and what the project needs to bundle it - What the component gets as props, and what to check before it draws them - How to call a tool with `useCallTool()`, and handle each way a call can end - How to follow the host's theme, size the widget, and choose where React is loaded from ## When a template gets too big Here is the queue widget as a template function. It lists the open tickets, filters them to high priority, and closes a ticket from its row. Try it: Initial call configuration: ```js { tool: "list_queue", arguments: {} } ``` ```ts list-queue.tool.ts active import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk"; import { openTickets, type Queue } from "./tickets"; @Tool({ name: "list_queue", description: "List the open support tickets, most urgent first.", inputSchema: {}, ui: { template: (ctx: TemplateContext<{}, Queue>) => ctx.helpers.html`
                ${ctx.output.tickets.map((t) => ctx.helpers.html`
              • ${t.id} ${t.title}
              • `)}
              `, }, }) export class ListQueue extends ToolContext { async execute(): Promise { return { tickets: openTickets() }; } } ``` ```ts close-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @Tool({ name: "close_ticket", description: "Close a support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); if (ticket.status === "closed") this.fail(new PublicMcpError(`${id} is already closed.`)); ticket.status = "closed"; return { id, status: "closed" as const }; } } ``` ```ts tickets.ts export type Ticket = { id: string; title: string; priority: "high" | "normal" | "low"; status: "open" | "closed" }; export type Queue = { tickets: Omit[] }; export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", priority: "high", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", priority: "normal", status: "closed" }], ["T-3", { id: "T-3", title: "Login link expired", priority: "normal", status: "open" }], ["T-4", { id: "T-4", title: "Password reset email missing", priority: "high", status: "open" }], ]); export function openTickets(): Queue["tickets"] { return [...tickets.values()].filter((t) => t.status === "open").map(({ id, title, priority }) => ({ id, title, priority })); } ``` It works, and it's already hard to change. The rows are written twice: once as HTML on the server, and again, piece by piece, by the script as they change. Each state a row can be in, closing, closed or failed, is a DOM edit made by hand, and the filter is a loop over elements. None of the script is type-checked, because it's a string. Add a "reassign" button and a count of open tickets, and it gets worse. That's the kind of widget worth writing in React. ## Pointing a tool at a widget file Put the component in a file of its own, export it as the default, and point `ui.template` at the file with `{ file }` and an absolute path: ```ts list-queue.tool.ts import { join } from "node:path"; import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { openTickets } from "./tickets"; const QueueItem = z.object({ id: z.string(), title: z.string(), priority: z.enum(["high", "normal", "low"]) }); @Tool({ name: "list_queue", description: "List the open support tickets, most urgent first.", inputSchema: {}, outputSchema: { tickets: z.array(QueueItem) }, ui: { template: { file: join(__dirname, "queue.widget.tsx") } }, }) export class ListQueue extends ToolContext { async execute() { return { tickets: openTickets() }; } } ``` ```tsx queue.widget.tsx type Ticket = { id: string; title: string; priority: "high" | "normal" | "low" }; type Queue = { tickets: Ticket[] }; export default function QueueWidget({ output }: { output: Queue | null }) { if (!output?.tickets) return

              Loading the queue…

              ; return (
                {output.tickets.map((t) => (
              • {t.id} {t.title} ({t.priority})
              • ))}
              ); } ``` When the tool is called, FrontMCP bundles the file with esbuild, wraps the component in `McpBridgeProvider` from `@frontmcp/ui/react`, and sends a page that mounts it into `
              `, with `ui/type` set to `"react"`. The component gets three props: `output`, the call's result as `execute()` returned it; `input`, the call's arguments; and `loading`, which is `true` for the first render only. The project needs a few things for this to work: - **`@frontmcp/ui`**, even for a widget that imports nothing from it, because the code FrontMCP adds to mount your component does: `npm install @frontmcp/ui`. It brings React, React DOM, MUI and Emotion along as its peers. - **`esbuild`**, which a project made with `frontmcp create` already has, through the `frontmcp` CLI. In any other project, install it too, as a dependency and not a dev dependency, since FrontMCP loads it when the tool is called and `@frontmcp/uipack` only lists it as an optional peer. Without it every call logs `applyUI: UI rendering failed` with `FileSource widget "…/queue.widget.tsx" needs esbuild, which is not installed`, and the result has no page. - **A Node server**: CommonJS, which is what `frontmcp create` makes, or an ES module (`"type": "module"`), with `import.meta.dirname` in place of `__dirname`. Changed in 1.9: an ES module server couldn't bundle widget files, and logged `Dynamic require of "path" is not supported`. - **An absolute path.** A relative `file` is resolved from the process's working directory, not from the tool's file, so use `join(__dirname, …)`. `__dirname` is the folder the compiled tool runs from: `frontmcp build` copies the `.widget.tsx` files into its output, next to the bundle for `--target node`, but if you compile with `tsc`, copy them next to the output yourself. Otherwise the call logs `applyUI: UI rendering failed` with `FileSource widget not found (ENOENT): no file at "…/dist/queue.widget.tsx"`, and the message names the file under `src/` when the same path exists there. - **Keep widget files out of the server's own build.** The server's `tsc` doesn't need them, and would need JSX settings and React's types. A `frontmcp create` project's `tsconfig.json` already excludes `**/*.widget.tsx`. This is what the Playground does with that tool. It runs FrontMCP in your browser, which has no file system to bundle a widget from. The **Logs** tab counts one error: Initial call configuration: ```js { tool: "list_queue", arguments: {} } ``` ```ts list-queue.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { openTickets } from "./tickets"; const QueueItem = z.object({ id: z.string(), title: z.string(), priority: z.enum(["high", "normal", "low"]) }); @Tool({ name: "list_queue", description: "List the open support tickets, most urgent first.", inputSchema: {}, outputSchema: { tickets: z.array(QueueItem) }, // In a project: join(__dirname, "queue.widget.tsx") ui: { template: { file: "/help-desk/src/queue.widget.tsx" } }, }) export class ListQueue extends ToolContext { async execute() { return { tickets: openTickets() }; } } ``` ```ts tickets.ts type Ticket = { id: string; title: string; priority: "high" | "normal" | "low"; status: "open" | "closed" }; const tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", priority: "high", status: "open" }, { id: "T-2", title: "Invoice total is wrong", priority: "normal", status: "closed" }, { id: "T-3", title: "Login link expired", priority: "normal", status: "open" }, ]; export function openTickets() { return tickets.filter((t) => t.status === "open").map(({ id, title, priority }) => ({ id, title, priority })); } ``` ```ts no-page.test.ts import { test, expect } from "@frontmcp/testing"; test("the call succeeds, but its result has no page", async ({ mcp }) => { const result = await mcp.tools.call("list_queue", {}); expect(result).toBeSuccessful(); expect(result.raw._meta["ui/html"]).toBeUndefined(); }); test("tools/list still says the tool has a React widget", async ({ mcp }) => { const [tool] = await mcp.tools.list(); expect(tool._meta).toEqual({ ui: { resourceUri: "ui://widget/list_queue.html" }, "frontmcp/type": "react" }); }); ``` The log has `applyUI: UI rendering failed` with `Dynamic require of "path" is not supported`, and the result went out without a page: a widget that can't be built costs the widget, not the call. On a Node server the same tool sends a page of about 59 KB. ## Reading the call's data The widget above shows the open tickets in Chromium, in an MCP Apps host like the Playground's. The page starts with `output` set to what `execute()` returned. Then the host sends the call's result, as MCP Apps hosts do, and the bridge replaces the output with that result's `structuredContent`: the same object, so the component renders the same tickets again. The check is on the field the widget needs, `output?.tickets`, and not on `output` itself. A static widget's `output` is `null` until the host sends a result, which `?.` handles, but a result that failed makes it a string, the error's message, which is truthy. `useStructuredContent()` returns the host's object too: the result's `structuredContent` from an MCP Apps host, `window.openai.toolOutput` from the OpenAI Apps SDK, and `undefined` before the host has sent anything. Widgets written for 1.8 read it first, since a static page started with `output` set to `{}`, which in the OpenAI Apps SDK stayed `{}` until the host assigned a new output ([Hosts and platforms](https://frontmcp.dev/reference/ui/hosts#the-openai-apps-sdk)). In 1.9.3 `output` is enough, and this still works: ```tsx queue.widget.tsx import { useStructuredContent } from "@frontmcp/ui/react"; type Ticket = { id: string; title: string; priority: "high" | "normal" | "low" }; type Queue = { tickets: Ticket[] }; export default function QueueWidget({ output }: { output: Queue | null }) { const queue = useStructuredContent() ?? (output?.tickets ? output : null); if (!queue) return

              Loading the queue…

              ; return (
                {queue.tickets.map((t) => (
              • {t.id} {t.title} ({t.priority})
              • ))}
              ); } ``` The widget reads the object the tool returned, so that's what the tool has to get right. The Playground can run this side: Initial call configuration: ```js { tool: "list_queue", arguments: {} } ``` ```ts list-queue.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { openTickets } from "./tickets"; const QueueItem = z.object({ id: z.string(), title: z.string(), priority: z.enum(["high", "normal", "low"]) }); @Tool({ name: "list_queue", description: "List the open support tickets, most urgent first.", inputSchema: {}, outputSchema: { tickets: z.array(QueueItem) }, }) export class ListQueue extends ToolContext { async execute() { return { tickets: openTickets() }; } } ``` ```ts tickets.ts type Ticket = { id: string; title: string; priority: "high" | "normal" | "low"; status: "open" | "closed" }; const tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", priority: "high", status: "open" }, { id: "T-2", title: "Invoice total is wrong", priority: "normal", status: "closed" }, { id: "T-3", title: "Login link expired", priority: "normal", status: "open" }, ]; export function openTickets() { return tickets.filter((t) => t.status === "open").map(({ id, title, priority }) => ({ id, title, priority })); } ``` ```ts data.test.ts import { test, expect } from "@frontmcp/testing"; const queue = { tickets: [{ id: "T-1", title: "Cannot log in", priority: "high" }, { id: "T-3", title: "Login link expired", priority: "normal" }] }; test("structuredContent is the object the widget gets as `output`", async ({ mcp }) => { const result = await mcp.tools.call("list_queue", {}); expect(result.raw.structuredContent).toEqual(queue); }); test("the model reads the same object, as text", async ({ mcp }) => { const result = await mcp.tools.call("list_queue", {}); expect(result.raw.content).toEqual([{ type: "text", text: JSON.stringify(queue) }]); }); ``` Return an object, not an array: a tool that returns an array sends `structuredContent` as `{ "value": [...] }` ([Shaping Tool Results](https://frontmcp.dev/learn/shaping-tool-results#what-comes-back-from-toolscall)), and the widget has to know that. The other hooks for the call's data, `useToolOutput()` and `useToolInput()`, are in the [Widget components reference](https://frontmcp.dev/reference/ui/components#hooks). Changed in 1.8.7: after the host's result, `output` used to be the result's `content` array, `[{ type: "text", text: "{\"tickets\":[…]}" }]`, and a widget that checked `output?.tickets` showed `Loading the queue…` for good. Widgets read `useStructuredContent()` first to get around it. Both work now. ## Calling a tool with `useCallTool` Each row gets a Close button. `useCallTool(name)` returns a function that calls the tool through the host, and the state of the last call: ```tsx function TicketRow({ ticket }: { ticket: Ticket }) { const [close, { data, loading, error }] = useCallTool<{ id: string }, { id: string; status: "closed" }>("close_ticket"); const closed = data?.structuredContent?.status === "closed"; return (
            • {ticket.id} {ticket.title}{" "} {closed ? ( Closed ) : ( )} {data?.isError &&

              {data.content[0]?.text}

              } {error &&

              {error.message}

              }
            • ); } ``` A call can end three ways, and each has its place in the state: - **It succeeds.** `data` is the host's whole result, so the tool's object is `data.structuredContent`. - **The tool fails.** That's still a result: `data.isError` is `true` and the message is in `data.content`. Closing a ticket someone else closed a minute ago shows `T-1 is already closed.` under its row. - **The call can't be made**, like in a host that didn't finish its handshake. Then `error` is set, with a message like `Tool calls not supported on this platform (ext-apps)`, and `close()` resolves with `null`. > **Pitfall: useCallTool returns a tuple** > > `useCallTool()` returns an array, not an object, so `const { call, loading } = useCallTool("close_ticket")` gives `undefined` for both. Destructure an array: `const [close, { data, loading, error }, reset] = useCallTool("close_ticket")`. The widget gets back whatever the host got from your server, so what `close_ticket` returns matters here too. Its errors should be `PublicMcpError`s, so the message the widget shows survives production. And it's better without a `ui` of its own. The bridge marks the widget's call with `_meta["frontmcp/widgetCall"]`, and when a tool with a widget gets a marked call, FrontMCP answers with the data and no page, provided the host passes that marker on. Under the OpenAI Apps SDK, `callTool()` can't send it, and there a tool with a widget sends its page, about 38 KB, into the widget on every click. This Playground has one tool of each kind, and the last test calls `get_ticket` the way the bridge does: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @Tool({ name: "close_ticket", description: "Close a support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); if (ticket.status === "closed") this.fail(new PublicMcpError(`${id} is already closed.`)); ticket.status = "closed"; return { id, status: "closed" as const }; } } ``` ```ts get-ticket.tool.ts import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk"; import { tickets, type Ticket } from "./tickets"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, ui: { template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`

              ${ctx.output.title}

              ` }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return tickets.get(id)!; } } ``` ```ts tickets.ts export type Ticket = { id: string; title: string; priority: "high" | "normal" | "low"; status: "open" | "closed" }; export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", priority: "high", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", priority: "normal", status: "closed" }], ]); ``` ```ts calls.test.ts import { test, expect } from "@frontmcp/testing"; test("success: data.structuredContent is the tool's object", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.raw.structuredContent).toEqual({ id: "T-1", status: "closed" }); }); test("a failed tool: data.isError, with the message in content", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-2" }); expect(result.raw.isError).toBe(true); expect(result.raw.content).toEqual([{ type: "text", text: "T-2 is already closed." }]); }); test("close_ticket has no ui, so its results carry no page", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.raw._meta["ui/html"]).toBeUndefined(); }); test("a tool with ui sends its page with a model's call", async ({ mcp }) => { const html: string = (await mcp.tools.call("get_ticket", { id: "T-1" })).raw._meta["ui/html"]; expect(html.length).toBeGreaterThan(25_000); }); test("a call the bridge marks as the widget's gets the data without the page", async ({ mcp }) => { const body = await mcp.raw.request({ method: "tools/call", params: { name: "get_ticket", arguments: { id: "T-1" }, _meta: { "frontmcp/widgetCall": true } }, }); expect(body.result.structuredContent.title).toBe("Cannot log in"); expect(body.result._meta["ui/html"]).toBeUndefined(); }); ``` For the server, a call from a widget is an ordinary `tools/call` from the host, with the host's identity and every check your tools already have ([Calling a tool from a widget](https://frontmcp.dev/reference/ui/hosts#calling-a-tool-from-a-widget)). ## The theme, the size, and where React comes from The bridge sets the page's `` and `` from the host's theme, as it does for a template ([Your First Widget](https://frontmcp.dev/learn/your-first-widget#following-the-hosts-theme)), so the browser's default colors and `light-dark()` in your CSS follow the host without code. When the component has to know, `useTheme()` returns `"light"` or `"dark"`: the host's answer to the handshake, then each change the host sends. In an MCP Apps host the component renders once before the host has answered the handshake, and again with its answer: `useTheme()`, `useCapability("canCallTools")` and `useHostContext()` start as `"light"`, `false` and the page's defaults, and update by themselves. A button that waits for `useCapability("canCallTools")` shows once the host can call tools, and stays away in a host that never answers. Handling `error` from `call()`, as above, covers the rest. Here is the whole widget, with the color of a high priority ticket picked from `useTheme()`: ```tsx queue.widget.tsx import { useState } from "react"; import { useCallTool, useTheme } from "@frontmcp/ui/react"; type Ticket = { id: string; title: string; priority: "high" | "normal" | "low" }; type Queue = { tickets: Ticket[] }; export default function QueueWidget({ output }: { output: Queue | null }) { const [highOnly, setHighOnly] = useState(false); const theme = useTheme(); if (!output?.tickets) return

              Loading the queue…

              ; const shown = highOnly ? output.tickets.filter((t) => t.priority === "high") : output.tickets; const urgent = theme === "dark" ? "#ff8a80" : "#b42318"; return (
                {shown.map((ticket) => ( ))}
              ); } function TicketRow({ ticket, urgent }: { ticket: Ticket; urgent: string }) { const [close, { data, loading, error }] = useCallTool<{ id: string }, { id: string; status: "closed" }>("close_ticket"); const closed = data?.structuredContent?.status === "closed"; return (
            • {ticket.id} {ticket.title} ({ticket.priority}){" "} {closed ? ( Closed ) : ( )} {data?.isError &&

              {data.content[0]?.text}

              } {error &&

              {error.message}

              }
            • ); } ``` With `autoResize: true` added to the tool's `ui`, this widget reports its height when it starts, and again when the filter or a closed ticket changes it. Nothing in the file is there for the size: the page measures its whole `` from outside, margins included, so the body's margin and the list's own count. In a dark host the text and the buttons use the dark scheme, and `useTheme()` is `"dark"` as soon as the host has answered, with no code to ask the bridge. Changed in 1.8.6: `useTheme()` used to stay `"light"` in an MCP Apps host until the host sent a change, because the handshake's context was merged without telling anyone. Widgets asked the bridge themselves, with `useMcpBridge()` on `bridge:ready`. The page also measured `#root`, which left out the body's margin, and could lose its first height, so widgets set `document.body.style.margin = "0"` and sent the first height themselves. None of that is needed now. Changed in 1.8.7: `useCapability()` and `useHostContext()` update too. They used to stay at `false` and `null`. Where React comes from is the other thing to decide, with `ui.resourceMode`: | `resourceMode` | The page | When to use it | | --- | --- | --- | | `"cdn"`, the default for most clients | About 59 KB. An import map loads React 19.2.4 from `esm.sh`. | Hosts whose frames can reach `esm.sh` | | `"inline"` | About 730 KB, React bundled in. Nothing is loaded from outside. | Hosts that block outside scripts, and static widgets meant for them | A `"cdn"` page whose frame can't reach `esm.sh` stays on `Loading widget...` for good, with nothing in the page to say why. Both were run in Chromium, with `esm.sh` blocked for the second. `@frontmcp/ui` also has React components built on MUI, like `Card`, `Badge`, `Button` and `Table`, with a theme provider for them. They make a page of about 460 KB. They're in the [Widget components reference](https://frontmcp.dev/reference/ui/components#components), with every hook. The [Expense Dashboard](https://frontmcp.dev/examples/expense-dashboard#running-it-for-real) shows a whole dashboard written this way, and what it took to run it. > **Note** > > `@frontmcp/react` sounds like it belongs on this page, but it's something else: a React client that runs a FrontMCP server inside a React app. It isn't for widgets. It has [a reference of its own](https://frontmcp.dev/reference/react). Widgets use `@frontmcp/ui/react`. ## Recap - Write a widget in React when it has state: filters, per-row actions, loading and error states. A card is fine as a template function. - `ui: { template: { file: join(__dirname, "queue.widget.tsx") } }` points a tool at a component file, exported as the default. The server, CommonJS or ES module, needs `@frontmcp/ui`, `esbuild`, and the widget file next to the compiled tool, which `frontmcp build` copies for you. The Playground can't bundle one. - The component gets `output`, `input` and `loading`. In an MCP Apps host, `output` becomes the result's `structuredContent` once the host sends the result. Check for the fields you need, not for `output`: an error's text is truthy. `useStructuredContent()` is the host's object too. - `const [call, { data, loading, error }] = useCallTool(name)`: `data` is the whole result, `data.isError` means the tool failed, and `error` means the call couldn't be made. Give the tools a widget calls `PublicMcpError`s and no `ui`: the bridge marks a widget's own calls so they come back without a page, but not under the OpenAI Apps SDK. - In an MCP Apps host, `useTheme()`, `useCapability()` and `useHostContext()` follow the host's handshake, and the page follows its theme by itself with `light-dark()` colors. Handle `error` from `call()` for a host that never answers. `autoResize: true` sizes the frame, with no margin or first-report code. - `resourceMode: "cdn"` loads React from `esm.sh`; `"inline"` bundles it, for hosts that can't reach it. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Return an object the widget can read The queue widget reads the tickets like this: ```tsx const queue = useStructuredContent<{ tickets: Ticket[] }>(); return
                {queue?.tickets.map((t) =>
              • {t.title}
              • )}
              ; ``` But `list_queue` returns an array, and the widget shows nothing. Make the tool return what the widget reads, and declare that shape so clients see it in `tools/list`. Initial call configuration: ```js { tool: "list_queue", arguments: {} } ``` ```ts list-queue.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { openTickets } from "./tickets"; @Tool({ name: "list_queue", description: "List the open support tickets, most urgent first.", inputSchema: {}, }) export class ListQueue extends ToolContext { async execute() { return openTickets(); } } ``` **Solution file (alternative to the starter):** ```ts list-queue.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { openTickets } from "./tickets"; const QueueItem = z.object({ id: z.string(), title: z.string(), priority: z.enum(["high", "normal", "low"]) }); @Tool({ name: "list_queue", description: "List the open support tickets, most urgent first.", inputSchema: {}, outputSchema: { tickets: z.array(QueueItem) }, }) export class ListQueue extends ToolContext { async execute() { return { tickets: openTickets() }; } } ``` ```ts tickets.ts type Ticket = { id: string; title: string; priority: "high" | "normal" | "low"; status: "open" | "closed" }; const tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in", priority: "high", status: "open" }, { id: "T-2", title: "Invoice total is wrong", priority: "normal", status: "closed" }, { id: "T-4", title: "Password reset email missing", priority: "high", status: "open" }, ]; export function openTickets() { return tickets.filter((t) => t.status === "open").map(({ id, title, priority }) => ({ id, title, priority })); } ``` **Hidden fixture/test file (runs with the example):** ```ts shape.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`structuredContent.tickets` holds the open tickets", async ({ mcp }) => { const result = await mcp.tools.call("list_queue", {}); expect(result.raw.structuredContent).toEqual({ tickets: [ { id: "T-1", title: "Cannot log in", priority: "high" }, { id: "T-4", title: "Password reset email missing", priority: "high" }, ], }); }); test("`tools/list` declares `tickets` as an array", async ({ mcp }) => { const [tool] = await mcp.tools.list(); expect(tool.outputSchema?.properties?.tickets?.type).toBe("array"); }); ``` **Hint:** What does FrontMCP put in `structuredContent` when `execute()` returns an array? The widget wants an object with a `tickets` field. **Solution:** An array arrives as `{ "value": [...] }`, so `queue.tickets` was `undefined`. Returning `{ tickets: openTickets() }` gives the widget the object it reads, and `outputSchema: { tickets: z.array(QueueItem) }` shows its shape in `tools/list` and checks every result against it. ### Challenge: Fail so the widget can say why The widget shows `data.content[0].text` under a row when `data.isError` is set. But `close_ticket` reports success for a ticket that's already closed, and for one that doesn't exist. Make it fail for both, with messages the widget can show as they are, in production too. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @Tool({ name: "close_ticket", description: "Close a support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (ticket) ticket.status = "closed"; return { id, status: "closed" as const }; } } ``` **Solution file (alternative to the starter):** ```ts close-ticket.tool.ts solution import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @Tool({ name: "close_ticket", description: "Close a support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); if (ticket.status === "closed") this.fail(new PublicMcpError(`${id} is already closed.`)); ticket.status = "closed"; return { id, status: "closed" as const }; } } ``` ```ts tickets.ts type Ticket = { id: string; title: string; status: "open" | "closed" }; export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }], ]); ``` **Hidden fixture/test file (runs with the example):** ```ts close.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("closing an open ticket returns `{ id, status: \"closed\" }`", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result).toBeSuccessful(); expect(result.raw.structuredContent).toEqual({ id: "T-1", status: "closed" }); }); test("closing T-2, which is closed, fails with `T-2 is already closed.`", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-2" }); expect(result).toBeError("PUBLIC_ERROR"); expect(result.text()).toBe("T-2 is already closed."); }); test("closing T-9 fails with `There's no ticket T-9.`", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-9" }); expect(result).toBeError("PUBLIC_ERROR"); expect(result.text()).toBe("There's no ticket T-9."); }); ``` **Hint:** A tool fails with `this.fail()`. Which error class keeps its message in production, where FrontMCP hides the message of a plain `Error`? **Solution:** `this.fail(new PublicMcpError(…))` ends the call with `isError: true` and the message as the result's only text block, so the widget's `data.content[0].text` is exactly `T-2 is already closed.` A plain `Error` would work in development, but in production FrontMCP hides its message, and the widget would show a generic one. ### Challenge: Keep the widget's calls small Someone copied `get_ticket`'s `ui` onto `close_ticket`, so every result of `close_ticket` now carries a whole page. Under the OpenAI Apps SDK, where the bridge can't mark the widget's own calls, that's every click on a widget's Close button too, a page the widget throws away. Make `close_ticket` results small again, and keep `get_ticket`'s widget. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @Tool({ name: "close_ticket", description: "Close a support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, ui: { template: (ctx: TemplateContext<{ id: string }, { id: string }>) => ctx.helpers.html`

              ${ctx.output.id} closed

              ` }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); ticket.status = "closed"; return { id, status: "closed" as const }; } } ``` **Solution file (alternative to the starter):** ```ts close-ticket.tool.ts solution import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @Tool({ name: "close_ticket", description: "Close a support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); ticket.status = "closed"; return { id, status: "closed" as const }; } } ``` ```ts get-ticket.tool.ts import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk"; import { tickets, type Ticket } from "./tickets"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, ui: { template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`

              ${ctx.output.title}

              ${ctx.output.status}

              ` }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return tickets.get(id)!; } } ``` ```ts tickets.ts export type Ticket = { id: string; title: string; status: "open" | "closed" }; export const tickets = new Map([["T-1", { id: "T-1", title: "Cannot log in", status: "open" }]]); ``` **Hidden fixture/test file (runs with the example):** ```ts small.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`close_ticket` results carry no page", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result).toBeSuccessful(); expect(Object.keys(result.raw._meta ?? {}).filter((k) => k.startsWith("ui/"))).toEqual([]); }); test("`close_ticket` isn't listed with a widget", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "close_ticket"); expect(tool?._meta?.ui).toBeUndefined(); }); test("`get_ticket` still has its widget", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result.raw._meta["ui/html"]).toContain("

              Cannot log in

              "); }); ``` **Hint:** Which tool does a person look at, and which one does only the widget call? **Solution:** Without `ui`, `close_ticket` answers with its `content` and `structuredContent` only, a few dozen bytes instead of about 38 KB, whatever the host. The widget reads `data.structuredContent.status` and never needed the page. `get_ticket`, which people look at, keeps its widget. --- # Built-in Plugins and Adapters > The plugins and the adapter the FrontMCP team publishes, and when to reach for each. Caching results, remembering values across calls, asking for approval, feature flags, CodeCall, and turning an OpenAPI spec into tools. Source: https://frontmcp.dev/learn/built-in-plugins-and-adapters [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 In [Extending with Plugins](https://frontmcp.dev/learn/extending-with-plugins) you wrote a plugin that records every change in an audit log. Some jobs come up on almost every server, and for those the FrontMCP team publishes plugins of its own: answering a repeated lookup without running it again, remembering a user's preference, making sure a person agreed before something is deleted, shipping a tool to some users before the rest. There's also CodeCall, for servers with more tools than a model can read, and an adapter that turns an OpenAPI description of a REST API into tools. This chapter teaches each one with the help desk, and points out where FrontMCP behaves differently from what an option's name suggests. **In this chapter** - [How to answer repeated calls from a cache](https://frontmcp.dev/learn/caching-results) - [How tools remember values from one call to the next](https://frontmcp.dev/learn/remembering-across-calls) - [How to require a person's approval before a tool runs](https://frontmcp.dev/learn/asking-for-approval) - [How to turn tools on and off with feature flags](https://frontmcp.dev/learn/turning-features-on-and-off) - [How to let the model find tools and call them from a short script](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall) - [How to turn an OpenAPI spec into tools](https://frontmcp.dev/learn/wrapping-an-openapi-service) ## Using an official plugin Each is its own npm package. The plugins are registered like the one you wrote, with `SomePlugin.init({ ... })` in the `plugins` of an `@App`, for that app's tools, or of `@FrontMcp`, for every app's, and each adds something your code opts in to. What a plugin adds to `this`, like `this.remember`, reaches only the tools of the apps it's registered for: | Package | Adds | Lesson | | --- | --- | --- | | `@frontmcp/plugin-cache` | `cache` on `@Tool` | [Caching Results](https://frontmcp.dev/learn/caching-results) | | `@frontmcp/plugin-remember` | `this.remember`, and four memory tools you can turn on | [Remembering Across Calls](https://frontmcp.dev/learn/remembering-across-calls) | | `@frontmcp/plugin-approval` | `approval` on `@Tool`, and `this.approval` | [Asking for Approval](https://frontmcp.dev/learn/asking-for-approval) | | `@frontmcp/plugin-feature-flags` | `featureFlag` on tools, resources, prompts, skills and agents, and `this.featureFlags` | [Turning Features On and Off](https://frontmcp.dev/learn/turning-features-on-and-off) | | `@frontmcp/plugin-codecall` | Six `codecall:*` tools in front of yours | [Letting the Model Write Code with CodeCall](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall) | | `@frontmcp/adapters` | `OpenapiAdapter`: a tool for each operation in a spec | [Wrapping an OpenAPI Service](https://frontmcp.dev/learn/wrapping-an-openapi-service) | A few rules hold for all of them: - **Register a plugin with `init()`.** With no argument it's the same as `init({})`, the defaults, for the plugins whose options all have defaults. Feature flags need an `adapter`: `FeatureFlagPlugin.init()` throws a `FeatureFlagConfigurationError` without it. The bare class, `plugins: [SomePlugin]`, is the same as `init()` with no argument, so `plugins: [FeatureFlagPlugin]` stops the server with that error. Before 1.9.4 the bare class worked for the cache plugin only. - **`approval` and `featureFlag` need their plugin.** A server with a tool that declares one of them, and no plugin to enforce it, refuses to start, so a gate can't be left off by accident. `cache` and `codecall` without their plugin do nothing. - **Adapters go in `adapters`**, not in `plugins`: an app's, for that app, or `@FrontMcp`'s, whose adapter tools every app serves. - **Every package loads in CommonJS and ES module projects alike.** Before 1.9, an ES module project (`"type": "module"`) couldn't import the cache and CodeCall plugins: see [ES module projects](https://frontmcp.dev/reference/plugins#es-module-projects). ## Caching results A lookup the model repeats, like a customer's account from a slow CRM, doesn't need to run twice. With the cache plugin on the app and `cache: { ttl }` on the tool, the next call with the same arguments is answered from a store, without running `execute()`: Initial call configuration: ```js { tool: "get_customer", arguments: { id: "C-1" } } ``` ```ts help-desk.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { CachePlugin } from "@frontmcp/plugin-cache"; import { crm } from "./crm"; @Tool({ name: "get_customer", description: "Get a customer account by its id. May be up to 5 minutes old.", inputSchema: { id: z.string().describe("Customer id, like C-1") }, annotations: { readOnlyHint: true }, cache: { ttl: 300 }, // seconds }) export class GetCustomer extends ToolContext { async execute({ id }: { id: string }) { return crm.getCustomer(id); } } @App({ id: "help-desk", name: "Help Desk", tools: [GetCustomer], plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })], // the same answer for every caller }) export class HelpDeskApp {} ``` ```ts crm.ts // Stands in for a CRM that is slow to answer, and counts the requests it gets. export const crm = { requests: 0, async getCustomer(id: string) { crm.requests++; await new Promise((resolve) => setTimeout(resolve, 200)); return { id, name: id === "C-1" ? "Acme Corp" : "Globex", plan: "enterprise" }; }, }; ``` ```ts cache.test.ts import { test, expect } from "@frontmcp/testing"; import { crm } from "./crm"; test("the second call is answered from the cache", async ({ mcp }) => { const before = crm.requests; await mcp.tools.call("get_customer", { id: "C-2" }); const second = await mcp.tools.call("get_customer", { id: "C-2" }); expect(second.raw._meta?.cache).toBe("hit"); expect(crm.requests - before).toBe(1); }); ``` By default each caller has entries of their own. That keeps one user's answer from reaching another, and it also means an anonymous caller under MCP 2026-07-28, who is new on every request, is never answered from the cache. `keyByIdentity: false` shares entries, and is only safe for answers that are the same for everyone. ### [Caching Results](https://frontmcp.dev/learn/caching-results) Learn how to turn the cache on, why it answers nothing for anonymous callers, how to keep per-caller answers apart and share the rest, what makes two calls the same, how long an entry lives and why it can't be cleared, and what not to cache. ## Remembering across calls A tool forgets everything when its call ends. The Remember plugin gives every tool `this.remember`, a key-value memory whose **scope** decides who reads a value back. `user` scope keeps a value for the caller. The tests call the server in-process with [`createDirect()`](https://frontmcp.dev/learn/running-frontmcp-anywhere) as two signed-in agents, and as the Playground's anonymous client, which `user` scope refuses: Initial call configuration: ```js { tool: "draft_reply", arguments: { ticketId: "T-1", text: "You can log in again." } } ``` ```ts signature.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { RememberPlugin } from "@frontmcp/plugin-remember"; @Tool({ name: "set_signature", description: "Set the signature your replies end with.", inputSchema: { signature: z.string() } }) export class SetSignature extends ToolContext { async execute({ signature }: { signature: string }) { await this.remember.set("signature", signature, { scope: "user" }); return { signature }; } } @Tool({ name: "draft_reply", description: "Draft a reply, signed with your signature.", inputSchema: { ticketId: z.string(), text: z.string() } }) export class DraftReply extends ToolContext { async execute({ ticketId, text }: { ticketId: string; text: string }) { const signature = await this.remember.get("signature", { scope: "user", defaultValue: "The Help Desk" }); return { ticketId, reply: `${text}\n\n${signature}` }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SetSignature, DraftReply], plugins: [RememberPlugin.init({ type: "memory" })] }) export class HelpDeskApp {} ``` ```ts remember.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDeskApp } from "./signature.app"; test("each signed-in agent's signature is their own", async () => { const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); const nour = { authContext: { user: { sub: "nour" } } }; const sam = { authContext: { user: { sub: "sam" } } }; await server.callTool("set_signature", { signature: "Nour, Tier 2 support" }, nour); const nours = await server.callTool("draft_reply", { ticketId: "T-1", text: "Fixed." }, nour); const sams = await server.callTool("draft_reply", { ticketId: "T-2", text: "Fixed." }, sam); expect(nours.structuredContent).toMatchObject({ reply: "Fixed.\n\nNour, Tier 2 support" }); expect(sams.structuredContent).toMatchObject({ reply: "Fixed.\n\nThe Help Desk" }); await server.dispose(); }); test("an anonymous caller is refused user scope, with a message that says why", async ({ mcp }) => { const result = await mcp.tools.call("set_signature", { signature: "Someone" }); expect(result).toBeError("REMEMBER_IDENTITY_REQUIRED"); expect(result).toHaveTextContent("Remember cannot use user scope without an authenticated user"); }); ``` The other scopes matter as much: `global` is shared by everyone, and `session`, the default, doesn't mean a conversation under MCP 2026-07-28. It lasts for the signed-in caller. An anonymous caller can't use `user` or `session`: Remember refuses it with `REMEMBER_IDENTITY_REQUIRED`, and only `global` works for it. ### [Remembering Across Calls](https://frontmcp.dev/learn/remembering-across-calls) Learn how to store and read values with `this.remember`, what each scope keeps for signed-in and anonymous callers under MCP 2026-07-28, how to make values expire and forget them, and how to give the model memory tools of its own, limited to the scopes you choose. ## Asking for approval `destructiveHint` tells a client a tool can't be undone, and the client may still run it without asking anyone. The approval plugin refuses every call to a tool with `approval` until an approval of it is recorded for the caller: Initial call configuration: ```js { tool: "delete_ticket", arguments: { id: "T-1" } } ``` ```ts help-desk.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { ApprovalPlugin } from "@frontmcp/plugin-approval"; export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in" }], ["T-2", { id: "T-2", title: "Invoice total is wrong" }], ]); @Tool({ name: "delete_ticket", description: "Delete a support ticket for good. Needs the user's approval: call approve_delete first.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, approval: { approvalMessage: "delete_ticket needs the user's approval. Call approve_delete first." }, }) export class DeleteTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, deleted: tickets.delete(id) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [DeleteTicket], plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })] }) export class HelpDeskApp {} ``` ```ts approval.test.ts import { test, expect } from "@frontmcp/testing"; import { tickets } from "./help-desk.app"; test("an unapproved delete is refused, and the ticket stays", async ({ mcp }) => { const result = await mcp.tools.call("delete_ticket", { id: "T-2" }); expect(result).toBeError(); expect(result.text()).toMatch(/^delete_ticket needs the user's approval/); expect(tickets.has("T-2")).toBe(true); }); ``` The plugin doesn't ask anyone itself. Your code records an approval, with `this.approval`, once the user says yes in a form from `this.elicit()`, which under MCP 2026-07-28 the model can't answer. An approval belongs to a signed-in caller, so an anonymous caller can never keep one. ### [Asking for Approval](https://frontmcp.dev/learn/asking-for-approval) Learn how to put a tool behind approval, what the model sees when a call is refused, which is the same in production, how to record an approval only after the user says yes, and how to withdraw one, limit how long it lasts, and why a session approval lasts for the user under MCP 2026-07-28. ## Turning features on and off A feature flag ships a tool to some users before the rest, or turns one off without a deploy. With the feature flags plugin, `featureFlag: "bulk-export"` on a tool hides it from every list while the flag is off for the caller, and refuses it if it's called anyway. Flags come from an adapter: fixed values, as here, or LaunchDarkly, Split, Unleash or your own. Open the **Capabilities** tab to see which tools are listed: Initial call configuration: ```js { tool: "search_tickets", arguments: {} } ``` ```ts help-desk.app.ts active import { App, Tool, ToolContext } from "@frontmcp/sdk"; import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags"; @Tool({ name: "search_tickets", description: "Search support tickets.", inputSchema: {} }) export class SearchTickets extends ToolContext { async execute() { return { tickets: ["T-1", "T-3"] }; } } @Tool({ name: "bulk_export", description: "Export every ticket as CSV.", inputSchema: {}, featureFlag: "bulk-export" }) export class BulkExport extends ToolContext { async execute() { return { csv: "id,title\nT-1,Cannot log in" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, BulkExport], plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: { "bulk-export": false } })], }) export class HelpDeskApp {} ``` ```ts flags.test.ts import { test, expect } from "@frontmcp/testing"; test("a tool whose flag is off isn't listed", async ({ mcp }) => { expect((await mcp.tools.list()).map((t: { name: string }) => t.name)).toEqual(["search_tickets"]); }); test("and is refused if it's called anyway", async ({ mcp }) => { const result = await mcp.tools.call("bulk_export", {}); expect(result).toBeError("FEATURE_FLAG_DISABLED"); expect(result).toHaveTextContent('Tool "bulk_export" is disabled by feature flag "bulk-export"'); }); ``` Change `"bulk-export": false` to `true`, and `bulk_export` appears. Each caller's flags are evaluated for them, from their token, so a flag can be on for some users and off for the rest; under MCP 2026-07-28 every anonymous caller gets the same answer. ### [Turning Features On and Off](https://frontmcp.dev/learn/turning-features-on-and-off) Learn why a switch inside `execute()` is the wrong way to hide a tool, how to put tools, resources and prompts behind a flag, what a caller sees while it's off, how to make a flag a kill switch that's on by default, and how to turn a flag on for some callers first and read it inside a tool. ## Letting the model write code with CodeCall A server with dozens of tools sends every tool's schema to the model before it can pick one. CodeCall puts them behind six meta-tools instead: the model searches with `codecall:search`, reads the schemas of the tools it picked with `codecall:describe`, and calls them one at a time with `codecall:invoke`, or several at once from a short script with `codecall:execute`: Initial call configuration: ```js { tool: "codecall:search", arguments: { queries: ["close a ticket"] } } ``` ```ts help-desk.app.ts active import { App } from "@frontmcp/sdk"; import { CodeCallPlugin } from "@frontmcp/plugin-codecall"; import { CloseTicket, GetCustomer, GetTicket, SearchTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, GetTicket, CloseTicket, GetCustomer], plugins: [CodeCallPlugin.init({ mode: "codecall_only" })], }) export class HelpDeskApp {} ``` ```ts tools.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in", status: "open", priority: "high", customerId: "C-1" }, { id: "T-2", title: "Invoice total is wrong", status: "open", priority: "low", customerId: "C-2" }, { id: "T-3", title: "Login link expired", status: "open", priority: "high", customerId: "C-2" }, ]; const customers = [ { id: "C-1", name: "Acme Corp", plan: "enterprise" }, { id: "C-2", name: "Globex", plan: "starter" }, ]; @Tool({ name: "search_tickets", description: "Search support tickets by status", inputSchema: { status: z.enum(["open", "closed"]).optional() }, }) export class SearchTickets extends ToolContext { async execute({ status }: { status?: "open" | "closed" }) { return { tickets: tickets.filter((t) => !status || t.status === status) }; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string().describe("A 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}.`)); return ticket; } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "get_customer", description: "Get a customer account by its id", inputSchema: { id: z.string() } }) export class GetCustomer extends ToolContext { async execute({ id }: { id: string }) { return customers.find((c) => c.id === id) ?? this.fail(new PublicMcpError(`There's no customer ${id}.`)); } } ``` ```ts codecall.test.ts import { test, expect } from "@frontmcp/testing"; test("tools/list shows CodeCall's tools instead of the app's", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t: { name: string }) => t.name); expect(names).toEqual([ "codecall:describe", "codecall:execute", "codecall:invoke", "codecall:search", "codecall:searchKnowledge", "codecall:searchSkills", ]); }); test("the model finds a tool by what it wants to do", async ({ mcp }) => { const { tools } = (await mcp.tools.call("codecall:search", { queries: ["close a ticket"] })).json(); expect(tools[0]).toMatchObject({ name: "close_ticket", appId: "help-desk" }); }); test("and calls it through codecall:invoke", async ({ mcp }) => { const result = await mcp.tools.call("codecall:invoke", { tool: "get_ticket", input: { id: "T-1" } }); expect(result.json()).toMatchObject({ id: "T-1", title: "Cannot log in" }); }); test("a script joins tools, and only its answer goes back", async ({ mcp }) => { const script = [ 'const { tickets } = await callTool("search_tickets", { status: "open" });', 'const urgent = tickets.filter((t) => t.priority === "high");', "const out = [];", "for (const t of urgent) {", ' const customer = await callTool("get_customer", { id: t.customerId });', " out.push({ ticket: t.id, customer: customer.name });", "}", "return out;", ].join("\n"); const answer = (await mcp.tools.call("codecall:execute", { script })).json(); expect(answer).toEqual({ status: "ok", result: [ { ticket: "T-1", customer: "Acme Corp" }, { ticket: "T-3", customer: "Globex" }, ], }); }); ``` A script runs on the server, in a sandbox, and can loop over results and join them, so only the answer goes back to the model. This one lists the urgent open tickets with their customers, in one request: ```js const { tickets } = await callTool("search_tickets", { status: "open" }); const urgent = tickets.filter((t) => t.priority === "high"); const out = []; for (const t of urgent) { const customer = await callTool("get_customer", { id: t.customerId }); out.push({ ticket: t.id, customer: customer.name }); } return out; ``` Here are the tools above again, with `codecall:execute` called with that script. The Playground runs it in a browser sandbox, where a server uses one built on Node's `vm`; the answer is the same: **Reuses runnable project: help-desk.** Initial call configuration: ```js { tool: "codecall:execute", arguments: { script: [ 'const { tickets } = await callTool("search_tickets", { status: "open" });', 'const urgent = tickets.filter((t) => t.priority === "high");', 'const out = [];', 'for (const t of urgent) {', ' const customer = await callTool("get_customer", { id: t.customerId });', ' out.push({ ticket: t.id, customer: customer.name });', '}', 'return out;', ].join("\n") } } ``` `codecall:execute` returns `{ "status": "ok", "result": [{ "ticket": "T-1", "customer": "Acme Corp" }, { "ticket": "T-3", "customer": "Globex" }] }`. ### [Letting the Model Write Code with CodeCall](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall) Learn what a long tool list costs a model, how to put your tools behind CodeCall and when it's worth it, how the model finds, reads and calls them, one at a time or from a script, what a script may do, and how to keep tools out of its reach. ## Wrapping an OpenAPI service When what the model needs already exists as a REST API with an OpenAPI description, `OpenapiAdapter` from `@frontmcp/adapters` makes a tool of each operation, named by its `operationId`, which sends the request the spec describes and hands back the API's answer. The Playground can't reach a real API, so `desk.example.ts` plays it, in-process: Initial call configuration: ```js { tool: "getTicket", arguments: { id: "T-1" } } ``` ```ts desk.app.ts active import "./desk.example"; import { App } from "@frontmcp/sdk"; import { OpenapiAdapter } from "@frontmcp/adapters"; import { spec } from "./spec"; @App({ id: "desk", name: "Desk", adapters: [OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec })], }) export class DeskApp {} ``` ```ts spec.ts export const spec = { openapi: "3.0.3", info: { title: "Desk API", version: "1.0.0" }, paths: { "/tickets": { get: { operationId: "listTickets", summary: "List support tickets, newest first", parameters: [{ name: "status", in: "query", schema: { type: "string", enum: ["open", "closed"] } }], responses: { "200": { description: "The tickets" } }, }, }, "/tickets/{id}": { get: { operationId: "getTicket", summary: "Get one support ticket by its id", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "The ticket" } }, }, }, }, }; ``` ```ts desk.example.ts // Plays the Desk API at https://desk.example, which the Playground can't reach. // It answers requests in-process and records them. Your server doesn't need it. export const received: string[] = []; const tickets: Record = { "T-1": { id: "T-1", title: "Cannot log in", status: "open" }, }; async function desk(request: Request): Promise { received.push(`${request.method} ${request.url}`); const path = new URL(request.url).pathname; if (path === "/v1/tickets") return Response.json(Object.values(tickets)); const ticket = tickets[path.replace("/v1/tickets/", "")]; return ticket ? Response.json(ticket) : Response.json({ message: "No such ticket" }, { status: 404 }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); return url.hostname === "desk.example" ? desk(new Request(input, init)) : realFetch(input, init); }; ``` ```ts desk.test.ts import { test, expect } from "@frontmcp/testing"; import { received } from "./desk.example"; test("each operation is a tool named by its operationId", async ({ mcp }) => { expect((await mcp.tools.list()).map((t: { name: string }) => t.name).sort()).toEqual(["getTicket", "listTickets"]); }); test("a call sends the request, and returns the API's status and body", async ({ mcp }) => { const result = await mcp.tools.call("getTicket", { id: "T-1" }); expect(result.json()).toEqual({ status: 200, ok: true, data: { id: "T-1", title: "Cannot log in", status: "open" } }); expect(received.at(-1)).toBe("GET https://desk.example/v1/tickets/T-1"); }); test("an error from the API is an error the model can read", async ({ mcp }) => { const result = await mcp.tools.call("getTicket", { id: "T-9" }); expect(result).toBeError(); expect(result.text()).toContain("No such ticket"); }); ``` The model gets the API's answer as it is, and every operation in the spec as a tool, which is a lot of tools for a large API. The adapter's options choose which operations become tools, rename and describe them, send the API's credentials, and reshape what comes back. The adapter goes in an app's `adapters`, and each one needs a `name` of its own. ### [Wrapping an OpenAPI Service](https://frontmcp.dev/learn/wrapping-an-openapi-service) Learn how to turn an OpenAPI description into tools, what a call sends to the API and what the model gets back, how to give the adapter the API's credentials without the model seeing them, how to choose and name the operations the model sees, and what to do when an API is too big for one tool per operation. ## Plugin, adapter or your own code? | If you need… | Use | | --- | --- | | The same answer again, without the wait | The cache plugin, on tools that only read | | A value that outlasts one call | The Remember plugin, in the scope that matches who should read it | | A person's yes before a tool runs | The approval plugin, with a form only the user can answer | | A tool for some users, or one you can switch off | The feature flags plugin | | More tools than a model can usefully read | CodeCall | | An existing REST API as tools | The OpenAPI adapter, or a few tools with [`this.fetch()`](https://frontmcp.dev/learn/calling-other-services) when the model only needs a few well-shaped calls | | Something none of these do | [A plugin of your own](https://frontmcp.dev/learn/extending-with-plugins) | Every option of each one is in the [plugins and adapters reference](https://frontmcp.dev/reference/plugins). ## What's next? Start the chapter with [Caching Results](https://frontmcp.dev/learn/caching-results). After it, [Testing and Shipping](https://frontmcp.dev/learn/testing-and-shipping) covers proving your server works and getting it into production. --- # Caching Results > How to answer repeated calls to a slow FrontMCP tool from a cache with @frontmcp/plugin-cache, whose calls share an entry, what makes two calls the same, how long an entry lives, and what not to cache. Source: https://frontmcp.dev/learn/caching-results [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A model asks the same question more than once. It looks up the customer on a ticket, then again before it writes the reply, then again for the next ticket from the same customer. When the lookup waits on a slow CRM, every repeat makes the user wait again for an answer the model already had. FrontMCP's cache plugin, `@frontmcp/plugin-cache`, stores what a tool returns and answers the next call with the same arguments from the store, without running the tool. Turning it on is one line in the app and one field on the tool. Deciding what to cache, and for whom, is the rest of this lesson. **You will learn** - How to cache a tool's results with the cache plugin - Why the cache keeps each caller's answers apart, and when to share them - What makes two calls the same call - How long an entry lives, and why you can't clear one - What not to cache ## A lookup the model repeats `get_customer` asks the CRM for a customer's account. The CRM takes a fifth of a second to answer, and `crm.ts` counts the requests it gets. Open the **Tests** tab: Initial call configuration: ```js { tool: "get_customer", arguments: { id: "C-1" } } ``` ```ts get-customer.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { crm } from "./crm"; @Tool({ name: "get_customer", description: "Get a customer account by its id: the company's name and its plan.", inputSchema: { id: z.string().describe("Customer id, like C-1") }, annotations: { readOnlyHint: true }, }) export class GetCustomer extends ToolContext { async execute({ id }: { id: string }) { return crm.getCustomer(id); } } ``` ```ts crm.ts import { PublicMcpError } from "@frontmcp/sdk"; // Stands in for a CRM that is slow to answer, and counts the requests it gets. const customers: Record = { "C-1": { id: "C-1", name: "Acme Corp", plan: "enterprise" }, "C-2": { id: "C-2", name: "Globex", plan: "starter" }, }; export const crm = { requests: 0, async getCustomer(id: string) { crm.requests++; await new Promise((resolve) => setTimeout(resolve, 200)); const customer = customers[id]; if (!customer) throw new PublicMcpError(`There's no customer ${id}.`); return { ...customer }; }, }; ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { GetCustomer } from "./get-customer.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [GetCustomer] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts repeat.test.ts import { test, expect } from "@frontmcp/testing"; import { crm } from "./crm"; test("🚩 every call waits for the CRM again", async ({ mcp }) => { const before = crm.requests; for (let i = 0; i < 3; i++) { const result = await mcp.tools.call("get_customer", { id: "C-1" }); expect(result.json()).toEqual({ id: "C-1", name: "Acme Corp", plan: "enterprise" }); expect(result.durationMs).toBeGreaterThan(150); } expect(crm.requests - before).toBe(3); }); ``` Three calls, three requests to the CRM, three waits, for the same answer. A customer's name and plan don't change from one minute to the next, so the second and third calls could have been answered at once. ## Turning the cache on Install the plugin: ```bash npm install @frontmcp/plugin-cache ``` Then register it on the app with `CachePlugin.init({ type: "memory" })`, and mark each tool it should cache with `cache`. `ttl` is how long an answer is kept, in seconds: Initial call configuration: ```js { tool: "get_customer", arguments: { id: "C-1" } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { CachePlugin } from "@frontmcp/plugin-cache"; import { GetCustomer } from "./get-customer.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [GetCustomer], plugins: [CachePlugin.init({ type: "memory" })], }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts get-customer.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { crm } from "./crm"; @Tool({ name: "get_customer", description: "Get a customer account by its id: the company's name and its plan.", inputSchema: { id: z.string().describe("Customer id, like C-1") }, annotations: { readOnlyHint: true }, cache: { ttl: 300 }, // keep each answer for 5 minutes }) export class GetCustomer extends ToolContext { async execute({ id }: { id: string }) { return crm.getCustomer(id); } } ``` ```ts crm.ts import { PublicMcpError } from "@frontmcp/sdk"; // Stands in for a CRM that is slow to answer, and counts the requests it gets. const customers: Record = { "C-1": { id: "C-1", name: "Acme Corp", plan: "enterprise" }, "C-2": { id: "C-2", name: "Globex", plan: "starter" }, }; export const crm = { requests: 0, async getCustomer(id: string) { crm.requests++; await new Promise((resolve) => setTimeout(resolve, 200)); const customer = customers[id]; if (!customer) throw new PublicMcpError(`There's no customer ${id}.`); return { ...customer }; }, }; ``` ```ts cache.test.ts import { test, expect } from "@frontmcp/testing"; import { crm } from "./crm"; test("🚩 an anonymous caller is never answered from the cache", async ({ mcp }) => { const before = crm.requests; await mcp.tools.call("get_customer", { id: "C-1" }); const second = await mcp.tools.call("get_customer", { id: "C-1" }); expect(crm.requests - before).toBe(2); expect(second.raw._meta?.cache).toBeUndefined(); }); ``` The cache is on, and the CRM still gets every request. Nothing is wrong with the setup. By default the cache keeps a separate entry for each caller, so that one user is never given an answer that was meant for another. The Playground's client doesn't sign in, and under MCP 2026-07-28 a caller without credentials is a [new anonymous caller](https://frontmcp.dev/learn/authenticating-clients#a-server-anyone-can-call) on every request. Each call stores an entry under a caller that never comes back, and no call ever reads one. There's no error or warning. ## Sharing answers between callers A customer's account looks the same to every support agent who asks, so there's no reason to keep one copy per caller. `keyByIdentity: false` makes every caller share one entry per tool and arguments: Initial call configuration: ```js { tool: "get_customer", arguments: { id: "C-1" } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { CachePlugin } from "@frontmcp/plugin-cache"; import { GetCustomer } from "./get-customer.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [GetCustomer], plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })], // ✅ the same answer for everyone }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts get-customer.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { crm } from "./crm"; @Tool({ name: "get_customer", description: "Get a customer account by its id: the company's name and its plan.", inputSchema: { id: z.string().describe("Customer id, like C-1") }, annotations: { readOnlyHint: true }, cache: { ttl: 300 }, }) export class GetCustomer extends ToolContext { async execute({ id }: { id: string }) { return crm.getCustomer(id); } } ``` ```ts crm.ts import { PublicMcpError } from "@frontmcp/sdk"; // Stands in for a CRM that is slow to answer, and counts the requests it gets. const customers: Record = { "C-1": { id: "C-1", name: "Acme Corp", plan: "enterprise" }, "C-2": { id: "C-2", name: "Globex", plan: "starter" }, }; export const crm = { requests: 0, async getCustomer(id: string) { crm.requests++; await new Promise((resolve) => setTimeout(resolve, 200)); const customer = customers[id]; if (!customer) throw new PublicMcpError(`There's no customer ${id}.`); return { ...customer }; }, }; ``` ```ts cache.test.ts import { test, expect } from "@frontmcp/testing"; import { crm } from "./crm"; test("the second call is answered from the cache, at once", async ({ mcp }) => { const before = crm.requests; const first = await mcp.tools.call("get_customer", { id: "C-2" }); const second = await mcp.tools.call("get_customer", { id: "C-2" }); expect(crm.requests - before).toBe(1); expect(second.durationMs).toBeLessThan(150); expect(first.raw._meta?.cache).toBeUndefined(); expect(second.raw._meta?.cache).toBe("hit"); }); test("the data is what the tool returned, marker aside", async ({ mcp }) => { await mcp.tools.call("get_customer", { id: "C-1" }); const hit = await mcp.tools.call("get_customer", { id: "C-1" }); expect(hit.raw._meta?.cache).toBe("hit"); expect(hit.json()).toEqual({ id: "C-1", name: "Acme Corp", plan: "enterprise" }); }); test("a failed call isn't stored", async ({ mcp }) => { const before = crm.requests; expect(await mcp.tools.call("get_customer", { id: "C-9" })).toBeError(); expect(await mcp.tools.call("get_customer", { id: "C-9" })).toBeError(); expect(crm.requests - before).toBe(2); }); ``` Now the first call asks the CRM and the second is answered from the store, without running `execute()`. Every caller shares the entry, so one agent's lookup also saves the next agent's wait. - **A hit is marked, and its data isn't changed.** Its result carries `_meta.cache: "hit"`, next to the data the tool returned. The marker isn't in `structuredContent` or in the text the model reads ([What a cache hit looks like](https://frontmcp.dev/reference/plugins/cache#what-a-cache-hit-looks-like)). - **Only calls that succeed are stored.** A tool that throws stores nothing, so `C-9` is looked up again each time. - **The store is in memory.** `type: "memory"` keeps entries in the server's process: they're lost on restart and not shared between instances. With more than one instance, keep them in Redis instead ([Using Redis](https://frontmcp.dev/reference/plugins/cache#using-redis)). ## Answers that depend on who's asking Sharing is right for a customer's account. It's wrong for `my_open_tickets`, which lists the tickets assigned to the caller. A plugin applies to the tools of the app it's registered on, so tools that need different rules go in different apps, each with its own `CachePlugin.init()`. `my_open_tickets` keeps the default, one entry per caller. The Playground's client is anonymous, which would tell us nothing here, so the tests call the server in-process with [`createDirect()`](https://frontmcp.dev/learn/running-frontmcp-anywhere) as two signed-in support agents, `nour` and `sam`: Initial call configuration: ```js { tool: "get_customer", arguments: { id: "C-1" } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { CachePlugin } from "@frontmcp/plugin-cache"; import { GetCustomer, MyOpenTickets } from "./tools"; @App({ id: "customers", name: "Customers", tools: [GetCustomer], plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })], // the same answer for everyone }) class CustomersApp {} @App({ id: "queue", name: "Queue", tools: [MyOpenTickets], plugins: [CachePlugin.init({ type: "memory" })], // ✅ one entry per caller }) class QueueApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [CustomersApp, QueueApp], }; @FrontMcp(config) export default class Server {} ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { crm, queue } from "./stores"; @Tool({ name: "get_customer", description: "Get a customer account by its id: the company's name and its plan.", inputSchema: { id: z.string().describe("Customer id, like C-1") }, annotations: { readOnlyHint: true }, cache: { ttl: 300 }, }) export class GetCustomer extends ToolContext { async execute({ id }: { id: string }) { return crm.getCustomer(id); } } @Tool({ name: "my_open_tickets", description: "The open support tickets assigned to you.", inputSchema: {}, annotations: { readOnlyHint: true }, cache: { ttl: 60 }, }) export class MyOpenTickets extends ToolContext { async execute() { const agent = this.auth.user.sub; return { agent, tickets: await queue.openTicketsFor(agent) }; } } ``` ```ts stores.ts import { PublicMcpError } from "@frontmcp/sdk"; // Stand in for the CRM and the ticket database, and count the requests they get. const customers: Record = { "C-1": { id: "C-1", name: "Acme Corp", plan: "enterprise" }, "C-2": { id: "C-2", name: "Globex", plan: "starter" }, }; export const crm = { requests: 0, async getCustomer(id: string) { crm.requests++; await new Promise((resolve) => setTimeout(resolve, 200)); const customer = customers[id]; if (!customer) throw new PublicMcpError(`There's no customer ${id}.`); return { ...customer }; }, }; const assigned: Record = { nour: ["T-1", "T-3"], sam: ["T-2"] }; export const queue = { searches: 0, async openTicketsFor(agent: string) { queue.searches++; return assigned[agent] ?? []; }, }; ``` ```ts per-caller.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { CachePlugin } from "@frontmcp/plugin-cache"; import { config } from "./main"; import { queue } from "./stores"; import { MyOpenTickets } from "./tools"; const nour = { authContext: { user: { sub: "nour" } } }; const sam = { authContext: { user: { sub: "sam" } } }; test("each agent gets their own tickets, and their repeats from the cache", async () => { const server = await FrontMcpInstance.createDirect(config); const before = queue.searches; expect((await server.callTool("my_open_tickets", {}, nour)).structuredContent).toEqual({ agent: "nour", tickets: ["T-1", "T-3"] }); expect((await server.callTool("my_open_tickets", {}, sam)).structuredContent).toEqual({ agent: "sam", tickets: ["T-2"] }); const again = await server.callTool("my_open_tickets", {}, nour); expect(again._meta?.cache).toBe("hit"); expect(queue.searches - before).toBe(2); // Nour's second call didn't search await server.dispose(); }); test("🚩 with keyByIdentity: false, Sam is given Nour's tickets", async () => { @App({ id: "queue", name: "Queue", tools: [MyOpenTickets], plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })] }) class SharedQueueApp {} const server = await FrontMcpInstance.createDirect({ ...config, apps: [SharedQueueApp] }); await server.callTool("my_open_tickets", {}, nour); const forSam = await server.callTool("my_open_tickets", {}, sam); expect(forSam._meta?.cache).toBe("hit"); expect(forSam.structuredContent).toMatchObject({ agent: "nour", tickets: ["T-1", "T-3"] }); await server.dispose(); }); ``` Each agent's first call searches, and their repeat is answered from their own entry. With the whole app on `keyByIdentity: false`, the second test shows what goes wrong: Sam asks for his tickets and gets Nour's, marked as a cache hit. With `keyByIdentity` on, the cache tells callers apart by what [authentication](https://frontmcp.dev/learn/authenticating-clients) established: | Caller | Entries | | --- | --- | | Signed in through an identity provider (`transparent`, `local` or `remote`) | One set per user, by the token's `sub`, shared by all of that user's sessions and devices | | A `static` key | One set per key | | Anonymous, under MCP 2026-07-28 | None that are ever read back: a new caller on every request | | Anonymous, on a session-based client (before 2026-07-28) | One set per session | > **Pitfall: keyByIdentity: false gives one caller's answer to everyone** > > With `keyByIdentity: false`, the first caller's result is served to every later caller with the same arguments until it expires. If a tool reads `this.auth`, or its answer depends on the caller's tenant, account or permissions, the next caller gets the first caller's data. Share only answers that are the same for everyone, and put those tools in an app of their own. ## What makes two calls the same The cache looks an answer up by a key made of three things: the caller (unless `keyByIdentity` is `false`), the tool as `:`, and the call's arguments **after** the input schema has validated them. That last part decides how often a model's calls hit. `search_articles` searches the help center, whose articles are the same for everyone: Initial call configuration: ```js { tool: "search_articles", arguments: { query: "password reset" } } ``` ```ts search-articles.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { helpCenter } from "./help-center"; @Tool({ name: "search_articles", description: "Search the help center's articles. Returns the best matches, most relevant first.", inputSchema: { query: z.string().trim().toLowerCase().describe("What to search for, like 'password reset'"), limit: z.number().int().min(1).max(10).default(5).describe("How many articles to return"), }, annotations: { readOnlyHint: true }, cache: { ttl: 600 }, }) export class SearchArticles extends ToolContext { async execute({ query, limit }: { query: string; limit: number }) { return { articles: await helpCenter.search(query, limit) }; } } ``` ```ts help-center.ts // Stands in for the help center's search, and counts the searches it runs. const articles = [ { slug: "reset-your-password", title: "Reset your password" }, { slug: "password-rules", title: "Password rules for your team" }, { slug: "export-tickets", title: "Export tickets as CSV" }, ]; export const helpCenter = { searches: 0, async search(query: string, limit: number) { helpCenter.searches++; const words = query.split(/\s+/); return articles.filter((a) => words.some((w) => a.title.toLowerCase().includes(w))).slice(0, limit); }, }; ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { CachePlugin } from "@frontmcp/plugin-cache"; import { SearchArticles } from "./search-articles.tool"; @App({ id: "help-center", name: "Help Center", tools: [SearchArticles], plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })], }) class HelpCenterApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpCenterApp], }) export default class Server {} ``` ```ts key.test.ts import { test, expect } from "@frontmcp/testing"; import { helpCenter } from "./help-center"; /** How many of these calls reached the help center. */ async function searchesFor(mcp: any, ...calls: object[]) { const before = helpCenter.searches; for (const args of calls) await mcp.tools.call("search_articles", args); return helpCenter.searches - before; } test("the order of the arguments doesn't matter", async ({ mcp }) => { expect(await searchesFor(mcp, { query: "export", limit: 3 }, { limit: 3, query: "export" })).toBe(1); }); test("the schema's transforms run first", async ({ mcp }) => { expect(await searchesFor(mcp, { query: "rules" }, { query: " Rules " }, { query: "RULES" })).toBe(1); }); test("a default counts as if the model had sent it", async ({ mcp }) => { expect(await searchesFor(mcp, { query: "team" }, { query: "team", limit: 5 })).toBe(1); }); test("arguments the schema doesn't declare don't count", async ({ mcp }) => { expect(await searchesFor(mcp, { query: "csv" }, { query: "csv", locale: "fr" })).toBe(1); }); test("any other value is another entry", async ({ mcp }) => { expect(await searchesFor(mcp, { query: "password", limit: 1 }, { query: "password", limit: 2 })).toBe(2); }); ``` The key is built from what `execute()` receives, not from what the model sent. Object keys are sorted, arguments the schema doesn't declare are dropped, defaults are filled in, and transforms like `trim()` and `toLowerCase()` have already run. So a schema that normalizes its input also makes the cache hit more often: `" Rules "` and `"RULES"` are the same search as `"rules"`. Anything left that differs, even `limit: 1` against `limit: 2`, is another entry. ## How long an entry lives An entry lives for its `ttl`, and then the next call runs the tool again. The plugin has no way to delete an entry sooner. Here `update_plan` changes a customer's plan in the CRM, and `get_customer` keeps giving the old one. The tests move the clock forward by replacing `Date.now()`, which is what the cache's memory store reads: Initial call configuration: ```js { tool: "get_customer", arguments: { id: "C-2" } } ``` ```ts tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { crm } from "./crm"; @Tool({ name: "get_customer", description: "Get a customer account by its id: the company's name and its plan. May be up to 5 minutes old.", inputSchema: { id: z.string().describe("Customer id, like C-1") }, annotations: { readOnlyHint: true }, cache: { ttl: 300 }, }) export class GetCustomer extends ToolContext { async execute({ id }: { id: string }) { return crm.getCustomer(id); } } @Tool({ name: "update_plan", description: "Change a customer's plan.", inputSchema: { id: z.string(), plan: z.enum(["starter", "business", "enterprise"]) }, }) export class UpdatePlan extends ToolContext { async execute({ id, plan }: { id: string; plan: "starter" | "business" | "enterprise" }) { return crm.setPlan(id, plan); } } @Tool({ name: "get_sla_policy", description: "The help desk's response times for each plan.", inputSchema: {}, annotations: { readOnlyHint: true }, cache: true, // 🚩 kept for a day: the plugin's defaultTTL }) export class GetSlaPolicy extends ToolContext { async execute() { crm.requests++; return { starter: "2 days", business: "8 hours", enterprise: "1 hour" }; } } ``` ```ts crm.ts import { PublicMcpError } from "@frontmcp/sdk"; // Stands in for a CRM that is slow to answer, and counts the requests it gets. const customers: Record = { "C-1": { id: "C-1", name: "Acme Corp", plan: "enterprise" }, "C-2": { id: "C-2", name: "Globex", plan: "starter" }, }; export const crm = { requests: 0, async getCustomer(id: string) { crm.requests++; await new Promise((resolve) => setTimeout(resolve, 200)); const customer = customers[id]; if (!customer) throw new PublicMcpError(`There's no customer ${id}.`); return { ...customer }; }, async setPlan(id: string, plan: string) { const customer = customers[id]; if (!customer) throw new PublicMcpError(`There's no customer ${id}.`); customer.plan = plan; return { ...customer }; }, }; ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { CachePlugin } from "@frontmcp/plugin-cache"; import { GetCustomer, GetSlaPolicy, UpdatePlan } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [GetCustomer, UpdatePlan, GetSlaPolicy], plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })], }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts ttl.test.ts import { test, expect } from "@frontmcp/testing"; import { crm } from "./crm"; /** Runs `fn` with the clock `seconds` ahead. */ async function inSeconds(seconds: number, fn: () => Promise) { const realNow = Date.now; const now = realNow(); Date.now = () => now + seconds * 1000; try { return await fn(); } finally { Date.now = realNow; } } test("🚩 a change doesn't reach the cached answer", async ({ mcp }) => { await mcp.tools.call("get_customer", { id: "C-2" }); // stored: starter await mcp.tools.call("update_plan", { id: "C-2", plan: "business" }); const cached = await mcp.tools.call("get_customer", { id: "C-2" }); expect(cached.raw._meta?.cache).toBe("hit"); expect(cached.json().plan).toBe("starter"); }); test("once the ttl has passed, the tool runs again", async ({ mcp }) => { const fresh = await inSeconds(301, () => mcp.tools.call("get_customer", { id: "C-2" })); expect(fresh.raw._meta?.cache).toBeUndefined(); expect(fresh.json().plan).toBe("business"); }); test("cache: true keeps the first answer for a day, however often it's read", async ({ mcp }) => { const before = crm.requests; const day = 24 * 60 * 60; await mcp.tools.call("get_sla_policy", {}); const lastMinute = await inSeconds(day - 60, () => mcp.tools.call("get_sla_policy", {})); expect(lastMinute.raw._meta?.cache).toBe("hit"); const nextDay = await inSeconds(day + 60, () => mcp.tools.call("get_sla_policy", {})); expect(nextDay.raw._meta?.cache).toBeUndefined(); expect(crm.requests - before).toBe(2); }); ``` The model changed the plan, and the next lookup still says `starter`: the model is told two different things in a row. A client can skip the cache for one call by sending the header `x-frontmcp-disable-cache: true`, but that call's fresh answer isn't stored, and the old entry stays for everyone else ([Letting a client skip the cache](https://frontmcp.dev/reference/plugins/cache#letting-a-client-skip-the-cache)). Short of restarting a server whose store is in memory, which empties it, nothing clears an entry early. So choose `ttl` as the oldest answer you're willing to give: - **Say it in the description.** "May be up to 5 minutes old" lets the model tell the user, or tell them a change can take a few minutes to show. - **Keep it short, or don't cache, for data your own tools change.** If `update_plan` is used often, a five-minute-old plan will confuse the model more than a slow lookup. - **Give every cached tool an explicit `ttl`.** `cache: true` uses the plugin's `defaultTTL`, a day unless you set it, so a change can take a day to reach the model. The last test shows `get_sla_policy` answering from its first call until the day is over, however often it's read. (Before FrontMCP 1.9, each read gave the entry a full day again, so an answer read daily was never refreshed. Reading an entry now keeps it alive only with `slideWindow: true`: see [the reference](https://frontmcp.dev/reference/plugins/cache#cache-in-tool).) ## What not to cache A cached call doesn't run `execute()`. For a tool that only reads, that's the point. For a tool that does something, it means the second call doesn't do it: Initial call configuration: ```js { tool: "add_note", arguments: { ticketId: "T-1", text: "Called the customer back." } } ``` ```ts add-note.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; export const notes: string[] = []; @Tool({ name: "add_note", description: "Add an internal note to a support ticket.", inputSchema: { ticketId: z.string(), text: z.string() }, cache: { ttl: 300 }, // 🚩 on a tool that changes something }) export class AddNote extends ToolContext { async execute({ ticketId, text }: { ticketId: string; text: string }) { notes.push(`${ticketId}: ${text}`); return { ticketId, added: true }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { CachePlugin } from "@frontmcp/plugin-cache"; import { AddNote } from "./add-note.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [AddNote], plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })], }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts notes.test.ts import { test, expect } from "@frontmcp/testing"; import { notes } from "./add-note.tool"; test("🚩 the second note is never added, and the model is told it was", async ({ mcp }) => { const before = notes.length; const args = { ticketId: "T-2", text: "Customer tried again, still failing." }; await mcp.tools.call("add_note", args); const second = await mcp.tools.call("add_note", args); expect(second.json()).toMatchObject({ ticketId: "T-2", added: true }); expect(notes.length - before).toBe(1); }); ``` An agent really may add the same note twice, after the customer writes in again. The second call answers "added" from the cache, and nothing is added. Leave these out of the cache: | Don't cache | Because | | --- | --- | | Tools that change something: `add_note`, `close_ticket`, `refund_invoice` | A hit skips the change and reports success. Cache only tools that just read, the ones you'd mark `readOnlyHint: true`. | | Answers that depend on the caller, in an app with `keyByIdentity: false` | The first caller's answer goes to everyone. Keep them per caller, behind authentication. | | Answers the model acts on right away | A ticket's status just before closing it, a balance just before refunding it: a few minutes old can be wrong. | ## Recap - `@frontmcp/plugin-cache` answers a repeated call from a store without running `execute()`. Register `CachePlugin.init({ type: "memory" })` on the app, and mark each tool with `cache: { ttl }`, in seconds. - By default, each caller has their own entries. Anonymous callers under MCP 2026-07-28 are new on every request, so they're never answered from the cache. - `keyByIdentity: false` shares entries between callers. Use it only for answers that are the same for everyone, in an app of their own; a plugin covers the tools of the app it's registered on. - Two calls are the same when the tool and the validated arguments match: key order, undeclared arguments and defaults don't matter, and the schema's transforms have run. - An entry lives until its `ttl` ends, and the plugin can't clear it sooner. Choose `ttl` as the oldest answer you'll accept, and say so in the description. - Cache only tools that read. Failed calls aren't stored, whether the tool throws or returns an error result. - Every option, the Redis stores and the bypass header are in the [Cache plugin reference](https://frontmcp.dev/reference/plugins/cache). ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Cache the article lookup `get_article` reads an article from the help center, which is slow, and the model reads the same few articles over and over. Answer repeated lookups from a cache for 10 minutes. Articles are the same for everyone who asks. Initial call configuration: ```js { tool: "get_article", arguments: { slug: "reset-your-password" } } ``` ```ts main.ts active import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; import { helpCenter } from "./help-center"; @Tool({ name: "get_article", description: "Read a help center article by its slug.", inputSchema: { slug: z.string().describe("The article's slug, like reset-your-password") }, annotations: { readOnlyHint: true }, }) class GetArticle extends ToolContext { async execute({ slug }: { slug: string }) { return helpCenter.getArticle(slug); } } @App({ id: "help-center", name: "Help Center", tools: [GetArticle] }) class HelpCenterApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpCenterApp], }) export default class Server {} ``` **Solution file (alternative to the starter):** ```ts main.ts solution import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; import { CachePlugin } from "@frontmcp/plugin-cache"; import { helpCenter } from "./help-center"; @Tool({ name: "get_article", description: "Read a help center article by its slug. May be up to 10 minutes old.", inputSchema: { slug: z.string().describe("The article's slug, like reset-your-password") }, annotations: { readOnlyHint: true }, cache: { ttl: 600 }, }) class GetArticle extends ToolContext { async execute({ slug }: { slug: string }) { return helpCenter.getArticle(slug); } } @App({ id: "help-center", name: "Help Center", tools: [GetArticle], plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })], }) class HelpCenterApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpCenterApp], }) export default class Server {} ``` ```ts help-center.ts import { PublicMcpError } from "@frontmcp/sdk"; // Stands in for a slow help center, and counts the requests it gets. const articles: Record = { "reset-your-password": { slug: "reset-your-password", title: "Reset your password", body: "Open the sign-in page and choose Forgot password." }, "export-tickets": { slug: "export-tickets", title: "Export tickets as CSV", body: "Open Reports, then choose Export." }, }; export const helpCenter = { requests: 0, async getArticle(slug: string) { helpCenter.requests++; await new Promise((resolve) => setTimeout(resolve, 200)); const article = articles[slug]; if (!article) throw new PublicMcpError(`There's no article ${slug}.`); return { ...article }; }, }; ``` **Hidden fixture/test file (runs with the example):** ```ts article.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { helpCenter } from "./help-center"; test("a repeated lookup doesn't reach the help center", async ({ mcp }) => { const before = helpCenter.requests; const first = await mcp.tools.call("get_article", { slug: "export-tickets" }); const second = await mcp.tools.call("get_article", { slug: "export-tickets" }); expect(second.json().title).toBe(first.json().title); expect(helpCenter.requests - before).toBe(1); }); test("the repeat is marked as a cache hit", async ({ mcp }) => { await mcp.tools.call("get_article", { slug: "reset-your-password" }); const second = await mcp.tools.call("get_article", { slug: "reset-your-password" }); expect(second.raw._meta?.cache).toBe("hit"); }); test("each article is looked up for itself", async ({ mcp }) => { expect((await mcp.tools.call("get_article", { slug: "export-tickets" })).json().title).toBe("Export tickets as CSV"); expect((await mcp.tools.call("get_article", { slug: "reset-your-password" })).json().title).toBe("Reset your password"); }); test("a missing article is still an error", async ({ mcp }) => { expect(await mcp.tools.call("get_article", { slug: "no-such-article" })).toBeError(); }); ``` **Hint:** It takes a change to the app and one to the tool. Then think about who the Playground's caller is: the checks call the tool the way the Playground does, without signing in. **Solution:** `CachePlugin.init({ type: "memory", keyByIdentity: false })` on the app and `cache: { ttl: 600 }` on the tool turn the cache on for `get_article`. `keyByIdentity: false` is what makes it work for the Playground's caller: with the default, each anonymous request is a new caller with its own entries, so nothing is ever read back. Sharing is safe because an article doesn't depend on who reads it. The missing article throws, so it isn't stored, and the description now says how old an article may be. ### Challenge: Keep each agent's queue their own The help desk caches everything in one app with `keyByIdentity: false`. That's right for `get_article`, and wrong for `my_open_tickets`: Sam gets whichever agent's tickets were cached first. Keep each agent's tickets their own, still answered from the cache when an agent asks again, and keep articles shared. Initial call configuration: ```js { tool: "get_article", arguments: { slug: "export-tickets" } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { CachePlugin } from "@frontmcp/plugin-cache"; import { GetArticle, MyOpenTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [GetArticle, MyOpenTickets], plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })], }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }; @FrontMcp(config) export default class Server {} ``` **Solution file (alternative to the starter):** ```ts main.ts solution import { App, FrontMcp } from "@frontmcp/sdk"; import { CachePlugin } from "@frontmcp/plugin-cache"; import { GetArticle, MyOpenTickets } from "./tools"; @App({ id: "help-center", name: "Help Center", tools: [GetArticle], plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })], // the same for everyone }) class HelpCenterApp {} @App({ id: "queue", name: "Queue", tools: [MyOpenTickets], plugins: [CachePlugin.init({ type: "memory" })], // one entry per caller }) class QueueApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpCenterApp, QueueApp], }; @FrontMcp(config) export default class Server {} ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; export const counts = { articleReads: 0, ticketSearches: 0 }; const assigned: Record = { nour: ["T-1", "T-3"], sam: ["T-2"] }; @Tool({ name: "get_article", description: "Read a help center article by its slug.", inputSchema: { slug: z.string() }, annotations: { readOnlyHint: true }, cache: { ttl: 600 }, }) export class GetArticle extends ToolContext { async execute({ slug }: { slug: string }) { counts.articleReads++; return { slug, title: slug === "export-tickets" ? "Export tickets as CSV" : "Reset your password" }; } } @Tool({ name: "my_open_tickets", description: "The open support tickets assigned to you.", inputSchema: {}, annotations: { readOnlyHint: true }, cache: { ttl: 60 }, }) export class MyOpenTickets extends ToolContext { async execute() { counts.ticketSearches++; const agent = this.auth.user.sub; return { agent, tickets: assigned[agent] ?? [] }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts queue.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; import { counts } from "./tools"; const nour = { authContext: { user: { sub: "nour" } } }; const sam = { authContext: { user: { sub: "sam" } } }; test("each agent gets their own open tickets", async () => { const server = await FrontMcpInstance.createDirect(config); expect((await server.callTool("my_open_tickets", {}, nour)).structuredContent).toEqual({ agent: "nour", tickets: ["T-1", "T-3"] }); expect((await server.callTool("my_open_tickets", {}, sam)).structuredContent).toEqual({ agent: "sam", tickets: ["T-2"] }); await server.dispose(); }); test("an agent's repeat is answered from the cache", async () => { const server = await FrontMcpInstance.createDirect(config); await server.callTool("my_open_tickets", {}, sam); const before = counts.ticketSearches; const again = await server.callTool("my_open_tickets", {}, sam); expect(again._meta?.cache).toBe("hit"); expect(counts.ticketSearches).toBe(before); await server.dispose(); }); test("articles are still shared between callers", async ({ mcp }) => { await mcp.tools.call("get_article", { slug: "reset-your-password" }); const before = counts.articleReads; const second = await mcp.tools.call("get_article", { slug: "reset-your-password" }); expect(second.raw._meta?.cache).toBe("hit"); expect(counts.articleReads).toBe(before); }); ``` **Hint:** `keyByIdentity` is an option of the plugin, not of a tool, and a plugin registered on an app applies to that app's tools only. **Solution:** `get_article` and `my_open_tickets` need different rules, so they go in different apps, each with its own `CachePlugin.init()`: the help center app shares its entries with `keyByIdentity: false`, and the queue app keeps the default, one entry per caller. The checks call `my_open_tickets` as `nour` and `sam`, who each get their own tickets, and whose repeats are still hits. Removing `cache` from `my_open_tickets` would also keep the tickets apart, but every call would search again. ### Challenge: Refresh the SLA policy every hour `get_sla_policy` is cached with `cache: true`, so a change to the help desk's response times can take a day to reach the model. Make the policy at most an hour old, and keep answering repeats within the hour from the cache. Initial call configuration: ```js { tool: "get_sla_policy", arguments: {} } ``` ```ts get-sla-policy.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; import { policies } from "./policies"; @Tool({ name: "get_sla_policy", description: "The help desk's response times for each plan.", inputSchema: {}, annotations: { readOnlyHint: true }, cache: true, }) export class GetSlaPolicy extends ToolContext { async execute() { return policies.read(); } } ``` **Solution file (alternative to the starter):** ```ts get-sla-policy.tool.ts solution import { Tool, ToolContext } from "@frontmcp/sdk"; import { policies } from "./policies"; @Tool({ name: "get_sla_policy", description: "The help desk's response times for each plan. May be up to an hour old.", inputSchema: {}, annotations: { readOnlyHint: true }, cache: { ttl: 3600 }, }) export class GetSlaPolicy extends ToolContext { async execute() { return policies.read(); } } ``` ```ts policies.ts // Stands in for the help desk's settings, and counts the reads. export const policies = { reads: 0, current: { starter: "2 days", business: "8 hours", enterprise: "1 hour" }, async read() { policies.reads++; return { ...policies.current }; }, }; ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { CachePlugin } from "@frontmcp/plugin-cache"; import { GetSlaPolicy } from "./get-sla-policy.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [GetSlaPolicy], plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })], }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], }) export default class Server {} ``` **Hidden fixture/test file (runs with the example):** ```ts freshness.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { policies } from "./policies"; async function inSeconds(seconds: number, fn: () => Promise) { const realNow = Date.now; const now = realNow(); Date.now = () => now + seconds * 1000; try { return await fn(); } finally { Date.now = realNow; } } test("a repeat within the hour is answered from the cache", async ({ mcp }) => { await mcp.tools.call("get_sla_policy", {}); const before = policies.reads; const again = await inSeconds(30 * 60, () => mcp.tools.call("get_sla_policy", {})); expect(again.raw._meta?.cache).toBe("hit"); expect(policies.reads).toBe(before); }); test("a change reaches the model within the hour", async ({ mcp }) => { await mcp.tools.call("get_sla_policy", {}); policies.current = { ...policies.current, business: "4 hours" }; const later = await inSeconds(60 * 60 + 60, () => mcp.tools.call("get_sla_policy", {})); expect(later.json().business).toBe("4 hours"); }); ``` **Hint:** `cache: true` takes the plugin's `defaultTTL`, which is a day unless you set it. A tool can say how long its own entries live. **Solution:** `cache: { ttl: 3600 }` keeps each answer for an hour from the call that stored it, however often it's read in between, so the change reaches the model on the first call after the hour. The description says so, which lets the model tell the user that a change can take an hour to show. Setting `defaultTTL: 3600` on the plugin passes the checks too, but changes every `cache: true` tool in the app. --- # Remembering Across Calls > Keep values between FrontMCP tool calls with @frontmcp/plugin-remember and this.remember: scopes, signed-in and anonymous callers, expiry, and memory tools. Source: https://frontmcp.dev/learn/remembering-across-calls [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A tool forgets everything when its call ends. A support agent tells the assistant to sign replies "Nour, Tier 2 support", and on the next call the signature is gone. A variable at the top of the file remembers it, but for every caller at once, and only until the server restarts. The Remember plugin, `@frontmcp/plugin-remember`, gives every FrontMCP tool `this.remember`, a small key-value memory. Each value is stored in a **scope**, which decides who can read it back. Under MCP 2026-07-28, what a scope keeps depends on whether the caller signed in, so this lesson spends as much time on *who* as on *what*. **You will learn** - How to store and read values with `this.remember` - Which scope to put a value in, and who can read it back - What lasts for a signed-in caller, and what an anonymous caller keeps, under MCP 2026-07-28 - How to make a value expire, and how to forget it - How to let the model remember things itself, and limit what it can store ## A preference in a module variable `set_signature` saves the signature an agent's replies end with, and `draft_reply` signs a reply with it. The signature lives in a variable at the top of the file. To see whose signature ends up where, the tests need two callers who are signed in. The Playground's own client never signs in, so `server.ts` gives two support agents, Nour and Sam, a [static key](https://frontmcp.dev/learn/authenticating-clients#requiring-a-shared-key-static-mode) each, and `callAs()` in `call-as.ts` sends a tool call with a key, the way an MCP 2026-07-28 client would. Every call goes to the same server. Open the **Tests** tab: Initial call configuration: ```js { tool: "draft_reply", arguments: { ticketId: "T-1", text: "You can log in again." } } ``` ```ts signature.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; // 🚩 One signature for the whole server let signature = "The Help Desk"; @Tool({ name: "set_signature", description: "Set the signature your replies end with.", inputSchema: { signature: z.string().max(80) }, }) export class SetSignature extends ToolContext { async execute(input: { signature: string }) { signature = input.signature; return { signature }; } } @Tool({ name: "draft_reply", description: "Draft a reply to a support ticket, signed with your signature.", inputSchema: { ticketId: z.string(), text: z.string() }, }) export class DraftReply extends ToolContext { async execute({ ticketId, text }: { ticketId: string; text: string }) { return { ticketId, reply: `${text}\n\n${signature}` }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SetSignature, DraftReply] }) export class HelpDeskApp {} ``` ```ts server.ts import { HelpDeskApp } from "./signature.app"; // Two support agents, each with a key of their own. // In your project: @FrontMcp(config) export default class Server {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] }, }; ``` ```ts call-as.ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./server"; let server: ReturnType | undefined; /** Sends one tools/call with this key, as an MCP 2026-07-28 client would. Every call goes to the same server. */ export async function callAs(key: string, tool: string, args: Record = {}) { server ??= FrontMcpInstance.createFetchHandler(config); const response = await (await server)( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": tool, authorization: `Bearer ${key}`, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return (await response.json()).result; } ``` ```ts signature.test.ts import { test, expect } from "@frontmcp/testing"; import { callAs } from "./call-as"; const NOUR = "agent-nour-key"; const SAM = "agent-sam-key"; test("the signature is there on the next call", async () => { await callAs(NOUR, "set_signature", { signature: "Nour, Tier 2 support" }); const draft = await callAs(NOUR, "draft_reply", { ticketId: "T-1", text: "You can log in again." }); expect(draft.structuredContent.reply).toBe("You can log in again.\n\nNour, Tier 2 support"); }); test("🚩 Sam's replies are signed with Nour's signature", async () => { await callAs(NOUR, "set_signature", { signature: "Nour, Tier 2 support" }); const draft = await callAs(SAM, "draft_reply", { ticketId: "T-2", text: "Your invoice is fixed." }); expect(draft.structuredContent.reply).toContain("Nour, Tier 2 support"); }); ``` The variable remembers, and that's the trouble: it belongs to the file, so every caller shares it. When Nour sets her signature, Sam's replies are signed with it too. It's also lost when the server restarts, and each instance of the server has its own. [Sharing State with Providers](https://frontmcp.dev/learn/sharing-state-with-providers#state-in-a-module-variable) covers what else goes wrong with module variables. What this tool needs is a place that keeps a value for one agent, across calls. ## Giving tools a memory Install the plugin: ```bash npm install @frontmcp/plugin-remember ``` Register it on the app with `RememberPlugin.init({ type: "memory" })`. Every tool then has `this.remember`. `set(key, value, { scope })` stores a value, and `get(key, { scope, defaultValue })` reads it back. `scope: "user"` keeps a value for the caller: Initial call configuration: ```js { tool: "draft_reply", arguments: { ticketId: "T-1", text: "You can log in again." } } ``` ```ts signature.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { RememberPlugin } from "@frontmcp/plugin-remember"; @Tool({ name: "set_signature", description: "Set the signature your replies end with.", inputSchema: { signature: z.string().max(80) }, }) export class SetSignature extends ToolContext { async execute({ signature }: { signature: string }) { await this.remember.set("signature", signature, { scope: "user" }); // ✅ this caller's return { signature }; } } @Tool({ name: "draft_reply", description: "Draft a reply to a support ticket, signed with your signature.", inputSchema: { ticketId: z.string(), text: z.string() }, }) export class DraftReply extends ToolContext { async execute({ ticketId, text }: { ticketId: string; text: string }) { const signature = await this.remember.get("signature", { scope: "user", defaultValue: "The Help Desk" }); return { ticketId, reply: `${text}\n\n${signature}` }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SetSignature, DraftReply], plugins: [RememberPlugin.init({ type: "memory" })], }) export class HelpDeskApp {} ``` ```ts server.ts import { HelpDeskApp } from "./signature.app"; // Two support agents, each with a key of their own. // In your project: @FrontMcp(config) export default class Server {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] }, }; ``` ```ts call-as.ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./server"; let server: ReturnType | undefined; /** Sends one tools/call with this key, as an MCP 2026-07-28 client would. Every call goes to the same server. */ export async function callAs(key: string, tool: string, args: Record = {}) { server ??= FrontMcpInstance.createFetchHandler(config); const response = await (await server)( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": tool, authorization: `Bearer ${key}`, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return (await response.json()).result; } ``` ```ts signature.test.ts import { test, expect } from "@frontmcp/testing"; import { callAs } from "./call-as"; const NOUR = "agent-nour-key"; const SAM = "agent-sam-key"; test("each agent's signature is their own", async () => { await callAs(NOUR, "set_signature", { signature: "Nour, Tier 2 support" }); const nours = await callAs(NOUR, "draft_reply", { ticketId: "T-1", text: "You can log in again." }); const sams = await callAs(SAM, "draft_reply", { ticketId: "T-2", text: "Your invoice is fixed." }); expect(nours.structuredContent.reply).toBe("You can log in again.\n\nNour, Tier 2 support"); expect(sams.structuredContent.reply).toBe("Your invoice is fixed.\n\nThe Help Desk"); }); test("setting Sam's doesn't change Nour's", async () => { await callAs(SAM, "set_signature", { signature: "Sam, Billing" }); const nours = await callAs(NOUR, "draft_reply", { ticketId: "T-1", text: "Done." }); expect(nours.structuredContent.reply).toBe("Done.\n\nNour, Tier 2 support"); }); test("an anonymous caller can't keep a signature", async ({ mcp }) => { // The Playground's own server, whose callers don't sign in const result = await mcp.tools.call("set_signature", { signature: "Someone" }); expect(result).toBeError("REMEMBER_IDENTITY_REQUIRED"); expect(result.text()).toContain("Remember cannot use user scope without an authenticated user"); }); ``` Now Nour's signature is Nour's, Sam gets the default until he sets his own, and each is there on the agent's next request. The last test calls the Playground's own server, whose client doesn't sign in: `user` scope refuses it, and so does the call in the Call tab. `user` scope keeps values for `this.remember.userId`, the caller the server worked out from the request: - **A signed-in user**, with a token from an identity provider, is the token's `sub`. Their values follow them across sessions, conversations and clients. - **A static key** is `static:` and a hash of the key. Everyone who uses the key shares its values. - **An anonymous caller** has no identity to keep values under: under MCP 2026-07-28 it's a new `anon:` id on every request, and `user` scope refuses it. `this.remember` throws a `RememberIdentityError`, which reaches the client as its message, with the code `REMEMBER_IDENTITY_REQUIRED`. So a memory that belongs to a person needs the person to sign in. [Authenticating Clients](https://frontmcp.dev/learn/authenticating-clients) covers how. > **Note** > > Changed in 1.8.6: `user` scope used to accept an anonymous caller. Its `anon:` id is new on every request, so `set()` stored the value where nothing looks again, and `get()` found nothing, without an error. Now it's refused, like `session` scope. > **Note** > > `RememberPlugin.init()` with no argument, or `plugins: [RememberPlugin]` without `init()`, keeps values in memory. (Before FrontMCP 1.9.4, the class without `init()` left tools without `this.remember`.) Registered on one app, the plugin gives `this.remember` to that app's tools only: register it on `@FrontMcp` for every app. (Before FrontMCP 1.9, it reached every app's tools, which read and wrote the same values: [Caveats](https://frontmcp.dev/reference/plugins/remember#caveats).) ## Choosing a scope Every `this.remember` method takes a `scope`, and without one it's `"session"`. Under MCP 2026-07-28, where a client sends each request on its own and there are no sessions, the four scopes keep: | Scope | A value belongs to | Signed-in caller | Anonymous caller | | --- | --- | --- | --- | | `user` | The caller | Kept, across requests | Refused | | `session` (the default) | The caller, apart from their `user` values | Kept, across requests | Refused | | `tool` | The caller, and the tool that stored it | Kept, across requests, for that tool only | Refused | | `global` | Everyone | Kept, and shared | Kept, and shared | A session-based client, on a protocol version before 2026-07-28, keeps `session` and `tool` values for its session instead ([Scopes](https://frontmcp.dev/reference/plugins/remember#scopes)). This help desk uses `global` for a banner every caller sees during an outage, and `session` for the ticket an agent is working on: Initial call configuration: ```js { tool: "get_banner", arguments: {} } ``` ```ts desk.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { RememberPlugin } from "@frontmcp/plugin-remember"; @Tool({ name: "set_banner", description: "Show a banner to everyone, like an outage notice.", inputSchema: { text: z.string() } }) export class SetBanner extends ToolContext { async execute({ text }: { text: string }) { await this.remember.set("banner", text, { scope: "global" }); return { banner: text }; } } @Tool({ name: "get_banner", description: "The banner everyone sees, if there is one.", inputSchema: {} }) export class GetBanner extends ToolContext { async execute() { return { banner: await this.remember.get("banner", { scope: "global", defaultValue: null }) }; } } @Tool({ name: "work_on", description: "Mark a ticket as the one you're working on.", inputSchema: { ticketId: z.string() } }) export class WorkOn extends ToolContext { async execute({ ticketId }: { ticketId: string }) { await this.remember.set("ticket", ticketId); // session scope, the default return { ticketId }; } } @Tool({ name: "current_ticket", description: "The ticket you're working on.", inputSchema: {} }) export class CurrentTicket extends ToolContext { async execute() { return { ticketId: await this.remember.get("ticket", { defaultValue: null }) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SetBanner, GetBanner, WorkOn, CurrentTicket], plugins: [RememberPlugin.init({ type: "memory" })], }) export class HelpDeskApp {} ``` ```ts server.ts import { HelpDeskApp } from "./desk.app"; // Two support agents, each with a key of their own. // In your project: @FrontMcp(config) export default class Server {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] }, }; ``` ```ts call-as.ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./server"; let server: ReturnType | undefined; /** Sends one tools/call with this key, as an MCP 2026-07-28 client would. Every call goes to the same server. */ export async function callAs(key: string, tool: string, args: Record = {}) { server ??= FrontMcpInstance.createFetchHandler(config); const response = await (await server)( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": tool, authorization: `Bearer ${key}`, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return (await response.json()).result; } ``` ```ts scopes.test.ts import { test, expect } from "@frontmcp/testing"; import { callAs } from "./call-as"; const NOUR = "agent-nour-key"; const SAM = "agent-sam-key"; test("global: every signed-in agent reads the banner", async () => { await callAs(NOUR, "set_banner", { text: "Login is down. We're on it." }); expect((await callAs(SAM, "get_banner")).structuredContent).toEqual({ banner: "Login is down. We're on it." }); }); test("global: an anonymous caller can set and read one too", async ({ mcp }) => { await mcp.tools.call("set_banner", { text: "Exports are slow today." }); expect((await mcp.tools.call("get_banner", {})).json()).toEqual({ banner: "Exports are slow today." }); }); test("session: kept for a signed-in agent across requests, and only for them", async () => { await callAs(NOUR, "work_on", { ticketId: "T-3" }); expect((await callAs(NOUR, "current_ticket")).structuredContent).toEqual({ ticketId: "T-3" }); expect((await callAs(SAM, "current_ticket")).structuredContent).toEqual({ ticketId: null }); }); test("session: an anonymous caller is refused", async ({ mcp }) => { const result = await mcp.tools.call("work_on", { ticketId: "T-1" }); expect(result).toBeError("REMEMBER_IDENTITY_REQUIRED"); expect(result.text()).toContain("Remember cannot use session or tool scope for an unauthenticated request"); }); ``` The banner is in `global` scope, so Sam reads what Nour set, and a caller who isn't signed in can set and read a banner too. (The Playground's own server and the one `callAs()` starts are two servers, and each has its own memory: the anonymous caller's banner is a different one from Nour's.) The ticket is in `session` scope, the default: it lasts for Nour across her requests, Sam doesn't see it, and a caller who isn't signed in can't use `session` scope, or `user`, at all. `this.remember` throws a `RememberIdentityError`, and the call fails with the code `REMEMBER_IDENTITY_REQUIRED`. The error's message says what to do: authenticate the request, or use `global` if the value really is shared. > **Pitfall: Under 2026-07-28, session doesn't mean a conversation** > > The name suggests a value that ends with the conversation. MCP 2026-07-28 has no sessions, so FrontMCP keeps `session` and `tool` values for the signed-in caller instead: Nour's current ticket is still there tomorrow, in every conversation and every client she uses, until it's forgotten or expires. Give conversation-sized values a `ttl` (next section), put what belongs to a person in `user` scope, and remember that an anonymous caller can use only `global`. Which scope, then: - **`user`** for what belongs to a person: a signature, a language, the columns they like in a report. - **`global`** for what everyone may see: a banner, a shared counter, the last time a sync ran. Never for anything personal, because every caller reads it. - **`session`** for what a signed-in caller is in the middle of, with a `ttl`, knowing it lasts across their conversations. - **`tool`** for a tool's own bookkeeping that no other tool should read or change, like which tickets `next_ticket` already suggested to this agent. ## How long a value lives A value lives until it's forgotten, unless you give it a `ttl`, in seconds, when you store it, or the plugin has a `defaultTTL`, which `init()` takes for values stored without one. A reply draft should be there tomorrow morning, but not next month. `save_draft` keeps a draft for a day, and `send_reply` forgets it once it's sent. The tests move the clock forward by replacing `Date.now()`, which is what the plugin and its memory store read: ```ts drafts.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { RememberPlugin } from "@frontmcp/plugin-remember"; const input = { ticketId: z.string().describe("Ticket id, like T-1") }; @Tool({ name: "save_draft", description: "Save a reply draft for a ticket. Drafts are kept for a day.", inputSchema: { ...input, text: z.string() } }) export class SaveDraft extends ToolContext { async execute({ ticketId, text }: { ticketId: string; text: string }) { await this.remember.set(`draft:${ticketId}`, text, { scope: "user", ttl: 24 * 60 * 60 }); return { saved: ticketId }; } } @Tool({ name: "get_draft", description: "Your reply draft for a ticket, if you have one.", inputSchema: input }) export class GetDraft extends ToolContext { async execute({ ticketId }: { ticketId: string }) { return { draft: await this.remember.get(`draft:${ticketId}`, { scope: "user", defaultValue: null }) }; } } @Tool({ name: "send_reply", description: "Send your reply draft for a ticket to the customer.", inputSchema: input }) export class SendReply extends ToolContext { async execute({ ticketId }: { ticketId: string }) { const key = `draft:${ticketId}`; const draft = await this.remember.get(key, { scope: "user" }); if (!draft) return { sent: false }; await this.remember.forget(key, { scope: "user" }); return { sent: true, text: draft }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SaveDraft, GetDraft, SendReply], plugins: [RememberPlugin.init({ type: "memory" })], }) export class HelpDeskApp {} ``` ```ts server.ts import { HelpDeskApp } from "./drafts.app"; // Two support agents, each with a key of their own. // In your project: @FrontMcp(config) export default class Server {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] }, }; ``` ```ts call-as.ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./server"; let server: ReturnType | undefined; /** Sends one tools/call with this key, as an MCP 2026-07-28 client would. Every call goes to the same server. */ export async function callAs(key: string, tool: string, args: Record = {}) { server ??= FrontMcpInstance.createFetchHandler(config); const response = await (await server)( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": tool, authorization: `Bearer ${key}`, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return (await response.json()).result; } ``` ```ts drafts.test.ts import { test, expect } from "@frontmcp/testing"; import { callAs } from "./call-as"; const NOUR = "agent-nour-key"; /** Runs `fn` with the clock `hours` ahead. */ async function inHours(hours: number, fn: () => Promise) { const realNow = Date.now; const now = realNow(); Date.now = () => now + hours * 60 * 60 * 1000; try { return await fn(); } finally { Date.now = realNow; } } test("a draft is there the next morning", async () => { await callAs(NOUR, "save_draft", { ticketId: "T-1", text: "You can log in again." }); const later = await inHours(20, () => callAs(NOUR, "get_draft", { ticketId: "T-1" })); expect(later.structuredContent).toEqual({ draft: "You can log in again." }); }); test("and gone after a day", async () => { await callAs(NOUR, "save_draft", { ticketId: "T-2", text: "Your invoice is fixed." }); const later = await inHours(25, () => callAs(NOUR, "get_draft", { ticketId: "T-2" })); expect(later.structuredContent).toEqual({ draft: null }); }); test("sending a reply forgets its draft", async () => { await callAs(NOUR, "save_draft", { ticketId: "T-3", text: "Here's a new link." }); expect((await callAs(NOUR, "send_reply", { ticketId: "T-3" })).structuredContent).toEqual({ sent: true, text: "Here's a new link." }); expect((await callAs(NOUR, "get_draft", { ticketId: "T-3" })).structuredContent).toEqual({ draft: null }); }); ``` `forget(key, { scope })` deletes a value at once, and a value past its `ttl` reads as missing. `this.remember` has a few more methods, like `knows()` to check for a value and `list()` for the keys in a scope ([`this.remember`](https://frontmcp.dev/reference/plugins/remember#thisremember)). Every value also lives only as long as its store: - **`type: "memory"` keeps values in the server's process, one store per server.** They're lost on restart and not shared between instances, or between two servers in one process. For a server that restarts or runs more than once, use Redis or Vercel KV ([Stores](https://frontmcp.dev/reference/plugins/remember#stores)). - **Values are encrypted** with a secret the server derives its keys from. In production, set `REMEMBER_SECRET` to the same value on every instance. Without it, each process makes up its own, and values stored before a restart, or by another instance, can't be read ([Encryption](https://frontmcp.dev/reference/plugins/remember#encryption)). ## Letting the model remember things So far your tools decide what to remember. The plugin can also give the model tools of its own. Set `tools.enabled` and it registers `remember_this`, `recall`, `forget` and `list_memories`, and your app lists none of them. Each takes a `scope`, which defaults to `session`. Their descriptions say what each scope keeps under 2026-07-28, and end with "An anonymous caller cannot use user scope, nor session or tool scope without a session." The model still picks the scope, so limit it to the one you mean with `tools.allowedScopes`: Initial call configuration: ```js { tool: "list_memories", arguments: { scope: "user" } } ``` ```ts assistant.app.ts active import { App } from "@frontmcp/sdk"; import { RememberPlugin } from "@frontmcp/plugin-remember"; @App({ id: "assistant", name: "Assistant", plugins: [RememberPlugin.init({ type: "memory", tools: { enabled: true, allowedScopes: ["user"] } })], }) export class AssistantApp {} ``` ```ts server.ts import { AssistantApp } from "./assistant.app"; // Two support agents, each with a key of their own. // In your project: @FrontMcp(config) export default class Server {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [AssistantApp], auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] }, }; ``` ```ts call-as.ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./server"; let server: ReturnType | undefined; /** Sends one tools/call with this key, as an MCP 2026-07-28 client would. Every call goes to the same server. */ export async function callAs(key: string, tool: string, args: Record = {}) { server ??= FrontMcpInstance.createFetchHandler(config); const response = await (await server)( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": tool, authorization: `Bearer ${key}`, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return (await response.json()).result; } ``` ```ts memory-tools.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { ForgetTool, ListMemoriesTool, RecallTool, RememberPlugin, RememberThisTool } from "@frontmcp/plugin-remember"; import { callAs } from "./call-as"; const NOUR = "agent-nour-key"; const SAM = "agent-sam-key"; test("the plugin registers the four memory tools, and the app lists none", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t: { name: string }) => t.name).sort(); expect(names).toEqual(["forget", "list_memories", "recall", "remember_this"]); }); test("the tools' scope description says what an anonymous caller can't use", async ({ mcp }) => { const tools = await mcp.tools.list(); const rememberThis = tools.find((t: { name: string }) => t.name === "remember_this"); expect(rememberThis.inputSchema.properties.scope.description).toContain("user: the signed-in caller, across all of its sessions."); expect(rememberThis.inputSchema.properties.scope.description).toContain("An anonymous caller cannot use user scope, nor session or tool scope without a session."); }); test("an anonymous caller can't list memories in user scope", async ({ mcp }) => { const result = await mcp.tools.call("list_memories", { scope: "user" }); expect(result).toBeError("REMEMBER_IDENTITY_REQUIRED"); expect(result.text()).toContain("Remember cannot use user scope without an authenticated user"); }); test("the model remembers and recalls things for the agent", async () => { const saved = await callAs(NOUR, "remember_this", { key: "timezone", value: "Europe/Paris", scope: "user" }); expect(saved.structuredContent).toEqual({ success: true, key: "timezone", scope: "user" }); expect((await callAs(NOUR, "recall", { key: "timezone", scope: "user" })).structuredContent).toMatchObject({ found: true, value: "Europe/Paris" }); expect((await callAs(SAM, "recall", { key: "timezone", scope: "user" })).structuredContent).toMatchObject({ found: false }); expect((await callAs(NOUR, "list_memories", { scope: "user" })).structuredContent).toMatchObject({ keys: ["timezone"], count: 1 }); }); test("another scope is refused, the default included, and the message names the allowed scope", async () => { const refused = await callAs(NOUR, "remember_this", { key: "timezone", value: "Europe/Paris" }); expect(refused.isError).toBe(true); expect(refused._meta.code).toBe("REMEMBER_SCOPE_NOT_ALLOWED"); expect(refused.content[0].text).toBe("Scope 'session' is not allowed. Allowed scopes: user"); const recalled = await callAs(NOUR, "recall", { key: "timezone", scope: "global" }); expect(recalled._meta.code).toBe("REMEMBER_SCOPE_NOT_ALLOWED"); }); test("tools.prefix puts a prefix on the names", async () => { @App({ id: "assistant", name: "Assistant", plugins: [RememberPlugin.init({ type: "memory", tools: { enabled: true, prefix: "memory_" } })] }) class Prefixed {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Prefixed] }); const { tools } = await server.listTools(); expect(tools.map((t) => t.name).sort()).toEqual(["memory_forget", "memory_list_memories", "memory_recall", "memory_remember_this"]); expect(tools.find((t) => t.name === "memory_recall")?.description).toContain("saved with memory_remember_this"); await server.dispose(); }); test("an app that lists the tool classes too has every tool twice", async () => { @App({ id: "assistant", name: "Assistant", tools: [RememberThisTool, RecallTool, ForgetTool, ListMemoriesTool], plugins: [RememberPlugin.init({ type: "memory", tools: { enabled: true } })], }) class Twice {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Twice] }); const names = (await server.listTools()).tools.map((t) => t.name); expect(names).toHaveLength(8); expect(names).toContain("assistant:recall"); expect(names).toContain("remember:recall"); await server.dispose(); }); ``` The Playground calls `list_memories` as its anonymous client, and is refused with `REMEMBER_IDENTITY_REQUIRED`: an anonymous caller has no `user` memory, so these tools are for signed-in users. For Nour, `remember_this` stores her time zone in `user` scope, and `recall` finds it for her and not for Sam. A call without a scope, or with another, is refused with a tool error that names the allowed scopes and has the code `REMEMBER_SCOPE_NOT_ALLOWED`, so the model can call again with `scope: "user"`. `tools.prefix` puts a prefix on all four names, like `memory_recall`, and their descriptions name each other by the prefixed names. > **Note** > > Changed in 1.8.6: `list_memories` for an anonymous caller in `user` scope answered `{ keys: [], count: 0 }`, as if there were nothing to list. It's an error now. > **Note** > > Changed in 1.8.7: the plugin registers these tools itself, and a refused scope is a public error, so in production the model reads the message instead of `Internal FrontMCP error…`. Before, the plugin added no tools: the app listed `RememberThisTool` and the other classes in its `tools`, and `tools` options passed to `init()` stopped the server with `TypeError: list is not iterable`, so they went through `useFactory`. An app that still lists the classes and sets `tools.enabled` has every tool twice: drop one of the two. The model decides what to remember, so treat what these tools store as the user's notes, not as settings your code relies on. For a value your code needs, like the signature, write a tool of your own that stores it in the scope you choose. ## Recap - `@frontmcp/plugin-remember` gives every tool `this.remember`. Register `RememberPlugin.init({ type: "memory" })`, then `set(key, value, { scope, ttl })`, `get(key, { scope, defaultValue })` and `forget(key, { scope })`. - `user` scope keeps a value for the caller: a signed-in user's token `sub`, or a static key. An anonymous caller has no identity to keep a value under, and `user` scope refuses it. - `session`, the default, is kept for a signed-in caller across requests and conversations under 2026-07-28, and refused to an anonymous one. `tool` works the same way, with values for one tool only. `global` is shared by every caller, and the only scope an anonymous caller can use. A refusal has the code `REMEMBER_IDENTITY_REQUIRED`. - `ttl` is in seconds; without one, a value lasts until it's forgotten, or for the plugin's `defaultTTL` when you set one. The memory store is lost on restart; use Redis or Vercel KV, and one `REMEMBER_SECRET`, for a server that runs more than once. - `tools: { enabled: true }` makes the plugin register `remember_this`, `recall`, `forget` and `list_memories`, so the model can remember things. Limit their scopes with `tools.allowedScopes`: a call with another scope is refused with `REMEMBER_SCOPE_NOT_ALLOWED`. - Every option, store and method is in the [Remember plugin reference](https://frontmcp.dev/reference/plugins/remember). ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Give each agent their own signature `set_signature` and `draft_reply` use `this.remember`, and still sign every agent's replies with whichever signature was set last. Make each agent's signature their own, with the default for agents who haven't set one. Initial call configuration: ```js { tool: "draft_reply", arguments: { ticketId: "T-1", text: "You can log in again." } } ``` ```ts signature.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { RememberPlugin } from "@frontmcp/plugin-remember"; @Tool({ name: "set_signature", description: "Set the signature your replies end with.", inputSchema: { signature: z.string().max(80) }, }) export class SetSignature extends ToolContext { async execute({ signature }: { signature: string }) { await this.remember.set("signature", signature, { scope: "global" }); return { signature }; } } @Tool({ name: "draft_reply", description: "Draft a reply to a support ticket, signed with your signature.", inputSchema: { ticketId: z.string(), text: z.string() }, }) export class DraftReply extends ToolContext { async execute({ ticketId, text }: { ticketId: string; text: string }) { const signature = await this.remember.get("signature", { scope: "global", defaultValue: "The Help Desk" }); return { ticketId, reply: `${text}\n\n${signature}` }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SetSignature, DraftReply], plugins: [RememberPlugin.init({ type: "memory" })], }) export class HelpDeskApp {} ``` **Solution file (alternative to the starter):** ```ts signature.app.ts solution import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { RememberPlugin } from "@frontmcp/plugin-remember"; @Tool({ name: "set_signature", description: "Set the signature your replies end with.", inputSchema: { signature: z.string().max(80) }, }) export class SetSignature extends ToolContext { async execute({ signature }: { signature: string }) { await this.remember.set("signature", signature, { scope: "user" }); return { signature }; } } @Tool({ name: "draft_reply", description: "Draft a reply to a support ticket, signed with your signature.", inputSchema: { ticketId: z.string(), text: z.string() }, }) export class DraftReply extends ToolContext { async execute({ ticketId, text }: { ticketId: string; text: string }) { const signature = await this.remember.get("signature", { scope: "user", defaultValue: "The Help Desk" }); return { ticketId, reply: `${text}\n\n${signature}` }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SetSignature, DraftReply], plugins: [RememberPlugin.init({ type: "memory" })], }) export class HelpDeskApp {} ``` ```ts server.ts import { HelpDeskApp } from "./signature.app"; // Two support agents, each with a key of their own. export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] }, }; ``` ```ts call-as.ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./server"; let server: ReturnType | undefined; /** Sends one tools/call with this key, as an MCP 2026-07-28 client would. Every call goes to the same server. */ export async function callAs(key: string, tool: string, args: Record = {}) { server ??= FrontMcpInstance.createFetchHandler(config); const response = await (await server)( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": tool, authorization: `Bearer ${key}`, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return (await response.json()).result; } ``` **Hidden fixture/test file (runs with the example):** ```ts signature.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { callAs } from "./call-as"; const NOUR = "agent-nour-key"; const SAM = "agent-sam-key"; test("an agent's replies are signed with their own signature", async () => { await callAs(NOUR, "set_signature", { signature: "Nour, Tier 2 support" }); const draft = await callAs(NOUR, "draft_reply", { ticketId: "T-1", text: "Fixed." }); expect(draft.structuredContent.reply).toBe("Fixed.\n\nNour, Tier 2 support"); }); test("an agent without a signature gets the default", async () => { const draft = await callAs(SAM, "draft_reply", { ticketId: "T-2", text: "Fixed." }); expect(draft.structuredContent.reply).toBe("Fixed.\n\nThe Help Desk"); }); test("one agent's change doesn't reach another's replies", async () => { await callAs(SAM, "set_signature", { signature: "Sam, Billing" }); const draft = await callAs(NOUR, "draft_reply", { ticketId: "T-1", text: "Fixed." }); expect(draft.structuredContent.reply).toBe("Fixed.\n\nNour, Tier 2 support"); }); ``` **Hint:** The code already stores the signature, and every agent reads it back. The question is who shares it. **Solution:** `global` scope is one value for every caller, so the last signature set was on everyone's replies. `scope: "user"` in both `set()` and `get()` keeps a value for each caller: the checks sign in with Nour's and Sam's keys, so each has their own, and Sam gets the default until he sets his. Both calls must use the same scope, or `draft_reply` looks in a different place from where `set_signature` wrote. ### Challenge: Show the outage banner to everyone During an outage, an agent sets a banner that every caller should see, including customers who haven't signed in. Right now the banner is only there for the agent who set it, and an anonymous caller's `get_banner` fails. Make every caller read the same banner. Initial call configuration: ```js { tool: "get_banner", arguments: {} } ``` ```ts banner.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { RememberPlugin } from "@frontmcp/plugin-remember"; @Tool({ name: "set_banner", description: "Show a banner to everyone, like an outage notice.", inputSchema: { text: z.string() } }) export class SetBanner extends ToolContext { async execute({ text }: { text: string }) { await this.remember.set("banner", text); return { banner: text }; } } @Tool({ name: "get_banner", description: "The banner everyone sees, if there is one.", inputSchema: {} }) export class GetBanner extends ToolContext { async execute() { return { banner: await this.remember.get("banner", { defaultValue: null }) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SetBanner, GetBanner], plugins: [RememberPlugin.init({ type: "memory" })], }) export class HelpDeskApp {} ``` **Solution file (alternative to the starter):** ```ts banner.app.ts solution import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { RememberPlugin } from "@frontmcp/plugin-remember"; @Tool({ name: "set_banner", description: "Show a banner to everyone, like an outage notice.", inputSchema: { text: z.string() } }) export class SetBanner extends ToolContext { async execute({ text }: { text: string }) { await this.remember.set("banner", text, { scope: "global" }); return { banner: text }; } } @Tool({ name: "get_banner", description: "The banner everyone sees, if there is one.", inputSchema: {} }) export class GetBanner extends ToolContext { async execute() { return { banner: await this.remember.get("banner", { scope: "global", defaultValue: null }) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SetBanner, GetBanner], plugins: [RememberPlugin.init({ type: "memory" })], }) export class HelpDeskApp {} ``` ```ts server.ts import { HelpDeskApp } from "./banner.app"; // Two support agents, each with a key of their own. export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] }, }; ``` ```ts call-as.ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./server"; let server: ReturnType | undefined; /** Sends one tools/call with this key, as an MCP 2026-07-28 client would. Every call goes to the same server. */ export async function callAs(key: string, tool: string, args: Record = {}) { server ??= FrontMcpInstance.createFetchHandler(config); const response = await (await server)( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": tool, authorization: `Bearer ${key}`, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return (await response.json()).result; } ``` **Hidden fixture/test file (runs with the example):** ```ts banner.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { callAs } from "./call-as"; test("another agent sees the banner an agent set", async () => { await callAs("agent-nour-key", "set_banner", { text: "Login is down. We're on it." }); expect((await callAs("agent-sam-key", "get_banner")).structuredContent).toEqual({ banner: "Login is down. We're on it." }); }); test("a caller who isn't signed in can post and read a banner too", async ({ mcp }) => { expect(await mcp.tools.call("set_banner", { text: "Exports are slow today." })).toBeSuccessful(); const result = await mcp.tools.call("get_banner", {}); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ banner: "Exports are slow today." }); }); ``` **Hint:** Neither call names a scope, so both use the default. What does the default keep under MCP 2026-07-28, and for whom? **Solution:** Without a `scope`, `this.remember` uses `session`, which under 2026-07-28 belongs to the signed-in caller: Nour's banner was Nour's alone, and an anonymous caller was refused with `RememberIdentityError`. `scope: "global"` in both calls keeps one banner for everyone. That's right for a banner, because it's meant for every caller, and wrong for anything personal. ### Challenge: Let drafts expire after a day Reply drafts are kept forever, and agents keep finding week-old drafts for tickets that were solved long ago. Keep a draft for one day after it's saved, and no longer. ```ts drafts.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { RememberPlugin } from "@frontmcp/plugin-remember"; const input = { ticketId: z.string().describe("Ticket id, like T-1") }; @Tool({ name: "save_draft", description: "Save a reply draft for a ticket.", inputSchema: { ...input, text: z.string() } }) export class SaveDraft extends ToolContext { async execute({ ticketId, text }: { ticketId: string; text: string }) { await this.remember.set(`draft:${ticketId}`, text, { scope: "user" }); return { saved: ticketId }; } } @Tool({ name: "get_draft", description: "Your reply draft for a ticket, if you have one.", inputSchema: input }) export class GetDraft extends ToolContext { async execute({ ticketId }: { ticketId: string }) { return { draft: await this.remember.get(`draft:${ticketId}`, { scope: "user", defaultValue: null }) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SaveDraft, GetDraft], plugins: [RememberPlugin.init({ type: "memory" })], }) export class HelpDeskApp {} ``` **Solution file (alternative to the starter):** ```ts drafts.app.ts solution import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { RememberPlugin } from "@frontmcp/plugin-remember"; const input = { ticketId: z.string().describe("Ticket id, like T-1") }; @Tool({ name: "save_draft", description: "Save a reply draft for a ticket. Drafts are kept for a day.", inputSchema: { ...input, text: z.string() } }) export class SaveDraft extends ToolContext { async execute({ ticketId, text }: { ticketId: string; text: string }) { await this.remember.set(`draft:${ticketId}`, text, { scope: "user", ttl: 24 * 60 * 60 }); return { saved: ticketId }; } } @Tool({ name: "get_draft", description: "Your reply draft for a ticket, if you have one.", inputSchema: input }) export class GetDraft extends ToolContext { async execute({ ticketId }: { ticketId: string }) { return { draft: await this.remember.get(`draft:${ticketId}`, { scope: "user", defaultValue: null }) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SaveDraft, GetDraft], plugins: [RememberPlugin.init({ type: "memory" })], }) export class HelpDeskApp {} ``` ```ts server.ts import { HelpDeskApp } from "./drafts.app"; // Two support agents, each with a key of their own. export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] }, }; ``` ```ts call-as.ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./server"; let server: ReturnType | undefined; /** Sends one tools/call with this key, as an MCP 2026-07-28 client would. Every call goes to the same server. */ export async function callAs(key: string, tool: string, args: Record = {}) { server ??= FrontMcpInstance.createFetchHandler(config); const response = await (await server)( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": tool, authorization: `Bearer ${key}`, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return (await response.json()).result; } ``` **Hidden fixture/test file (runs with the example):** ```ts drafts.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { callAs } from "./call-as"; const NOUR = "agent-nour-key"; async function inHours(hours: number, fn: () => Promise) { const realNow = Date.now; const now = realNow(); Date.now = () => now + hours * 60 * 60 * 1000; try { return await fn(); } finally { Date.now = realNow; } } test("a draft is still there after 23 hours", async () => { await callAs(NOUR, "save_draft", { ticketId: "T-1", text: "You can log in again." }); const later = await inHours(23, () => callAs(NOUR, "get_draft", { ticketId: "T-1" })); expect(later.structuredContent).toEqual({ draft: "You can log in again." }); }); test("a draft is gone after 25 hours", async () => { await callAs(NOUR, "save_draft", { ticketId: "T-2", text: "Your invoice is fixed." }); const later = await inHours(25, () => callAs(NOUR, "get_draft", { ticketId: "T-2" })); expect(later.structuredContent).toEqual({ draft: null }); }); ``` **Hint:** `set()` takes one more option. It's in seconds. **Solution:** `ttl: 24 * 60 * 60` stores the draft for 86,400 seconds, a day. After that `get()` treats it as missing and returns the `defaultValue`, `null`, and the store drops it. The description tells the model drafts are kept for a day, so it can tell the agent. ### Challenge: Let the model remember, for users only Give the assistant the four memory tools, so the model can remember things for a signed-in agent and recall them later. Allow only `user` scope: a call with any other scope, or none, must be refused. Initial call configuration: ```js { tool: "whats_new", arguments: {} } ``` ```ts assistant.app.ts active import { App, Tool, ToolContext } from "@frontmcp/sdk"; import { RememberPlugin } from "@frontmcp/plugin-remember"; @Tool({ name: "whats_new", description: "What's new in the help desk this week.", inputSchema: {} }) export class WhatsNew extends ToolContext { async execute() { return { news: ["Tickets can now be merged."] }; } } @App({ id: "assistant", name: "Assistant", tools: [WhatsNew], plugins: [RememberPlugin.init({ type: "memory" })], }) export class AssistantApp {} ``` **Solution file (alternative to the starter):** ```ts assistant.app.ts solution import { App, Tool, ToolContext } from "@frontmcp/sdk"; import { RememberPlugin } from "@frontmcp/plugin-remember"; @Tool({ name: "whats_new", description: "What's new in the help desk this week.", inputSchema: {} }) export class WhatsNew extends ToolContext { async execute() { return { news: ["Tickets can now be merged."] }; } } @App({ id: "assistant", name: "Assistant", tools: [WhatsNew], plugins: [RememberPlugin.init({ type: "memory", tools: { enabled: true, allowedScopes: ["user"] } })], }) export class AssistantApp {} ``` ```ts server.ts import { AssistantApp } from "./assistant.app"; // Two support agents, each with a key of their own. export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [AssistantApp], auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] }, }; ``` ```ts call-as.ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./server"; let server: ReturnType | undefined; /** Sends one tools/call with this key, as an MCP 2026-07-28 client would. Every call goes to the same server. */ export async function callAs(key: string, tool: string, args: Record = {}) { server ??= FrontMcpInstance.createFetchHandler(config); const response = await (await server)( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": tool, authorization: `Bearer ${key}`, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return (await response.json()).result; } ``` **Hidden fixture/test file (runs with the example):** ```ts memory.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { callAs } from "./call-as"; const NOUR = "agent-nour-key"; test("`remember_this`, `recall`, `forget` and `list_memories` are listed", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t: { name: string }) => t.name).sort(); expect(names).toEqual(["forget", "list_memories", "recall", "remember_this", "whats_new"]); }); test("the model can remember something for an agent and recall it", async () => { await callAs(NOUR, "remember_this", { key: "team", value: "Tier 2", scope: "user" }); expect((await callAs(NOUR, "recall", { key: "team", scope: "user" })).structuredContent).toMatchObject({ found: true, value: "Tier 2" }); }); test("a call without `scope: \"user\"` is refused", async () => { const withoutScope = await callAs(NOUR, "remember_this", { key: "team", value: "Tier 2" }); const inGlobal = await callAs(NOUR, "remember_this", { key: "team", value: "Tier 2", scope: "global" }); expect(withoutScope.isError).toBe(true); expect(inGlobal.isError).toBe(true); }); ``` **Hint:** The plugin registers the memory tools itself, when you ask it to. Two of its `tools` options do the job: one turns them on, the other limits their scopes. **Solution:** `tools: { enabled: true }` makes the plugin register `remember_this`, `recall`, `forget` and `list_memories` next to `whats_new`, and `allowedScopes: ["user"]` limits them to `user` scope. A call without a scope asks for `session`, which isn't allowed, so the model is told to use `user`. --- # Asking for Approval > Require recorded user approval before a FrontMCP tool runs. Build the approval UI and control expiration and withdrawal. Source: https://frontmcp.dev/learn/asking-for-approval [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Some tools shouldn't run just because the model decided to. Deleting a ticket can't be undone. `destructiveHint` tells the client so, but the client may run the tool without asking anyone, and [authorization](https://frontmcp.dev/learn/authorizing-calls) decides who may delete tickets, not whether this user agreed to let the assistant do it. `@frontmcp/plugin-approval` puts a gate in front of a FrontMCP tool: every call is refused until an approval of that tool is recorded for the caller. Your code records the approval once the user says yes, and can withdraw it. The plugin doesn't ask anyone itself, so this lesson builds the asking too. **You will learn** - How to require approval before a tool runs - What the model sees when a call is refused, and how to tell it what to do next - How to record an approval once the user says yes, and how to withdraw it - How long an approval lasts under MCP 2026-07-28 - Why the model must never be able to approve its own calls ## A tool the model can run on its own `delete_ticket` deletes a ticket for good, and says so with `destructiveHint`: Initial call configuration: ```js { tool: "delete_ticket", arguments: { id: "T-1" } } ``` ```ts delete-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./store"; @Tool({ name: "delete_ticket", description: "Delete a support ticket for good.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, }) export class DeleteTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, deleted: tickets.delete(id) }; } } ``` ```ts store.ts export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }], ["T-3", { id: "T-3", title: "Login link expired", status: "open" }], ]); ``` ```ts delete.test.ts import { test, expect } from "@frontmcp/testing"; import { tickets } from "./store"; test("the tool says it's destructive", async ({ mcp }) => { const [tool] = await mcp.tools.list(); expect(tool.annotations).toMatchObject({ destructiveHint: true }); }); test("🚩 and the call deletes the ticket, with nobody asked", async ({ mcp }) => { const result = await mcp.tools.call("delete_ticket", { id: "T-2" }); expect(result.json()).toEqual({ id: "T-2", deleted: true }); expect(tickets.has("T-2")).toBe(false); }); ``` A hint is only a hint. Some clients ask the user before a destructive call, some ask once and remember, and some don't ask at all, and your server can't tell which one it's talking to. [Asking the User Mid-Call](https://frontmcp.dev/learn/asking-the-user) shows how a tool can ask on every call. An approval is different: the user says yes once, your server records it, and a gate outside the tool checks for it on every call, until it expires or is withdrawn. ## Putting a tool behind approval Install the plugin: ```bash npm install @frontmcp/plugin-approval ``` Register it on the app with `ApprovalPlugin.init({ storage: { type: "memory" } })`, and give each tool that needs approval an `approval` field. `approvalMessage` is what a refused call says: Initial call configuration: ```js { tool: "delete_ticket", arguments: { id: "T-1" } } ``` ```ts help-desk.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { ApprovalPlugin } from "@frontmcp/plugin-approval"; import { tickets } from "./store"; @Tool({ name: "delete_ticket", description: "Delete a support ticket for good. Needs the user's approval.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, approval: { approvalMessage: "delete_ticket needs the user's approval first." }, }) export class DeleteTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, deleted: tickets.delete(id) }; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return tickets.get(id) ?? { id, found: false }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, DeleteTicket], plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })], }) export class HelpDeskApp {} ``` ```ts store.ts export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }], ["T-3", { id: "T-3", title: "Login link expired", status: "open" }], ]); ``` ```ts gate.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { DeleteTicket } from "./help-desk.app"; import { tickets } from "./store"; test("an unapproved call is refused, and the ticket stays", async ({ mcp }) => { const result = await mcp.tools.call("delete_ticket", { id: "T-2" }); expect(result).toBeError("APPROVAL_REQUIRED"); expect(result.text()).toBe("delete_ticket needs the user's approval first."); expect(tickets.has("T-2")).toBe(true); }); test("tools without `approval` aren't affected", async ({ mcp }) => { expect((await mcp.tools.call("get_ticket", { id: "T-1" })).json()).toMatchObject({ title: "Cannot log in" }); }); test("the gated tool is still listed", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("delete_ticket"); }); test("🚩 without the plugin, the server doesn't start", async () => { @App({ id: "help-desk", name: "Help Desk", tools: [DeleteTicket] }) class Unprotected {} await expect(FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Unprotected] })).rejects.toThrow( `Tool "delete_ticket" declares 'approval' (enforced by ApprovalPlugin from @frontmcp/plugin-approval)`, ); }); ``` The plugin checks after the arguments are validated and before `execute()` runs, so a refused call changes nothing. `get_ticket`, which has no `approval`, runs as before. `approval: true` works too, with a default message; either way, a tool with `approval` on a server with no approval plugin doesn't start, so a gate can't be left off by accident. The refusal is a tool error, the kind the model reads. Open the Call tab: the text is `approvalMessage` and nothing more, and `_meta.code` is `APPROVAL_REQUIRED`. It reads the same in production ([What the client sees](https://frontmcp.dev/reference/plugins/approval#what-the-client-sees)), so put in `approvalMessage` what the model should do next: "Call approve_delete first." > **Note** > > Changed in 1.8.6: before, a refusal reached the client as an internal error. Its `_meta.code` was `SERVER_ERROR`, in development its text went on with `Original error:` and a stack trace, and in production it was only `Internal FrontMCP error. Please contact support with error ID: …`, so the model never learned that it needed approval. ## Recording an approval Nothing can approve `delete_ticket` yet. The plugin adds `this.approval` to the tools of the app it's registered on (every app's, on `@FrontMcp`), which grants, checks and withdraws approvals for the caller. `approve_delete` grants one with `grantUserApproval()`, which lasts until it's withdrawn. An approval is kept for a caller, so the tests need callers who are signed in. As in [Remembering Across Calls](https://frontmcp.dev/learn/remembering-across-calls), `server.ts` gives Nour and Sam a [static key](https://frontmcp.dev/learn/authenticating-clients#requiring-a-shared-key-static-mode) each, and `callAs()` sends a tool call with a key the way an MCP 2026-07-28 client would, to one server for every call. It also answers a form, if a tool asks one, the way the user would; this section doesn't need that yet: Initial call configuration: ```js { tool: "approve_delete", arguments: {} } ``` ```ts approve-delete.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; // An approval names its tool by the app's id and the tool's name export const DELETE_TICKET = "help-desk:delete_ticket"; @Tool({ name: "approve_delete", description: "Approve delete_ticket for you.", inputSchema: {} }) export class ApproveDelete extends ToolContext { async execute() { const record = await this.approval.grantUserApproval(DELETE_TICKET); // 🚩 without asking anyone return { approved: true, scope: record.scope, grantedBy: record.grantedBy }; } } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { ApprovalPlugin } from "@frontmcp/plugin-approval"; import { ApproveDelete } from "./approve-delete.tool"; import { tickets } from "./store"; @Tool({ name: "delete_ticket", description: "Delete a support ticket for good. Needs the user's approval: call approve_delete first.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, approval: { approvalMessage: "delete_ticket needs the user's approval. Call approve_delete first." }, }) export class DeleteTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, deleted: tickets.delete(id) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [DeleteTicket, ApproveDelete], plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })], }) export class HelpDeskApp {} ``` ```ts store.ts export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }], ["T-3", { id: "T-3", title: "Login link expired", status: "open" }], ]); ``` ```ts server.ts import { HelpDeskApp } from "./help-desk.app"; // Two support agents, each with a key of their own. // In your project: @FrontMcp(config) export default class Server {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true }, auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] }, }; ``` ```ts call-as.ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./server"; type Answer = { action: "accept" | "decline"; content?: Record }; let server: ReturnType | undefined; /** * Sends one tools/call with this key, as an MCP 2026-07-28 client would. Every call goes to the same server. * If the tool asks the user something, `answer` is what the user replies; without one, they decline. */ export async function callAs(key: string, tool: string, args: Record = {}, answer?: Answer) { server ??= FrontMcpInstance.createFetchHandler(config); const handler = await server; let reply = {}; for (;;) { const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": tool, authorization: `Bearer ${key}`, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args, ...reply, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } }, }, }, }), }), ); const { result } = await response.json(); if (result.resultType !== "input_required") return result; // The tool asked the user: send their answer back, with the state the server returned const inputResponses = Object.fromEntries(Object.keys(result.inputRequests).map((id) => [id, answer ?? { action: "decline" }])); reply = { inputResponses, requestState: result.requestState }; } } ``` ```ts approve.test.ts import { test, expect } from "@frontmcp/testing"; import { callAs } from "./call-as"; import { tickets } from "./store"; const NOUR = "agent-nour-key"; const SAM = "agent-sam-key"; test("delete_ticket runs once it's approved", async () => { expect((await callAs(NOUR, "delete_ticket", { id: "T-1" })).isError).toBe(true); expect((await callAs(NOUR, "approve_delete")).structuredContent).toMatchObject({ approved: true, scope: "user" }); expect((await callAs(NOUR, "delete_ticket", { id: "T-1" })).structuredContent).toEqual({ id: "T-1", deleted: true }); }); test("the approval names the caller as its grantor, and says nobody was asked", async () => { const { grantedBy } = (await callAs(NOUR, "approve_delete")).structuredContent; expect(grantedBy).toEqual({ source: "user", identifier: expect.stringMatching(/^static:/), method: "implicit" }); }); test("an approval belongs to the caller who got it", async () => { const result = await callAs(SAM, "delete_ticket", { id: "T-3" }); expect(result.isError).toBe(true); expect(tickets.has("T-3")).toBe(true); }); test("🚩 an anonymous caller's approval is gone by their next call", async ({ mcp }) => { // The Playground's own server, whose callers don't sign in expect(await mcp.tools.call("approve_delete", {})).toBeSuccessful(); expect(await mcp.tools.call("delete_ticket", { id: "T-3" })).toBeError(); }); ``` Once Nour has an approval, her `delete_ticket` runs. Sam has none, so his is still refused. The last test calls the Playground's own server: its caller is approved, and then refused anyway. The result shows the record's `grantedBy`: without one in the grant's options, it's the caller who made the call, for a static key its `static:…` id, with `method: "implicit"`. `"implicit"` is the record saying that nobody was asked: the tool granted on its own. A caller who isn't signed in is `{ source: "user", method: "implicit" }`. - **An approval belongs to a caller.** The gate looks for one recorded for the caller of each call: a signed-in user by their token's `sub`, a static key by its `static:…` id. A caller without credentials is a new anonymous caller on every MCP 2026-07-28 request, so an approval never carries over to their next call. Approvals need callers who [sign in](https://frontmcp.dev/learn/authenticating-clients). - **An approval names its tool as `:`**, like `help-desk:delete_ticket`, not the name clients see. `grantUserApproval("delete_ticket")` records an approval that nothing checks, without an error. - **The store is in memory.** `type: "memory"` keeps approvals in the server's process, so a restart withdraws them all, and each instance has its own. In production, use Redis, Vercel KV, Upstash or Cloudflare KV ([Options](https://frontmcp.dev/reference/plugins/approval#options)). ## Only a person can say yes Look at the first test again, from the model's side: it called `approve_delete`, then `delete_ticket`, and the ticket is gone. Nobody was asked. A tool that grants an approval without asking anyone lets the model approve itself, and the gate protects nothing. The yes has to come from someone the model can't speak for. `this.elicit()` shows the user a form in their client, and under MCP 2026-07-28 only the user can answer it. `approve_delete` now asks first: Initial call configuration: ```js { tool: "approve_delete", arguments: {} } ``` ```ts approve-delete.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { userGrantor } from "@frontmcp/plugin-approval"; export const DELETE_TICKET = "help-desk:delete_ticket"; @Tool({ name: "approve_delete", description: "Ask the user to let you delete support tickets for them. Call it when delete_ticket says it needs approval.", inputSchema: {}, }) export class ApproveDelete extends ToolContext { async execute() { // ✅ The user answers this form in their client. The model can't. const answer = await this.elicit("Allow the assistant to delete support tickets for you?", z.object({ allow: z.boolean() })); if (answer.status !== "accept" || !answer.content?.allow) return { approved: false }; // The user was asked, so the record says so const record = await this.approval.grantUserApproval(DELETE_TICKET, { grantedBy: userGrantor(this.auth.user.sub) }); return { approved: true, grantedBy: record.grantedBy }; } } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { ApprovalPlugin } from "@frontmcp/plugin-approval"; import { ApproveDelete } from "./approve-delete.tool"; import { tickets } from "./store"; @Tool({ name: "delete_ticket", description: "Delete a support ticket for good. Needs the user's approval: call approve_delete first.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, approval: { approvalMessage: "delete_ticket needs the user's approval. Call approve_delete first." }, }) export class DeleteTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, deleted: tickets.delete(id) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [DeleteTicket, ApproveDelete], plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })], }) export class HelpDeskApp {} ``` ```ts store.ts export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }], ["T-3", { id: "T-3", title: "Login link expired", status: "open" }], ]); ``` ```ts server.ts import { HelpDeskApp } from "./help-desk.app"; // Two support agents, each with a key of their own. // In your project: @FrontMcp(config) export default class Server {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true }, auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] }, }; ``` **Hidden fixture/test file (runs with the example):** ```ts call-as.ts hidden import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./server"; type Answer = { action: "accept" | "decline"; content?: Record }; let server: ReturnType | undefined; export async function callAs(key: string, tool: string, args: Record = {}, answer?: Answer) { server ??= FrontMcpInstance.createFetchHandler(config); const handler = await server; let reply = {}; for (;;) { const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": tool, authorization: `Bearer ${key}`, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args, ...reply, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } }, }, }, }), }), ); const { result } = await response.json(); if (result.resultType !== "input_required") return result; const inputResponses = Object.fromEntries(Object.keys(result.inputRequests).map((id) => [id, answer ?? { action: "decline" }])); reply = { inputResponses, requestState: result.requestState }; } } ``` ```ts ask.test.ts import { test, expect } from "@frontmcp/testing"; import { callAs } from "./call-as"; const NOUR = "agent-nour-key"; const SAM = "agent-sam-key"; test("saying no, or closing the form, approves nothing", async () => { expect((await callAs(SAM, "approve_delete", {}, { action: "accept", content: { allow: false } })).structuredContent).toEqual({ approved: false }); expect((await callAs(SAM, "approve_delete", {}, { action: "decline" })).structuredContent).toEqual({ approved: false }); expect((await callAs(SAM, "delete_ticket", { id: "T-2" })).isError).toBe(true); }); test("the user's yes lets delete_ticket run, and records who gave it", async () => { const approved = await callAs(NOUR, "approve_delete", {}, { action: "accept", content: { allow: true } }); expect(approved.structuredContent).toMatchObject({ approved: true, grantedBy: { source: "user", identifier: expect.stringMatching(/^static:/), method: "interactive" }, }); expect((await callAs(NOUR, "delete_ticket", { id: "T-2" })).structuredContent).toEqual({ id: "T-2", deleted: true }); }); ``` Press **Call** in the Call tab: `approve_delete` shows the form the user would see. The model can still call `approve_delete`, and it should when `delete_ticket` is refused, as its description says. What it can't do is answer. The tests answer the form both ways: no, or closing it, approves nothing, and yes records an approval. Without a `grantedBy` option, the grant records the caller as `method: "implicit"`, form or no form: the plugin can't tell that a tool asked. `approve_delete` passes `grantedBy: userGrantor(this.auth.user.sub)`, which says `"interactive"`, because the user answered a form first. Pass `grantedBy` yourself too when the grant is made on someone else's behalf, like a team lead approving a teammate, which is what the grantor functions are for ([Grantors and revokers](https://frontmcp.dev/reference/plugins/approval#grantors-and-revokers)). > **Note** > > Changed in 1.8.7: the default `grantedBy` records `method: "implicit"`, because a tool that grants through `this.approval` asked no one. In 1.8.6 it said `"interactive"`, which an audit trail reads as the user saying yes, and before 1.8.6 it was `{ source: "policy" }`. A tool that did ask passes `grantedBy: userGrantor(id)`. > **Note** > > A client on a protocol version before 2026-07-28 that can't show forms gets FrontMCP's fallback instead: the model is told to ask the user in the chat and passes the answer on with `sendElicitationResult` ([this.elicit](https://frontmcp.dev/reference/sdk/elicit#the-model-is-asked-to-call-sendelicitationresult)). There, the yes comes through the model. Where that matters, have someone with more rights grant the approval, like a team lead approving a teammate for a limited time ([Approving for a limited time](https://frontmcp.dev/reference/plugins/approval#approving-for-a-limited-time)). ## Withdrawing an approval, and letting it expire A user approval lasts until it's withdrawn, which is more than most users mean by yes. `this.approval.revokeApproval(toolId)` withdraws the caller's approvals of a tool and says whether there were any. And `grantTimeLimitedApproval(toolId, ttlMs)` grants one that expires by itself. Here `approve_delete` grants for a number of minutes, 15 unless the model asks for another number, and says so in the form. The tests move the clock forward by replacing `Date.now()`, which is what the plugin reads: Initial call configuration: ```js { tool: "approve_delete", arguments: { minutes: 15 } } ``` ```ts approval-tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; export const DELETE_TICKET = "help-desk:delete_ticket"; @Tool({ name: "approve_delete", description: "Ask the user to let you delete support tickets for them, for some minutes. Call it when delete_ticket says it needs approval.", inputSchema: { minutes: z.number().int().min(1).max(60).default(15).describe("How long the approval lasts") }, }) export class ApproveDelete extends ToolContext { async execute({ minutes }: { minutes: number }) { const question = `Allow the assistant to delete support tickets for you for the next ${minutes} minutes?`; const answer = await this.elicit(question, z.object({ allow: z.boolean() })); if (answer.status !== "accept" || !answer.content?.allow) return { approved: false }; const record = await this.approval.grantTimeLimitedApproval(DELETE_TICKET, minutes * 60_000); return { approved: true, expiresAt: new Date(record.expiresAt!).toISOString() }; } } @Tool({ name: "revoke_delete", description: "Withdraw your approval of delete_ticket.", inputSchema: {} }) export class RevokeDelete extends ToolContext { async execute() { return { revoked: await this.approval.revokeApproval(DELETE_TICKET) }; } } @Tool({ name: "my_revocations", description: "The approvals of delete_ticket you withdrew in the last day.", inputSchema: {} }) export class MyRevocations extends ToolContext { async execute() { const revoked = await this.approval.getRevocations(DELETE_TICKET); return { revocations: revoked.map((r) => ({ scope: r.scope, revokedBy: r.revokedBy })) }; } } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { ApprovalPlugin } from "@frontmcp/plugin-approval"; import { ApproveDelete, MyRevocations, RevokeDelete } from "./approval-tools"; import { tickets } from "./store"; @Tool({ name: "delete_ticket", description: "Delete a support ticket for good. Needs the user's approval: call approve_delete first.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, approval: { approvalMessage: "delete_ticket needs the user's approval. Call approve_delete first." }, }) export class DeleteTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, deleted: tickets.delete(id) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [DeleteTicket, ApproveDelete, RevokeDelete, MyRevocations], plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })], }) export class HelpDeskApp {} ``` ```ts store.ts export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }], ["T-3", { id: "T-3", title: "Login link expired", status: "open" }], ]); ``` ```ts server.ts import { HelpDeskApp } from "./help-desk.app"; // Two support agents, each with a key of their own. // In your project: @FrontMcp(config) export default class Server {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true }, auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] }, }; ``` **Hidden fixture/test file (runs with the example):** ```ts call-as.ts hidden import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./server"; type Answer = { action: "accept" | "decline"; content?: Record }; let server: ReturnType | undefined; export async function callAs(key: string, tool: string, args: Record = {}, answer?: Answer) { server ??= FrontMcpInstance.createFetchHandler(config); const handler = await server; let reply = {}; for (;;) { const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": tool, authorization: `Bearer ${key}`, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args, ...reply, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } }, }, }, }), }), ); const { result } = await response.json(); if (result.resultType !== "input_required") return result; const inputResponses = Object.fromEntries(Object.keys(result.inputRequests).map((id) => [id, answer ?? { action: "decline" }])); reply = { inputResponses, requestState: result.requestState }; } } ``` ```ts lifetime.test.ts import { test, expect } from "@frontmcp/testing"; import { callAs } from "./call-as"; const NOUR = "agent-nour-key"; const SAM = "agent-sam-key"; const yes = { action: "accept" as const, content: { allow: true } }; const deletes = async (key: string, id: string) => ((await callAs(key, "delete_ticket", { id })).isError ? "refused" : "ran"); /** Runs `fn` with the clock `minutes` ahead. */ async function inMinutes(minutes: number, fn: () => Promise) { const realNow = Date.now; const now = realNow(); Date.now = () => now + minutes * 60_000; try { return await fn(); } finally { Date.now = realNow; } } test("revoking withdraws the approval", async () => { await callAs(NOUR, "approve_delete", {}, yes); expect((await callAs(NOUR, "revoke_delete")).structuredContent).toEqual({ revoked: true }); expect(await deletes(NOUR, "T-1")).toBe("refused"); expect((await callAs(NOUR, "revoke_delete")).structuredContent).toEqual({ revoked: false }); }); test("a withdrawn approval is kept for a day, with who withdrew it", async () => { await callAs(NOUR, "approve_delete", {}, yes); await callAs(NOUR, "revoke_delete"); const { revocations } = (await callAs(NOUR, "my_revocations")).structuredContent; expect(revocations.at(-1)).toEqual({ scope: "time_limited", revokedBy: { source: "user", identifier: expect.stringMatching(/^static:/), method: "implicit" }, }); const nextDay = await inMinutes(24 * 60 + 1, () => callAs(NOUR, "my_revocations")); expect(nextDay.structuredContent).toEqual({ revocations: [] }); }); test("a 15-minute approval works for 15 minutes", async () => { await callAs(SAM, "approve_delete", { minutes: 15 }, yes); expect(await inMinutes(14, () => deletes(SAM, "T-2"))).toBe("ran"); expect(await inMinutes(16, () => deletes(SAM, "T-3"))).toBe("refused"); }); ``` Once an approval is withdrawn or has expired, the refusal is the same as before it was granted, so the model knows to call `approve_delete` again, and the user can say yes for another 15 minutes. A withdrawn approval isn't forgotten at once: the plugin keeps a copy for 24 hours, with who withdrew it, and `this.approval.getRevocations(toolId)` returns the caller's, as `my_revocations` does. `this.approval` has three grants for the caller: | Grant | Lasts | Under MCP 2026-07-28 | | --- | --- | --- | | `grantSessionApproval(toolId)` | Until it's withdrawn, for the caller's session | There are no sessions, so it's kept for the signed-in caller, in every conversation | | `grantUserApproval(toolId)` | Until it's withdrawn, for the caller | The same | | `grantTimeLimitedApproval(toolId, ttlMs)` | `ttlMs` milliseconds | The same | A tool can also cap every approval of it, however it was granted, with `maxTtlMs` in its `approval`: the gate ignores an approval older than that. Every field of `approval` is in [the reference](https://frontmcp.dev/reference/plugins/approval#the-approval-tool-option). > **Pitfall: Under 2026-07-28, a session approval is a user approval** > > An approval the user gave "for this session" sounds like it ends with the conversation. MCP 2026-07-28 has no sessions, so the plugin keeps it for the signed-in caller, and it lasts for every later conversation, on every client, until it's withdrawn or the store is lost. When an approval should end, give it a time limit. > **Note** > > Register the plugin once. On `@FrontMcp`, it gates every app's tools. In one app's `plugins`, it gates that app's tools and those of every app without an approval plugin of its own, but only its own app's tools get `this.approval`. On both, the two gates decide together, and an approval in either store lets the tool run, but each store holds approvals the other doesn't know about ([Register the plugin once](https://frontmcp.dev/reference/plugins/approval#how-a-call-is-checked)). ## Recap - `@frontmcp/plugin-approval` refuses every call to a tool with `approval` until an approval of it is recorded for the caller. Register `ApprovalPlugin.init({ storage: { type: "memory" } })`, and give the tool `approval: true` or `approval: { approvalMessage }`. - A refused call is a tool error with the text `approvalMessage` and the code `APPROVAL_REQUIRED`, in production too, so say in it what the model should do next. - `this.approval` grants, checks and withdraws approvals for the caller, and records the caller as `grantedBy`. Tools are named `:`, and approvals need callers who sign in. - Grant only after a person says yes, in a form from `this.elicit()`, or from a caller with more rights. A tool that grants without asking lets the model approve itself, though its `grantedBy` says `"implicit"`. - `revokeApproval()` withdraws an approval, `grantTimeLimitedApproval()` grants one that expires, and `maxTtlMs` caps every approval of a tool. Under MCP 2026-07-28, a session approval lasts for the signed-in caller. - The store, the grantors, contexts and every option are in the [Approval plugin reference](https://frontmcp.dev/reference/plugins/approval). ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Put the refund behind approval `refund_invoice` sends money back to a customer, and nothing stops the model from calling it. `approve_refund` is already written. Make `refund_invoice` refuse every call that isn't approved, with a refusal that tells the model to call `approve_refund`. `get_invoice` must keep working. Initial call configuration: ```js { tool: "refund_invoice", arguments: { id: "INV-7" } } ``` ```ts billing.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { ApproveRefund } from "./approve-refund.tool"; import { invoices } from "./store"; @Tool({ name: "refund_invoice", description: "Refund an invoice in full.", inputSchema: { id: z.string().describe("Invoice id, like INV-7") }, annotations: { destructiveHint: true }, }) export class RefundInvoice extends ToolContext { async execute({ id }: { id: string }) { invoices.set(id, "refunded"); return { id, status: "refunded" }; } } @Tool({ name: "get_invoice", description: "Get an invoice's status.", inputSchema: { id: z.string() } }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, status: invoices.get(id) ?? "unknown" }; } } @App({ id: "billing", name: "Billing", tools: [RefundInvoice, GetInvoice, ApproveRefund] }) export class BillingApp {} ``` **Solution file (alternative to the starter):** ```ts billing.app.ts solution import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { ApprovalPlugin } from "@frontmcp/plugin-approval"; import { ApproveRefund } from "./approve-refund.tool"; import { invoices } from "./store"; @Tool({ name: "refund_invoice", description: "Refund an invoice in full.", inputSchema: { id: z.string().describe("Invoice id, like INV-7") }, annotations: { destructiveHint: true }, approval: { approvalMessage: "refund_invoice needs the user's approval. Call approve_refund first." }, }) export class RefundInvoice extends ToolContext { async execute({ id }: { id: string }) { invoices.set(id, "refunded"); return { id, status: "refunded" }; } } @Tool({ name: "get_invoice", description: "Get an invoice's status.", inputSchema: { id: z.string() } }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, status: invoices.get(id) ?? "unknown" }; } } @App({ id: "billing", name: "Billing", tools: [RefundInvoice, GetInvoice, ApproveRefund], plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })], }) export class BillingApp {} ``` ```ts approve-refund.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "approve_refund", description: "Ask the user to let you refund invoices for them.", inputSchema: {} }) export class ApproveRefund extends ToolContext { async execute() { const answer = await this.elicit("Allow the assistant to refund invoices for you?", z.object({ allow: z.boolean() })); if (answer.status !== "accept" || !answer.content?.allow) return { approved: false }; await this.approval.grantUserApproval("billing:refund_invoice"); return { approved: true }; } } ``` ```ts store.ts export const invoices = new Map([ ["INV-7", "paid"], ["INV-8", "paid"], ]); ``` **Hidden fixture/test file (runs with the example):** ```ts refund.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { invoices } from "./store"; test("an unapproved refund is refused, and nothing is refunded", async ({ mcp }) => { const result = await mcp.tools.call("refund_invoice", { id: "INV-8" }); expect(result).toBeError(); expect(invoices.get("INV-8")).toBe("paid"); }); test("the refusal tells the model to call `approve_refund`", async ({ mcp }) => { const result = await mcp.tools.call("refund_invoice", { id: "INV-8" }); expect(result.text()).toContain("approve_refund"); }); test("`get_invoice` still works", async ({ mcp }) => { expect((await mcp.tools.call("get_invoice", { id: "INV-7" })).json()).toMatchObject({ id: "INV-7" }); }); ``` **Hint:** The gate takes two changes: one on the app, one on the tool. What the refusal says is a field of the tool's `approval`. **Solution:** `ApprovalPlugin.init({ storage: { type: "memory" } })` on the app installs the gate, and `approval` on `refund_invoice` puts the tool behind it, with an `approvalMessage` that names `approve_refund`: that's the text the model reads when the call is refused. `get_invoice` has no `approval`, so it isn't gated. ### Challenge: Ask before approving `approve_delete` grants an approval as soon as it's called, so the model can approve its own deletes. Make it ask the user first, and grant only when they say yes. Initial call configuration: ```js { tool: "approve_delete", arguments: {} } ``` ```ts approve-delete.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; export const DELETE_TICKET = "help-desk:delete_ticket"; @Tool({ name: "approve_delete", description: "Ask the user to let you delete support tickets for them. Call it when delete_ticket says it needs approval.", inputSchema: {}, }) export class ApproveDelete extends ToolContext { async execute() { await this.approval.grantUserApproval(DELETE_TICKET); return { approved: true }; } } ``` **Solution file (alternative to the starter):** ```ts approve-delete.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; export const DELETE_TICKET = "help-desk:delete_ticket"; @Tool({ name: "approve_delete", description: "Ask the user to let you delete support tickets for them. Call it when delete_ticket says it needs approval.", inputSchema: {}, }) export class ApproveDelete extends ToolContext { async execute() { const answer = await this.elicit("Allow the assistant to delete support tickets for you?", z.object({ allow: z.boolean() })); if (answer.status !== "accept" || !answer.content?.allow) return { approved: false }; await this.approval.grantUserApproval(DELETE_TICKET); return { approved: true }; } } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { ApprovalPlugin } from "@frontmcp/plugin-approval"; import { ApproveDelete } from "./approve-delete.tool"; import { tickets } from "./store"; @Tool({ name: "delete_ticket", description: "Delete a support ticket for good. Needs the user's approval: call approve_delete first.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, approval: { approvalMessage: "delete_ticket needs the user's approval. Call approve_delete first." }, }) export class DeleteTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, deleted: tickets.delete(id) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [DeleteTicket, ApproveDelete], plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })], }) export class HelpDeskApp {} ``` ```ts store.ts export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }], ["T-3", { id: "T-3", title: "Login link expired", status: "open" }], ]); ``` ```ts server.ts import { HelpDeskApp } from "./help-desk.app"; // Two support agents, each with a key of their own. export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true }, auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] }, }; ``` **Hidden fixture/test file (runs with the example):** ```ts call-as.ts hidden import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./server"; type Answer = { action: "accept" | "decline"; content?: Record }; let server: ReturnType | undefined; export async function callAs(key: string, tool: string, args: Record = {}, answer?: Answer) { server ??= FrontMcpInstance.createFetchHandler(config); const handler = await server; let reply = {}; for (;;) { const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": tool, authorization: `Bearer ${key}`, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args, ...reply, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } }, }, }, }), }), ); const { result } = await response.json(); if (result.resultType !== "input_required") return result; const inputResponses = Object.fromEntries(Object.keys(result.inputRequests).map((id) => [id, answer ?? { action: "decline" }])); reply = { inputResponses, requestState: result.requestState }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts ask.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { callAs } from "./call-as"; const deletes = async (key: string, id: string) => ((await callAs(key, "delete_ticket", { id })).isError ? "refused" : "ran"); test("the user is asked, and saying no approves nothing", async () => { await callAs("agent-sam-key", "approve_delete", {}, { action: "accept", content: { allow: false } }); await callAs("agent-sam-key", "approve_delete", {}, { action: "decline" }); expect(await deletes("agent-sam-key", "T-2")).toBe("refused"); }); test("the user's yes lets `delete_ticket` run", async () => { await callAs("agent-nour-key", "approve_delete", {}, { action: "accept", content: { allow: true } }); expect(await deletes("agent-nour-key", "T-1")).toBe("ran"); }); ``` **Hint:** A tool can pause and ask the person using the client a question. The answer has a `status`, and, when they accepted, `content`. **Solution:** `this.elicit()` shows the user a form with one checkbox, and `approve_delete` grants only when the answer is `accept` with `allow: true`. The checks answer the form as the user would: no, a closed form, and yes. The model can still call `approve_delete`, but under MCP 2026-07-28 it can't answer the form. ### Challenge: Make approvals last an hour at most An approval of `delete_ticket` lasts until it's withdrawn, and users forget they gave one. Make every approval of `delete_ticket` stop working an hour after it's granted. Initial call configuration: ```js { tool: "delete_ticket", arguments: { id: "T-1" } } ``` ```ts help-desk.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { ApprovalPlugin } from "@frontmcp/plugin-approval"; import { ApproveDelete } from "./approve-delete.tool"; import { tickets } from "./store"; @Tool({ name: "delete_ticket", description: "Delete a support ticket for good. Needs the user's approval: call approve_delete first.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, approval: { approvalMessage: "delete_ticket needs the user's approval. Call approve_delete first." }, }) export class DeleteTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, deleted: tickets.delete(id) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [DeleteTicket, ApproveDelete], plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })], }) export class HelpDeskApp {} ``` **Solution file (alternative to the starter):** ```ts help-desk.app.ts solution import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { ApprovalPlugin } from "@frontmcp/plugin-approval"; import { ApproveDelete } from "./approve-delete.tool"; import { tickets } from "./store"; @Tool({ name: "delete_ticket", description: "Delete a support ticket for good. Needs the user's approval, which lasts an hour: call approve_delete first.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, annotations: { destructiveHint: true }, approval: { approvalMessage: "delete_ticket needs the user's approval. Call approve_delete first.", maxTtlMs: 60 * 60_000 }, }) export class DeleteTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, deleted: tickets.delete(id) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [DeleteTicket, ApproveDelete], plugins: [ApprovalPlugin.init({ storage: { type: "memory" } })], }) export class HelpDeskApp {} ``` ```ts approve-delete.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "approve_delete", description: "Ask the user to let you delete support tickets for them. Call it when delete_ticket says it needs approval.", inputSchema: {}, }) export class ApproveDelete extends ToolContext { async execute() { const answer = await this.elicit("Allow the assistant to delete support tickets for you?", z.object({ allow: z.boolean() })); if (answer.status !== "accept" || !answer.content?.allow) return { approved: false }; await this.approval.grantUserApproval("help-desk:delete_ticket"); return { approved: true }; } } ``` ```ts store.ts export const tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }], ["T-3", { id: "T-3", title: "Login link expired", status: "open" }], ]); ``` ```ts server.ts import { HelpDeskApp } from "./help-desk.app"; // Two support agents, each with a key of their own. export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true }, auth: { mode: "static" as const, tokens: ["agent-nour-key", "agent-sam-key"] }, }; ``` **Hidden fixture/test file (runs with the example):** ```ts call-as.ts hidden import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./server"; type Answer = { action: "accept" | "decline"; content?: Record }; let server: ReturnType | undefined; export async function callAs(key: string, tool: string, args: Record = {}, answer?: Answer) { server ??= FrontMcpInstance.createFetchHandler(config); const handler = await server; let reply = {}; for (;;) { const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": tool, authorization: `Bearer ${key}`, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args, ...reply, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } }, }, }, }), }), ); const { result } = await response.json(); if (result.resultType !== "input_required") return result; const inputResponses = Object.fromEntries(Object.keys(result.inputRequests).map((id) => [id, answer ?? { action: "decline" }])); reply = { inputResponses, requestState: result.requestState }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts expiry.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { callAs } from "./call-as"; const NOUR = "agent-nour-key"; const yes = { action: "accept" as const, content: { allow: true } }; const deletes = async (id: string) => ((await callAs(NOUR, "delete_ticket", { id })).isError ? "refused" : "ran"); async function inMinutes(minutes: number, fn: () => Promise) { const realNow = Date.now; const now = realNow(); Date.now = () => now + minutes * 60_000; try { return await fn(); } finally { Date.now = realNow; } } test("an approval works for the first hour", async () => { await callAs(NOUR, "approve_delete", {}, yes); expect(await inMinutes(59, () => deletes("T-1"))).toBe("ran"); }); test("and not after it", async () => { await callAs(NOUR, "approve_delete", {}, yes); expect(await inMinutes(61, () => deletes("T-2"))).toBe("refused"); }); ``` **Hint:** You could change how `approve_delete` grants. But the rule is about `delete_ticket`, whoever grants its approvals: one field of its `approval` says how long any approval of it lasts. **Solution:** `maxTtlMs: 60 * 60_000` in `delete_ticket`'s `approval` is a rule on the tool: the gate ignores any approval of it older than an hour, and a grant through `this.approval` without its own `ttlMs` gets that hour. Granting with `grantTimeLimitedApproval(toolId, 60 * 60_000)` in `approve_delete` would pass the checks too, but only for approvals granted there; `maxTtlMs` also covers any other tool that grants one. The description tells the model how long an approval lasts. --- # Turning Features On and Off > How to put a FrontMCP tool, resource or prompt behind a feature flag with @frontmcp/plugin-feature-flags, what a caller sees while its flag is off, and how to turn a flag on for some callers first. Source: https://frontmcp.dev/learn/turning-features-on-and-off [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A new tool is rarely ready for everyone on the day it's merged. You want the help desk team to try it first, then one customer, then everybody, and you want a way to switch it off again without a deploy when something goes wrong. A **feature flag** is a named switch that a flag service answers per caller. `@frontmcp/plugin-feature-flags` connects flags to a FrontMCP server: give a tool, resource or prompt `featureFlag: "bulk-export"`, and while that flag is off for the caller, the entry isn't listed and can't be called. **You will learn** - Why a switch inside `execute()` is the wrong way to hide a tool - How to put a tool, resource or prompt behind a flag - What a caller sees while a flag is off - How to make a flag default to on, as a kill switch - How to turn a flag on for some callers, and read it inside a tool ## A tool that isn't ready yet The help desk is building `bulk_export`, which exports every ticket as CSV. It works, but it's slow on big accounts, so it shouldn't be used yet. A first attempt keeps it switched off with a constant: Initial call configuration: ```js { tool: "bulk_export", arguments: {} } ``` ```ts tools.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./store"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title", inputSchema: { query: z.string() }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) }; } } // 🚩 Not ready: switched off in code const BULK_EXPORT_READY = false; @Tool({ name: "bulk_export", description: "Export every support ticket as CSV", inputSchema: {}, annotations: { readOnlyHint: true }, }) export class BulkExport extends ToolContext { async execute() { if (!BULK_EXPORT_READY) this.fail(new PublicMcpError("Bulk export isn't available yet.")); return { csv: ["id,title,status", ...tickets.map((t) => `${t.id},${t.title},${t.status}`)].join("\n") }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { BulkExport, SearchTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, BulkExport] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts store.ts export 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" }, ]; ``` ```ts not-ready.test.ts import { test, expect } from "@frontmcp/testing"; test("🚩 the model is offered bulk_export", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("bulk_export"); }); test("🚩 and every call to it fails", async ({ mcp }) => { const result = await mcp.tools.call("bulk_export", {}); expect(result).toBeError(); expect(result).toHaveTextContent("Bulk export isn't available yet."); }); ``` Nobody can export, so in that sense it works. But the model still reads `bulk_export` in its tool list, with a description that promises an export, and it only learns the truth after spending a call on it. And the switch is all or nothing: turning it on means a deploy, for every caller at once. There's no way to let the team try it first. ## Putting a tool behind a flag Install the plugin: ```bash npm install @frontmcp/plugin-feature-flags ``` Then give the tool a `featureFlag`, and register the plugin with the flags' values. The tool loses its `if`: Initial call configuration: ```js { tool: "bulk_export", arguments: {} } ``` ```ts tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./store"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title", inputSchema: { query: z.string() }, annotations: { readOnlyHint: true }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) }; } } @Tool({ name: "bulk_export", description: "Export every support ticket as CSV", inputSchema: {}, annotations: { readOnlyHint: true }, featureFlag: "bulk-export", // ✅ exists only while this flag is on }) export class BulkExport extends ToolContext { async execute() { return { csv: ["id,title,status", ...tickets.map((t) => `${t.id},${t.title},${t.status}`)].join("\n") }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags"; import { BulkExport, SearchTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, BulkExport], plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: { "bulk-export": false } })], }) export class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts store.ts export 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" }, ]; ``` ```ts flag.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags"; import { BulkExport, SearchTickets } from "./tools"; test("bulk_export isn't listed while its flag is off", async ({ mcp }) => { const tools = await mcp.tools.list(); expect(tools).not.toContainTool("bulk_export"); expect(tools).toContainTool("search_tickets"); }); test("calling it by name anyway is refused", async ({ mcp }) => { const result = await mcp.tools.call("bulk_export", {}); expect(result).toBeError("FEATURE_FLAG_DISABLED"); expect(result).toHaveTextContent('Tool "bulk_export" is disabled by feature flag "bulk-export"'); }); test("🚩 the class without init() has no adapter, and the server doesn't start", async () => { @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, BulkExport], plugins: [FeatureFlagPlugin] }) class WithoutInit {} const startup = FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [WithoutInit] }); await expect(startup).rejects.toThrow("FeatureFlagPlugin.init() requires an `adapter` option, got undefined."); }); test("🚩 without the plugin, the server doesn't start", async () => { @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, BulkExport] }) class WithoutPlugin {} const startup = FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [WithoutPlugin] }); await expect(startup).rejects.toThrow(`Tool "bulk_export" declares 'featureFlag'`); }); ``` Open the **Capabilities** tab: only `search_tickets` is listed. Now change `false` to `true` in `main.ts`, and `bulk_export` is back, working. - **`featureFlag: "bulk-export"`** names the flag that decides whether the tool exists for this caller. The key is any string; it's the name the flag has in your flag service. - **`FeatureFlagPlugin.init({ adapter, ... })`** registers the plugin. The **adapter** is where flag values come from. `"static"` takes them from `flags`, the same for every caller: good for development, for tests, and for a switch you're happy to change with a deploy. The [last section](https://frontmcp.dev/learn/turning-features-on-and-off#turning-a-flag-on-for-some-callers) connects a flag service. - **A call to a tool whose flag is off** is refused before `execute()` runs, with the message above. That covers clients that call the tool by name, for example from a list they fetched before the flag changed. A tool with `featureFlag` and no plugin to check it is a mistake FrontMCP won't let through: the server refuses to start with `Unenforced metadata: Tool "bulk_export" declares 'featureFlag' (enforced by FeatureFlagPlugin …)`, as the last test shows. FrontMCP refuses, rather than list and run the tool for everyone. > **Pitfall: Register the plugin with init()** > > `FeatureFlagPlugin.init()` with no options, or with an `adapter` it doesn't know, throws a `FeatureFlagConfigurationError` when the module loads, naming the supported adapters. `plugins: [FeatureFlagPlugin]`, the class without `init()`, has no adapter either, so the server doesn't start, with the same error. Always pass the options, with at least `adapter`. (Before FrontMCP 1.9.4 the class started, and then `tools/list` failed for every caller with `Provider "[ref]" is not available`.) > **Note** > > On an `@App`, as here, the plugin checks that app's entries, and those of any app without a feature-flag plugin of its own. On `@FrontMcp`, it checks every app's. [Which entries a plugin gates](https://frontmcp.dev/reference/plugins/feature-flags#which-entries-a-plugin-gates) has the details. ## On by default: kill switches A flag the adapter has no value for counts as off. That's the right default for something new: `bulk_export` stays hidden until someone decides otherwise. For a tool people already depend on, you want the opposite: on, unless someone switches it off in an emergency. Give that tool `featureFlag: { key, defaultValue: true }`: Initial call configuration: ```js { tool: "create_ticket", arguments: { title: "Printer on fire" } } ``` ```ts tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "create_ticket", description: "Open a support ticket", inputSchema: { title: z.string() }, // ✅ A kill switch: on unless the flag is set to false featureFlag: { key: "ticket-intake", defaultValue: true }, }) export class CreateTicket extends ToolContext { async execute({ title }: { title: string }) { return { id: "T-4", title, status: "open" }; } } @Tool({ name: "ai_summary", description: "Summarize a ticket's thread", inputSchema: { id: z.string() }, featureFlag: "ai-summary", // new: off until turned on }) export class AiSummary extends ToolContext { async execute({ id }: { id: string }) { return { id, summary: "The customer can't log in since this morning." }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags"; import { AiSummary, CreateTicket } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [CreateTicket, AiSummary], plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: {} })], // neither flag has a value }) export class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts defaults.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags"; import { AiSummary, CreateTicket } from "./tools"; /** The tools a server lists with these flag values. */ async function listedWith(flags: Record, gateDefaultValue = false) { @App({ id: "help-desk", name: "Help Desk", tools: [CreateTicket, AiSummary], plugins: [FeatureFlagPlugin.init({ adapter: "static", flags, gateDefaultValue })] }) class HelpDesk {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] }); const { tools } = await server.listTools(); await server.dispose(); return tools.map((t: { name: string }) => t.name).sort(); } test("a flag with no value is off, unless the tool gives a defaultValue", async ({ mcp }) => { expect((await mcp.tools.list()).map((t: { name: string }) => t.name)).toEqual(["create_ticket"]); }); test("setting the flags turns each tool on or off", async () => { expect(await listedWith({ "ai-summary": true })).toEqual(["ai_summary", "create_ticket"]); expect(await listedWith({ "ticket-intake": false })).toEqual([]); // the kill switch }); test("`gateDefaultValue: true` turns on every flag with no value; a flag set to false stays off", async () => { expect(await listedWith({}, true)).toEqual(["ai_summary", "create_ticket"]); expect(await listedWith({ "ai-summary": false }, true)).toEqual(["create_ticket"]); }); ``` `defaultValue` only fills in when the adapter has no answer for the flag, or fails while a call is checked. A flag set to `false` stays off, whatever the tool's `defaultValue` says, so the kill switch works. "No answer" means the adapter left the flag out: the static adapter does that for keys you didn't configure. LaunchDarkly, Split and Unleash answer every key, with their own default for one they don't know, which is off. So with a flag service, create the kill switch there, set to on. The tool's `defaultValue: true` still matters: if the service fails while a call is checked, the call goes through. (A list fails outright during an outage; see [When the flag service fails](https://frontmcp.dev/reference/plugins/feature-flags#when-the-flag-service-fails).) The plugin can flip that default for every tool at once: `FeatureFlagPlugin.init({ adapter, flags, gateDefaultValue: true })` treats every flag without a value as on, for the tools that don't give a `defaultValue` of their own, as the last test shows. That fails open: a new tool is out for everyone the moment it ships, and those tools stay callable while the flag service is down. Keep the default, `false`, and give each kill switch its own `defaultValue: true`. > **Pitfall: A misspelled flag hides the tool** > > Because a flag without a value is off, a typo is silent: `featureFlag: "bulk_export"` on the tool and `"bulk-export": true` in the flags hides the tool for everyone, with no error. Keep flag keys in one place, like `export const BULK_EXPORT = "bulk-export"`, and use the constant on both sides. ## Flagging resources and prompts `@Resource`, `@ResourceTemplate` and `@Prompt` take `featureFlag` too. The help desk's new insights, a ticket statistics resource and a weekly review prompt, share one flag, `insights`, which is off: Initial call configuration: ```js { resource: "tickets://stats" } ``` ```ts insights.ts active import { Prompt, PromptContext, Resource, ResourceContext, type GetPromptResult } from "@frontmcp/sdk"; @Resource({ name: "ticket_stats", uri: "tickets://stats", mimeType: "application/json", featureFlag: "insights" }) export class TicketStats extends ResourceContext { async execute() { return { open: 2, closed: 1 }; } } @Prompt({ name: "weekly_review", description: "Review the week's support tickets", arguments: [], featureFlag: "insights" }) export class WeeklyReview extends PromptContext { async execute(): Promise { return { messages: [{ role: "user", content: { type: "text", text: "Read tickets://stats and summarize the week." } }] }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags"; import { TicketStats, WeeklyReview } from "./insights"; @App({ id: "help-desk", name: "Help Desk", resources: [TicketStats], prompts: [WeeklyReview], plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: { insights: false } })], }) export class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts insights.test.ts import { test, expect } from "@frontmcp/testing"; test("neither is listed", async ({ mcp }) => { expect(await mcp.resources.list()).not.toContainResource("tickets://stats"); expect(await mcp.prompts.list()).toHaveLength(0); }); test("reading the resource is refused with a JSON-RPC error", async ({ mcp }) => { const read = await mcp.raw.request({ method: "resources/read", params: { uri: "tickets://stats" } }); expect(read.error).toMatchObject({ code: -32003, message: 'Resource "ticket_stats" is disabled by feature flag "insights"', data: { code: "FEATURE_FLAG_DISABLED" } }); }); test("so is getting the prompt", async ({ mcp }) => { const prompt = await mcp.raw.request({ method: "prompts/get", params: { name: "weekly_review", arguments: {} } }); expect(prompt.error?.message).toBe('Prompt "weekly_review" is disabled by feature flag "insights"'); }); ``` While a flag is off for the caller: | Request | What the caller gets | | --- | --- | | `tools/list`, `resources/list`, `resources/templates/list`, `prompts/list` | The entry is left out. | | `tools/call` | A tool error with the code `FEATURE_FLAG_DISABLED`: `Tool "bulk_export" is disabled by feature flag "bulk-export"`. | | `resources/read`, `prompts/get` | JSON-RPC error `-32003`, with `data.code` `FEATURE_FLAG_DISABLED`: `Resource "ticket_stats" is disabled by feature flag "insights"`, or the same for a prompt. | Those messages name the flag, and they reach the caller as written, in production too: a refusal is a public error, so a caller who tries a hidden entry by name learns its flag's key. Keep flag keys you don't mind showing. Skills and agents take `featureFlag` too; the [plugin's reference](https://frontmcp.dev/reference/plugins/feature-flags#the-featureflag-option) covers them. > **Note** > > Changed in 1.8.7: a refusal used to be wrapped as an internal error. Its `_meta.code` was `SERVER_ERROR`, a resource or prompt answered `-32603`, in development the text went on with `Original error:` and a stack trace, and in production the caller read `Internal FrontMCP error. Please contact support with error ID: …` instead of the message. ## Turning a flag on for some callers A static adapter gives every caller the same answer. A flag service, like LaunchDarkly, Split or Unleash, answers per caller: on for the help desk team, on for one customer's tenant, on for 10% of users. The plugin asks it on every request, about the caller who sent it, and passes it: - **`userId`**: the caller's id from their token, `this.auth.user.sub`. - **`attributes`**: whatever your `attributesResolver` returns, like the caller's tenant or plan. Here a small custom adapter stands in for the flag service: `bulk-export` is on for the `acme` tenant, and `ai-summary` for the support agent `sam`. The tests call the server in-process as two signed-in agents, `nour` from `acme`, and `sam`, who has no tenant; the Playground's own calls are anonymous: Initial call configuration: ```js { tool: "export_formats", arguments: {} } ``` ```ts main.ts active import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags"; import { RolloutAdapter } from "./rollout-adapter"; @Tool({ name: "bulk_export", description: "Export every support ticket as CSV", inputSchema: {}, featureFlag: "bulk-export" }) class BulkExport extends ToolContext { async execute() { return { csv: "id,title,status\nT-1,Cannot log in,open" }; } } @Tool({ name: "ai_summary", description: "Summarize a ticket's thread", inputSchema: { id: z.string() }, featureFlag: "ai-summary" }) class AiSummary extends ToolContext { async execute({ id }: { id: string }) { return { id, summary: "The customer can't log in since this morning." }; } } @Tool({ name: "export_formats", description: "List the formats this user can export tickets in", inputSchema: {} }) class ExportFormats extends ToolContext { async execute() { const bulk = await this.featureFlags.isEnabled("bulk-export"); // for this caller return { formats: bulk ? ["pdf", "csv"] : ["pdf"] }; } } @App({ id: "help-desk", name: "Help Desk", tools: [BulkExport, AiSummary, ExportFormats], plugins: [ FeatureFlagPlugin.init({ adapter: "custom", adapterInstance: new RolloutAdapter({ "bulk-export": { tenants: ["acme"] }, "ai-summary": { users: ["sam"] } }), // The caller's tenant, from a claim in their token attributesResolver: (ctx) => ({ tenant: (ctx.authInfo as { user?: { tenant?: string } }).user?.tenant }), }), ], }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }; @FrontMcp(config) export default class Server {} ``` ```ts rollout-adapter.ts import type { FeatureFlagAdapter, FeatureFlagContext, FeatureFlagVariant } from "@frontmcp/plugin-feature-flags"; type Rule = { users?: string[]; tenants?: string[] }; /** Flags that are on for some users and tenants. Stands in for a flag service. */ export class RolloutAdapter implements FeatureFlagAdapter { constructor(private readonly rules: Record) {} async initialize() {} async destroy() {} async isEnabled(flagKey: string, { userId, attributes }: FeatureFlagContext): Promise { const rule = this.rules[flagKey]; if (!rule) return false; return (!!userId && !!rule.users?.includes(userId)) || !!rule.tenants?.includes(String(attributes?.tenant)); } async getVariant(flagKey: string, context: FeatureFlagContext): Promise { const enabled = await this.isEnabled(flagKey, context); return { name: enabled ? "on" : "off", value: enabled, enabled }; } // What lists, calls and this.featureFlags.isEnabled() use. Flags without a rule are left out, so a defaultValue applies to them. async evaluateFlags(flagKeys: string[], context: FeatureFlagContext): Promise> { const results = new Map(); for (const key of flagKeys) { if (key in this.rules) results.set(key, await this.isEnabled(key, context)); } return results; } } ``` ```ts targeting.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; // Two signed-in support agents, with the claims their tokens would carry const nour = { authContext: { user: { sub: "nour", tenant: "acme" } } }; const sam = { authContext: { user: { sub: "sam" } } }; test("each agent sees the tools their flags turn on", async () => { const server = await FrontMcpInstance.createDirect(config); const names = async (as: object) => (await server.listTools(as)).tools.map((t: { name: string }) => t.name).sort(); expect(await names(nour)).toEqual(["bulk_export", "export_formats"]); expect(await names(sam)).toEqual(["ai_summary", "export_formats"]); await server.dispose(); }); test("a tool is refused to an agent whose flag is off", async () => { const server = await FrontMcpInstance.createDirect(config); expect((await server.callTool("bulk_export", {}, nour)).structuredContent).toMatchObject({ csv: expect.any(String) }); await expect(server.callTool("bulk_export", {}, sam)).rejects.toThrow('Tool "bulk_export" is disabled by feature flag "bulk-export"'); await server.dispose(); }); test("this.featureFlags answers for the caller", async () => { const server = await FrontMcpInstance.createDirect(config); expect((await server.callTool("export_formats", {}, nour)).structuredContent).toEqual({ formats: ["pdf", "csv"] }); expect((await server.callTool("export_formats", {}, sam)).structuredContent).toEqual({ formats: ["pdf"] }); await server.dispose(); }); test("an anonymous caller gets neither", async ({ mcp }) => { expect((await mcp.tools.list()).map((t: { name: string }) => t.name)).toEqual(["export_formats"]); }); ``` Open the **Capabilities** tab: the Playground calls anonymously, so it only sees `export_formats`, and gets `["pdf"]` from it. The tests show the other two callers. - **`adapter: "custom"`** with `adapterInstance` takes any object with the adapter's five methods: `init()` throws a `FeatureFlagConfigurationError` when it lacks `isEnabled()`, `getVariant()` or `evaluateFlags()`. `evaluateFlags()` is what lists, calls and `this.featureFlags.isEnabled()` use; leave out a key you have no answer for, so the `defaultValue` applies. - **`attributesResolver`** receives the request's context. `ctx.authInfo.user` holds the claims of the caller's token, like `tenant` here. - **`this.featureFlags`**, which the plugin adds to every tool, resource and prompt of the apps it's registered for, reads a flag inside `execute()`, for the same caller. Use it when a flag changes what a tool does rather than whether it exists, as `export_formats` does. An anonymous caller has no `userId`, so a flag service can't tell one from another: a rollout to 10% of users gives all of them the same answer. Target signed-in callers. With a real flag service, only the adapter changes. For LaunchDarkly, install its SDK (`npm install @launchdarkly/node-server-sdk`) and keep the resolver: ```ts FeatureFlagPlugin.init({ adapter: "launchdarkly", config: { sdkKey: process.env.LAUNCHDARKLY_SDK_KEY! }, attributesResolver: (ctx) => ({ tenant: (ctx.authInfo as { user?: { tenant?: string } }).user?.tenant }), }); ``` `"splitio"` and `"unleash"` work the same way, with their own `config`. [LaunchDarkly, Split and Unleash](https://frontmcp.dev/reference/plugins/feature-flags#launchdarkly-split-and-unleash) lists each one's options, and [When the flag service fails](https://frontmcp.dev/reference/plugins/feature-flags#when-the-flag-service-fails) what happens during an outage. > **Note** > > `this.featureFlags.isEnabled("new-editor", true)` gives `true` for a flag the adapter has no answer for, as `defaultValue` does on an entry, and when the adapter fails. Changed in 1.9: it used its default only when the adapter threw, and the static adapter said `false` for a key it didn't know. ## Recap - A switch inside `execute()` still lists the tool, so the model finds out it's off only by calling it. `featureFlag` on the entry hides it instead. - `@frontmcp/plugin-feature-flags` adds `featureFlag` to tools, resources, resource templates, prompts, skills and agents. Register it with `FeatureFlagPlugin.init({ adapter, ... })`; an entry with `featureFlag` and no plugin stops the server from starting. - While a flag is off for the caller, the entry is left out of lists, and calls, reads and gets are refused. - A flag without a value is off. `featureFlag: { key, defaultValue: true }` makes it on until it's set to `false`, for kill switches. - A flag service answers per caller, from their `userId` and the `attributes` your `attributesResolver` returns. `this.featureFlags` reads a flag inside a tool, for the same caller. - Every option, the adapters, and what happens when the flag service fails are in the [feature flags plugin reference](https://frontmcp.dev/reference/plugins/feature-flags). ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Hide the merge tool `merge_tickets` isn't finished, and the plugin already has a `ticket-merge` flag for it, set to `false`. The tool is still listed and still runs. Put it behind the flag. Initial call configuration: ```js { tool: "merge_tickets", arguments: { from: "T-3", into: "T-1" } } ``` ```ts tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Tool({ name: "merge_tickets", description: "Merge a duplicate ticket into another one", inputSchema: { from: z.string(), into: z.string() }, }) export class MergeTickets extends ToolContext { async execute({ from, into }: { from: string; into: string }) { return { merged: from, into }; } } ``` **Solution file (alternative to the starter):** ```ts tools.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Tool({ name: "merge_tickets", description: "Merge a duplicate ticket into another one", inputSchema: { from: z.string(), into: z.string() }, featureFlag: "ticket-merge", }) export class MergeTickets extends ToolContext { async execute({ from, into }: { from: string; into: string }) { return { merged: from, into }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags"; import { GetTicket, MergeTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, MergeTickets], plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: { "ticket-merge": false } })], }) export class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` **Hidden fixture/test file (runs with the example):** ```ts merge.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`merge_tickets` isn't listed", async ({ mcp }) => { expect(await mcp.tools.list()).not.toContainTool("merge_tickets"); }); test("calling it is refused by the `ticket-merge` flag", async ({ mcp }) => { const result = await mcp.tools.call("merge_tickets", { from: "T-3", into: "T-1" }); expect(result).toBeError(); expect(result).toHaveTextContent('disabled by feature flag "ticket-merge"'); }); test("`get_ticket` is still listed and works", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("get_ticket"); expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeSuccessful(); }); ``` **Hint:** The flag's value is already in `main.ts`. What's missing is the link from the tool to the flag's key. **Solution:** `featureFlag: "ticket-merge"` ties the tool to the flag, and the plugin, already registered on the app, does the rest: while the flag is `false`, `merge_tickets` is left out of `tools/list` and refused when called by name. Setting the flag to `true` in `main.ts` brings it back without touching the tool. ### Challenge: Make ticket intake a kill switch Someone put `create_ticket` behind a `ticket-intake` flag, so it can be switched off if the ticket database struggles. But the flags in `main.ts` don't set it, so the tool vanished for everyone. Make `create_ticket` stay on while the flag has no value, and still go away when the flag is set to `false`. Initial call configuration: ```js { tool: "create_ticket", arguments: { title: "Printer on fire" } } ``` ```ts tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "create_ticket", description: "Open a support ticket", inputSchema: { title: z.string() }, featureFlag: "ticket-intake", }) export class CreateTicket extends ToolContext { async execute({ title }: { title: string }) { return { id: "T-4", title, status: "open" }; } } ``` **Solution file (alternative to the starter):** ```ts tools.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "create_ticket", description: "Open a support ticket", inputSchema: { title: z.string() }, featureFlag: { key: "ticket-intake", defaultValue: true }, }) export class CreateTicket extends ToolContext { async execute({ title }: { title: string }) { return { id: "T-4", title, status: "open" }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags"; import { CreateTicket } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [CreateTicket], plugins: [FeatureFlagPlugin.init({ adapter: "static", flags: {} })], }) export class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` **Hidden fixture/test file (runs with the example):** ```ts intake.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags"; import { CreateTicket } from "./tools"; async function listedWith(flags: Record) { @App({ id: "help-desk", name: "Help Desk", tools: [CreateTicket], plugins: [FeatureFlagPlugin.init({ adapter: "static", flags })] }) class HelpDesk {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] }); const { tools } = await server.listTools(); await server.dispose(); return tools.map((t: { name: string }) => t.name); } test("with no value for `ticket-intake`, `create_ticket` is listed and works", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("create_ticket"); expect(await mcp.tools.call("create_ticket", { title: "Printer on fire" })).toBeSuccessful(); expect(await listedWith({})).toEqual(["create_ticket"]); }); test("setting `ticket-intake` to false still switches it off", async () => { expect(await listedWith({ "ticket-intake": false })).toEqual([]); }); ``` **Hint:** `featureFlag` also takes an object, with the key and what to assume when the adapter has no value for it. **Solution:** `featureFlag: { key: "ticket-intake", defaultValue: true }` treats a flag without a value as on, so the tool is back for everyone. A value of `false` still wins over `defaultValue`, so the switch still works. Adding `"ticket-intake": true` to `main.ts` would bring the tool back too, but only for that one configuration: the checks also start servers whose flags don't set it. The default belongs on the tool. ### Challenge: Roll out bulk export to one tenant The rollout adapter turns `bulk-export` on for the `acme` tenant, and `nour`'s token says she's from `acme`. But she doesn't see `bulk_export`: the adapter never learns her tenant. Pass it on, from the `tenant` claim of the caller's token. Initial call configuration: ```js { tool: "search_tickets", arguments: {} } ``` ```ts main.ts active import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk"; import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags"; import { RolloutAdapter } from "./rollout-adapter"; @Tool({ name: "search_tickets", description: "Search support tickets", inputSchema: {} }) class SearchTickets extends ToolContext { async execute() { return { tickets: ["T-1", "T-3"] }; } } @Tool({ name: "bulk_export", description: "Export every support ticket as CSV", inputSchema: {}, featureFlag: "bulk-export" }) class BulkExport extends ToolContext { async execute() { return { csv: "id,title,status\nT-1,Cannot log in,open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, BulkExport], plugins: [ FeatureFlagPlugin.init({ adapter: "custom", adapterInstance: new RolloutAdapter({ "bulk-export": { tenants: ["acme"] } }), }), ], }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }; @FrontMcp(config) export default class Server {} ``` **Solution file (alternative to the starter):** ```ts main.ts solution import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk"; import { FeatureFlagPlugin } from "@frontmcp/plugin-feature-flags"; import { RolloutAdapter } from "./rollout-adapter"; @Tool({ name: "search_tickets", description: "Search support tickets", inputSchema: {} }) class SearchTickets extends ToolContext { async execute() { return { tickets: ["T-1", "T-3"] }; } } @Tool({ name: "bulk_export", description: "Export every support ticket as CSV", inputSchema: {}, featureFlag: "bulk-export" }) class BulkExport extends ToolContext { async execute() { return { csv: "id,title,status\nT-1,Cannot log in,open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, BulkExport], plugins: [ FeatureFlagPlugin.init({ adapter: "custom", adapterInstance: new RolloutAdapter({ "bulk-export": { tenants: ["acme"] } }), attributesResolver: (ctx) => ({ tenant: (ctx.authInfo as { user?: { tenant?: string } }).user?.tenant }), }), ], }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }; @FrontMcp(config) export default class Server {} ``` ```ts rollout-adapter.ts import type { FeatureFlagAdapter, FeatureFlagContext, FeatureFlagVariant } from "@frontmcp/plugin-feature-flags"; type Rule = { users?: string[]; tenants?: string[] }; /** Flags that are on for some users and tenants. Stands in for a flag service. */ export class RolloutAdapter implements FeatureFlagAdapter { constructor(private readonly rules: Record) {} async initialize() {} async destroy() {} async isEnabled(flagKey: string, { userId, attributes }: FeatureFlagContext): Promise { const rule = this.rules[flagKey]; if (!rule) return false; return (!!userId && !!rule.users?.includes(userId)) || !!rule.tenants?.includes(String(attributes?.tenant)); } async getVariant(flagKey: string, context: FeatureFlagContext): Promise { const enabled = await this.isEnabled(flagKey, context); return { name: enabled ? "on" : "off", value: enabled, enabled }; } async evaluateFlags(flagKeys: string[], context: FeatureFlagContext): Promise> { const results = new Map(); for (const key of flagKeys) { if (key in this.rules) results.set(key, await this.isEnabled(key, context)); } return results; } } ``` **Hidden fixture/test file (runs with the example):** ```ts rollout.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; const nour = { authContext: { user: { sub: "nour", tenant: "acme" } } }; const sam = { authContext: { user: { sub: "sam", tenant: "globex" } } }; async function listedFor(as: object) { const server = await FrontMcpInstance.createDirect(config); const { tools } = await server.listTools(as); await server.dispose(); return tools.map((t: { name: string }) => t.name).sort(); } test("`nour`, from acme, sees `bulk_export`", async () => { expect(await listedFor(nour)).toEqual(["bulk_export", "search_tickets"]); }); test("`sam`, from another tenant, doesn't", async () => { expect(await listedFor(sam)).toEqual(["search_tickets"]); }); test("neither does an anonymous caller", async ({ mcp }) => { expect(await mcp.tools.list()).not.toContainTool("bulk_export"); }); ``` **Hint:** The adapter checks `attributes.tenant`. Which plugin option decides what `attributes` holds? **Solution:** `attributesResolver` runs on every request, and what it returns reaches the adapter as `attributes`. Reading `tenant` from `ctx.authInfo.user`, the caller's token claims, gives the adapter `acme` for `nour`, so `bulk-export` is on for her, and something else, or nothing, for everyone else. --- # Letting the Model Write Code with CodeCall > Why long tool lists cost a model, and how @frontmcp/plugin-codecall lets it search tools, read their schemas and call them directly or from a short server-side script. Source: https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Every tool you add is one more name, description and schema that the model reads before it answers anything. At a dozen tools that's nothing. At a few hundred, the list crowds the model's context, similar tools blur together, and a task that takes five calls takes five round trips, with every result passing through the model. `@frontmcp/plugin-codecall` changes what a FrontMCP server shows the model: instead of your tools, a few CodeCall tools that it uses to search yours, read the ones it needs, and call them, one at a time or from a short script that runs on your server and sends back only the answer. **You will learn** - What a long tool list costs a model - How to put your tools behind CodeCall, and when it's worth it - How the model finds tools with `codecall:search` and reads them with `codecall:describe` - How it calls them, one with `codecall:invoke`, or several from a `codecall:execute` script - What a script may do, and how to keep tools out of its reach ## A server with too many tools The help desk has grown. It has seven areas now, tickets, customers, invoices, agents, macros, articles and SLA policies, each with five actions. That's 35 tools, written here with a loop to keep the example short. Open the **Tests** tab: ```ts tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; const areas = ["ticket", "customer", "invoice", "agent", "macro", "article", "sla policy"]; const actions: [string, (area: string) => string][] = [ ["list", (area) => `List ${area}s, newest first. Filter by status and page through the results.`], ["get", (area) => `Get one ${area} by its id, with every field.`], ["create", (area) => `Create a new ${area}. Returns the new record with its id.`], ["update", (area) => `Update the fields of one ${area}. Fields you leave out keep their value.`], ["delete", (area) => `Delete one ${area}. This can't be undone.`], ]; export const helpDeskTools = areas.flatMap((area) => actions.map(([action, describe]) => { @Tool({ name: `${action}_${area.replace(" ", "_")}`, description: describe(area), inputSchema: { id: z.string().optional().describe(`The ${area}'s id`), status: z.enum(["open", "pending", "closed"]).optional().describe("Only records with this status"), page: z.number().int().min(1).optional().describe("Page number, from 1"), pageSize: z.number().int().min(1).max(100).optional().describe("Records per page, up to 100"), }, }) class AreaTool extends ToolContext { async execute(input: { id?: string; status?: "open" | "pending" | "closed"; page?: number; pageSize?: number }) { return { action, area, ...input }; } } return AreaTool; }), ); ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { helpDeskTools } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: helpDeskTools }) export class HelpDeskApp {} ``` ```ts list.test.ts import { test, expect } from "@frontmcp/testing"; test("🚩 the model is sent all 35 tools, every schema included", async ({ mcp }) => { const tools = await mcp.tools.list(); expect(tools).toHaveLength(35); expect(JSON.stringify(tools).length).toBeGreaterThan(19_000); // about 20 KB }); test("🚩 and the tools look alike", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t: { name: string }) => t.name); expect(names.filter((name: string) => name.endsWith("_ticket"))).toEqual([ "create_ticket", "delete_ticket", "get_ticket", "list_ticket", "update_ticket", ]); }); ``` Nothing here is wrong, and a client lists all 35 tools. Open the **Capabilities** tab and scroll: that's what the model reads before every answer, about 20 KB of names, descriptions and schemas, and each new tool adds half a kilobyte more. Three problems grow with the list: 1. **Context.** The whole list is sent with every request to the model, whether the conversation needs one tool or none. At 200 tools it's over 100 KB. 2. **Choice.** The more tools look alike, the more often the model picks the wrong one, or calls two to be sure. 3. **Round trips.** "Which urgent tickets are open, and whose are they?" takes a search and then a customer lookup per ticket. Each call is a round trip through the model, and each result, every field of every ticket, lands in its context, where it stays. ## Putting the tools behind CodeCall Install the plugin: ```bash npm install @frontmcp/plugin-codecall ``` It needs Node 24 or later. Then register it on the app: Initial call configuration: ```js { tool: "codecall:search", arguments: { queries: ["update ticket", "list invoices"] } } ``` ```ts help-desk.app.ts active import { App } from "@frontmcp/sdk"; import { CodeCallPlugin } from "@frontmcp/plugin-codecall"; import { helpDeskTools } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: helpDeskTools, plugins: [CodeCallPlugin.init({ mode: "codecall_only" })], }) export class HelpDeskApp {} ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const areas = ["ticket", "customer", "invoice", "agent", "macro", "article", "sla policy"]; const actions: [string, (area: string) => string][] = [ ["list", (area) => `List ${area}s, newest first. Filter by status and page through the results.`], ["get", (area) => `Get one ${area} by its id, with every field.`], ["create", (area) => `Create a new ${area}. Returns the new record with its id.`], ["update", (area) => `Update the fields of one ${area}. Fields you leave out keep their value.`], ["delete", (area) => `Delete one ${area}. This can't be undone.`], ]; export const helpDeskTools = areas.flatMap((area) => actions.map(([action, describe]) => { @Tool({ name: `${action}_${area.replace(" ", "_")}`, description: describe(area), inputSchema: { id: z.string().optional().describe(`The ${area}'s id`), status: z.enum(["open", "pending", "closed"]).optional().describe("Only records with this status"), page: z.number().int().min(1).optional().describe("Page number, from 1"), pageSize: z.number().int().min(1).max(100).optional().describe("Records per page, up to 100"), }, }) class AreaTool extends ToolContext { async execute(input: { id?: string; status?: "open" | "pending" | "closed"; page?: number; pageSize?: number }) { return { action, area, ...input }; } } return AreaTool; }), ); ``` ```ts codecall.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { CodeCallPlugin } from "@frontmcp/plugin-codecall"; import { helpDeskTools } from "./tools"; test("tools/list shows CodeCall's tools instead of the app's", async ({ mcp }) => { expect((await mcp.tools.list()).map((t: { name: string }) => t.name)).toEqual([ "codecall:describe", "codecall:execute", "codecall:invoke", "codecall:search", "codecall:searchKnowledge", "codecall:searchSkills", ]); }); test("the list is the same size, however many tools are behind it", async ({ mcp }) => { const small = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: helpDeskTools.slice(0, 5), plugins: [CodeCallPlugin.init({ mode: "codecall_only" })], }); const fiveTools = (await small.listTools()).tools; await small.dispose(); expect(JSON.stringify(await mcp.tools.list()).length).toBe(JSON.stringify(fiveTools).length); }); test("CodeCall can use all 35 tools, and finds them", async ({ mcp }) => { const { tools, totalAvailableTools } = (await mcp.tools.call("codecall:search", { queries: ["update ticket", "list invoices"] })).json(); expect(totalAvailableTools).toBe(35); expect(tools.slice(0, 2).map((t: { name: string }) => t.name).sort()).toEqual(["list_invoice", "update_ticket"]); }); ``` Open **Capabilities** again: the app's 35 tools are gone from the list, and CodeCall's six are there instead. The **Call** tab shows the model searching for two of the tools it can no longer see. The model now works in steps: | Tool | What the model does with it | | --- | --- | | `codecall:search` | Finds tools for short phrases, like `"close ticket"`. | | `codecall:describe` | Reads the input and output schemas of the tools it picked. | | `codecall:invoke` | Calls one tool, and gets its result. | | `codecall:execute` | Runs a short script that calls several tools, and gets what the script returns. | | `codecall:searchSkills`, `codecall:searchKnowledge` | Search the server's [skills](https://frontmcp.dev/learn/teaching-the-model-skills). | Each has a long description written for the model, which explains that flow. Every tool call still goes through FrontMCP's normal flow, with the caller's credentials: validation, hooks and [authorization](https://frontmcp.dev/learn/authorizing-calls) all apply, as for a direct call. `mode: "codecall_only"` is the default, and the usual choice: CodeCall's tools are listed, yours aren't. `"codecall_opt_in"` lists every tool as before and lets CodeCall use only the ones marked `codecall: { enabledInCodeCall: true }`, which is a way to try CodeCall on a server whose clients already use the tools directly. In `codecall_only`, a client can't call your tools directly any more: a `tools/call` for one that isn't listed gets `Tool "…" not found`. [Modes](https://frontmcp.dev/reference/plugins/codecall#modes) lists all three. > **Pitfall: CodeCall's own tools aren't small** > > Those six descriptions and schemas come to about 21 KB, what 35 to 40 small tools like these take, and it stays that size however many tools are behind them. For a server with a dozen tools, list them directly: CodeCall costs the model more than it saves. It pays off when your own list is bigger than that, or when the model's tasks need several calls in a row. > **Note** > > It makes no difference whether you register CodeCall on one `@App` or on `@FrontMcp`: either way it hides every app's tools from `tools/list`, and its tools search and call every app's tools. [`appIds`](https://frontmcp.dev/reference/plugins/codecall#several-apps-on-one-server) limits the hiding to the apps you name. ## Finding tools: search, then describe The rest of this lesson uses a smaller help desk, with real tickets and customers, so there's something to find. `codecall:search` takes one or more short phrases, searches for each on its own, and merges the results, best first: Initial call configuration: ```js { tool: "codecall:search", arguments: { queries: ["close ticket", "refund invoice"] } } ``` ```ts tools.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in", status: "open", priority: "high", customerId: "C-1" }, { id: "T-2", title: "Invoice shows the wrong total", status: "open", priority: "low", customerId: "C-2" }, { id: "T-3", title: "Export times out", status: "open", priority: "high", customerId: "C-2" }, { id: "T-4", title: "Password reset email never arrives", status: "closed", priority: "high", customerId: "C-1" }, ]; const customers = [ { id: "C-1", name: "Acme Corp", plan: "enterprise" }, { id: "C-2", name: "Globex", plan: "starter" }, ]; @Tool({ name: "search_tickets", description: "Search support tickets by status and priority", inputSchema: { status: z.enum(["open", "closed"]).optional(), priority: z.enum(["low", "high"]).optional() }, }) export class SearchTickets extends ToolContext { async execute({ status, priority }: { status?: "open" | "closed"; priority?: "low" | "high" }) { return { tickets: tickets.filter((t) => (!status || t.status === status) && (!priority || t.priority === priority)) }; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string().describe("A ticket id, like T-1") }, examples: [{ description: "Look up the ticket about logging in", input: { id: "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}.`)); return ticket; } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "get_customer", description: "Get a customer account by its id", inputSchema: { id: z.string() } }) export class GetCustomer extends ToolContext { async execute({ id }: { id: string }) { return customers.find((c) => c.id === id) ?? this.fail(new PublicMcpError(`There's no customer ${id}.`)); } } @Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { invoiceId: z.string() } }) export class RefundInvoice extends ToolContext { async execute({ invoiceId }: { invoiceId: string }) { return { invoiceId, status: "refunded" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CodeCallPlugin } from "@frontmcp/plugin-codecall"; import { CloseTicket, GetCustomer, GetTicket, RefundInvoice, SearchTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, GetTicket, CloseTicket, GetCustomer, RefundInvoice], plugins: [CodeCallPlugin.init({ mode: "codecall_only" })], }) export class HelpDeskApp {} ``` ```ts find.test.ts import { test, expect } from "@frontmcp/testing"; const search = async (mcp: any, queries: string[]) => (await mcp.tools.call("codecall:search", { queries })).json(); test("each phrase finds its tool, best first", async ({ mcp }) => { const { tools } = await search(mcp, ["close ticket", "refund invoice"]); expect(tools[0]).toMatchObject({ name: "close_ticket", matchedQueries: ["close ticket"] }); expect(tools[1]).toMatchObject({ name: "refund_invoice", matchedQueries: ["refund invoice"] }); }); test("🚩 search matches words, not meaning", async ({ mcp }) => { expect((await search(mcp, ["give money back"])).tools).toEqual([]); }); test("codecall:describe returns the JSON Schema, with each field's description", async ({ mcp }) => { const { tools } = (await mcp.tools.call("codecall:describe", { toolNames: ["get_ticket"] })).json(); expect(tools[0].inputSchema.properties.id).toEqual({ type: "string", description: "A ticket id, like T-1" }); expect(tools[0].usageExamples).toEqual([ { description: "Look up the ticket about logging in", code: 'const result = await callTool(\'get_ticket\', {\n "id": "T-1"\n});\nreturn result;', }, ]); }); test("without examples, CodeCall writes one from the tool's schema", async ({ mcp }) => { const { tools } = (await mcp.tools.call("codecall:describe", { toolNames: ["search_tickets"] })).json(); expect(Object.keys(tools[0].inputSchema.properties)).toEqual(["status", "priority"]); expect(tools[0].usageExamples).toEqual([ { description: "Search for search_tickets", code: 'const results = await callTool(\'search_tickets\', {\n "status": "open"\n});\nreturn results.items || results;', }, ]); }); ``` - **Search matches words**, from each tool's name (split on `_`, `-`, `:` and `.`), its description, its `tags`, the names of its input fields and its `examples`. Built-in synonyms help (`add` also finds `create`), but words aren't reduced to their stem, so `ticket` doesn't find `tickets`, and a phrase in other words, like `give money back`, finds nothing. Write descriptions with the words a model would search for. - **Each result** has the tool's `name`, `description`, `appId`, a `relevanceScore` from 0 to 1, and `matchedQueries`, the phrases that found it. `totalAvailableTools` counts the tools CodeCall may use. - **`codecall:describe`** takes the names the model picked and returns each tool's input and output schema as JSON Schema, with the text of each field's `.describe()`, and some usage examples. A name CodeCall doesn't have comes back in `notFound`. CodeCall caches the results of `codecall:search` and `codecall:describe` for 60 seconds per signed-in caller, with the [Cache plugin](https://frontmcp.dev/learn/caching-results) it brings along. After you change a tool, a caller who asked about it in the last minute gets the old answer until then. Anonymous callers, like the Playground's, aren't cached. > **Note** > > `codecall:describe` gives a tool's own [`examples`](https://frontmcp.dev/reference/sdk/tool#options) as its usage examples, and shows only those. A tool without any gets one that CodeCall writes from its name and input schema: it uses only properties the schema declares, like `status`, with the first value of the enum, for `search_tickets`. That's a valid call, but a guess at what the model wants. Give a tool an example of the call you'd make, and that's the one the model copies. ## Calling one tool: `codecall:invoke` Once the model knows a tool's schema, `codecall:invoke` calls it and returns the tool's own result, errors included: Initial call configuration: ```js { tool: "codecall:invoke", arguments: { tool: "get_ticket", input: { id: "T-1" } } } ``` ```ts help-desk.app.ts active import { App } from "@frontmcp/sdk"; import { CodeCallPlugin } from "@frontmcp/plugin-codecall"; import { CloseTicket, GetCustomer, GetTicket, SearchTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, GetTicket, CloseTicket, GetCustomer], plugins: [CodeCallPlugin.init({ mode: "codecall_only" })], }) export class HelpDeskApp {} ``` ```ts tools.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in", status: "open", priority: "high", customerId: "C-1" }, { id: "T-2", title: "Invoice shows the wrong total", status: "open", priority: "low", customerId: "C-2" }, { id: "T-3", title: "Export times out", status: "open", priority: "high", customerId: "C-2" }, { id: "T-4", title: "Password reset email never arrives", status: "closed", priority: "high", customerId: "C-1" }, ]; const customers = [ { id: "C-1", name: "Acme Corp", plan: "enterprise" }, { id: "C-2", name: "Globex", plan: "starter" }, ]; @Tool({ name: "search_tickets", description: "Search support tickets by status and priority", inputSchema: { status: z.enum(["open", "closed"]).optional(), priority: z.enum(["low", "high"]).optional() }, }) export class SearchTickets extends ToolContext { async execute({ status, priority }: { status?: "open" | "closed"; priority?: "low" | "high" }) { return { tickets: tickets.filter((t) => (!status || t.status === status) && (!priority || t.priority === priority)) }; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string().describe("A 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}.`)); return ticket; } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "get_customer", description: "Get a customer account by its id", inputSchema: { id: z.string() } }) export class GetCustomer extends ToolContext { async execute({ id }: { id: string }) { return customers.find((c) => c.id === id) ?? this.fail(new PublicMcpError(`There's no customer ${id}.`)); } } ``` ```ts invoke.test.ts import { test, expect } from "@frontmcp/testing"; test("it returns the tool's own result", async ({ mcp }) => { const result = await mcp.tools.call("codecall:invoke", { tool: "get_ticket", input: { id: "T-1" } }); expect(result.json()).toMatchObject({ id: "T-1", title: "Cannot log in", status: "open" }); }); test("and the tool's own error", async ({ mcp }) => { const result = await mcp.tools.call("codecall:invoke", { tool: "get_ticket", input: { id: "T-9" } }); expect(result).toBeError(); expect(result).toHaveTextContent("There's no ticket T-9."); }); test("a tool CodeCall doesn't have is refused, with a hint", async ({ mcp }) => { const result = await mcp.tools.call("codecall:invoke", { tool: "delete_everything", input: {} }); expect(result).toHaveTextContent('Tool "delete_everything" is not available. Use codecall:search to discover available tools.'); }); ``` ```ts script.test.ts import { test, expect } from "@frontmcp/testing"; const run = async (mcp: any, script: string) => (await mcp.tools.call("codecall:execute", { script })).json(); test("a script joins tools and returns only the answer", async ({ mcp }) => { const script = [ 'const { tickets } = await callTool("search_tickets", { status: "open", priority: "high" });', "const out = [];", "for (const t of tickets) {", ' const customer = await callTool("get_customer", { id: t.customerId });', " out.push({ ticket: t.id, title: t.title, customer: customer.name });", "}", "return out;", ].join("\n"); expect(await run(mcp, script)).toEqual({ status: "ok", result: [ { ticket: "T-1", title: "Cannot log in", customer: "Acme Corp" }, { ticket: "T-3", title: "Export times out", customer: "Globex" }, ], }); }); test("parallel() fetches the customers at once", async ({ mcp }) => { const script = [ 'const { tickets } = await callTool("search_tickets", { status: "open" });', 'const owners = await parallel(tickets, (t) => callTool("get_customer", { id: t.customerId }));', "return tickets.map((t, i) => `${t.id}: ${owners[i].name}`);", ].join("\n"); expect(await run(mcp, script)).toEqual({ status: "ok", result: ["T-1: Acme Corp", "T-2: Globex", "T-3: Globex"] }); }); test("a script that reads and then deletes is stopped by the sandbox", async ({ mcp }) => { const script = 'const customer = await callTool("get_customer", { id: "C-2" });\nreturn await callTool("delete_customer", { id: customer.id });'; expect(await run(mcp, script)).toEqual({ status: "runtime_error", error: { source: "script", message: "Suspicious pattern detected: Delete operation after data access (potential cover-up) [DELETE_AFTER_ACCESS]", name: "Error", }, }); }); test("a failed call throws, and the script's error isn't the tool's", async ({ mcp }) => { const caught = 'try {\n return await callTool("get_ticket", { id: "T-9" });\n} catch (error) {\n return { failed: error.message };\n}'; expect(await run(mcp, caught)).toEqual({ status: "ok", result: { failed: 'Tool "get_ticket" execution failed' } }); expect(await run(mcp, 'return await callTool("get_ticket", { id: "T-9" });')).toEqual({ status: "tool_error", error: { source: "tool", toolName: "get_ticket", message: 'Tool "get_ticket" execution failed', code: "EXECUTION" }, }); }); ``` That's the same result the model would get from calling `get_ticket` directly, so for a single call nothing changes but the path. Try `T-9` in the Call tab: the tool's error message comes through too. ## Several calls in one script: `codecall:execute` "Which urgent tickets are open, and whose are they?" needs a search and then one customer lookup per ticket. With `codecall:execute`, the model writes those calls as a short JavaScript program and sends it in one request. The script runs on your server, and only what it returns goes back to the model: ```js const { tickets } = await callTool("search_tickets", { status: "open", priority: "high" }); const out = []; for (const t of tickets) { const customer = await callTool("get_customer", { id: t.customerId }); out.push({ ticket: t.id, title: t.title, customer: customer.name }); } return out; ``` Here are the tools above again, with `codecall:execute` already called with that script. It's in the **Call** tab's `script` field: change it and send it again. **Reuses runnable project: help-desk.** Initial call configuration: ```js { tool: "codecall:execute", arguments: { script: [ 'const { tickets } = await callTool("search_tickets", { status: "open", priority: "high" });', 'const out = [];', 'for (const t of tickets) {', ' const customer = await callTool("get_customer", { id: t.customerId });', ' out.push({ ticket: t.id, title: t.title, customer: customer.name });', '}', 'return out;', ].join("\n") } } ``` `codecall:execute` answers: ```json { "status": "ok", "result": [ { "ticket": "T-1", "title": "Cannot log in", "customer": "Acme Corp" }, { "ticket": "T-3", "title": "Export times out", "customer": "Globex" } ] } ``` Three tool calls, one round trip, and the model reads two short lines instead of every field of every ticket and customer. The script is the body of an `async` function: it uses `await` at the top level and `return`s its answer. - **`callTool(name, input)`** calls a tool through the same flow as a direct call, with the caller's credentials, and returns the object the tool returned. - **`parallel(items, fn)`** runs calls at once, like `Promise.all`, for up to 100 items. - **`mcpLog(level, message)`** adds a line to the result's `logs`, since there's no `console`. - **`status`** says how the script ended: `ok` with a `result`, or `syntax_error`, `illegal_access`, `tool_error`, `runtime_error` or `timeout`, with an `error`. `codecall:execute` answers with such an object whatever happens to the script, so the model can read what went wrong and try again. Only input that doesn't fit its schema, like a script under 23 characters, is an error result. The same question with `parallel()`, which fetches the customers at once: ```js const { tickets } = await callTool("search_tickets", { status: "open" }); const owners = await parallel(tickets, (t) => callTool("get_customer", { id: t.customerId })); return tickets.map((t, i) => `${t.id}: ${owners[i].name}`); // { "status": "ok", "result": ["T-1: Acme Corp", "T-2: Globex", "T-3: Globex"] } ``` > **Note: The Playground runs scripts in a browser sandbox** > > A server runs a script in a sandbox built on Node's `vm` module, which a browser doesn't have, so the Playground runs it in Enclave's browser sandbox: two nested iframes with no network and no `eval`. The checks and the tool calls are the same, and the tests on this page pass on Node and in the browser. A few things differ, like `getTool()`, which only answers for names written in the script. [In the Playground](https://frontmcp.dev/reference/plugins/codecall#in-the-playground) lists them. ## What a script may do A script is code a model wrote, so CodeCall treats it as untrusted. It runs a subset of JavaScript called **AgentScript**, and checks each script before running any of it. Anything outside the subset is refused with `illegal_access`, and nothing in the script runs, not even the tool calls before the refused line. Checking works in the Playground too, so the tests here try some refused scripts. Add your own: Initial call configuration: ```js { tool: "codecall:execute", arguments: { script: "let page = 1;\nwhile (page < 3) { page++; }\nreturn callTool('search_tickets', {});" } } ``` ```ts refused.test.ts active import { test, expect } from "@frontmcp/testing"; import { closed } from "./tools"; const run = async (mcp: any, script: string) => (await mcp.tools.call("codecall:execute", { script })).json(); test("while loops are refused; use for…of", async ({ mcp }) => { const result = await run(mcp, "let page = 1;\nwhile (page < 3) { page++; }\nreturn callTool('search_tickets', {});"); expect(result.status).toBe("illegal_access"); expect(result.error.message).toContain("FORBIDDEN_LOOP (line 2): Only for-of loops are allowed (vm.allowLoops is false) (while loop)"); }); test("so are counting for loops, in the default preset", async ({ mcp }) => { const result = await run(mcp, "let n = 0;\nfor (let i = 0; i < 3; i++) { n += i; }\nreturn callTool('search_tickets', {});"); expect(result.error.message).toContain("FORBIDDEN_LOOP (line 2): Only for-of loops are allowed (vm.allowLoops is false) (for loop)"); }); test("function declarations are refused; use arrow functions", async ({ mcp }) => { const result = await run(mcp, "function isOpen(t) { return t.status === 'open'; }\nreturn callTool('search_tickets', {});"); expect(result.error.message).toContain("NO_USER_FUNCTION_DECLARATION"); }); test("console doesn't exist; use mcpLog()", async ({ mcp }) => { const result = await run(mcp, "console.log('hi');\nreturn callTool('search_tickets', {});"); expect(result.error.message).toContain("DISALLOWED_IDENTIFIER (line 1): console is not available in CodeCall scripts; use mcpLog(level, message)"); }); test("eval, process and constructor tricks are refused", async ({ mcp }) => { for (const script of [ "return eval(\"callTool('search_tickets', {})\");", "const env = process.env;\nreturn callTool('search_tickets', {});", "return [].constructor.constructor('return process')();", ]) { expect((await run(mcp, script)).status).toBe("illegal_access"); } }); test("a refused script has no effect at all", async ({ mcp }) => { const result = await run(mcp, "await callTool('close_ticket', { id: 'T-2' });\nwhile (true) {}\nreturn 1;"); expect(result.status).toBe("illegal_access"); expect(closed).toEqual([]); // close_ticket never ran }); test("a script shorter than 23 characters is an error result", async ({ mcp }) => { expect(await mcp.tools.call("codecall:execute", { script: "return 1 + 1" })).toBeError("INVALID_INPUT"); }); ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; export const closed: string[] = []; @Tool({ name: "search_tickets", description: "Search support tickets by status", inputSchema: { status: z.enum(["open", "closed"]).optional() } }) export class SearchTickets extends ToolContext { async execute(_input: { status?: "open" | "closed" }) { return { tickets: [{ id: "T-1", status: "open" }, { id: "T-2", status: "open" }] }; } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { closed.push(id); return { id, status: "closed" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CodeCallPlugin } from "@frontmcp/plugin-codecall"; import { CloseTicket, SearchTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, CloseTicket], plugins: [CodeCallPlugin.init({ mode: "codecall_only" })] }) export class HelpDeskApp {} ``` | A script may use | It may not use | | --- | --- | | `const`, `let`, `if`, `for…of` (with `break` and `continue`), `try`/`catch`, `throw` | `for (…; …; …)`, unless the server sets `vm.allowLoops`; `while`, `do…while`, `for…in` | | Arrow functions, also `async` | `function` declarations and expressions, `class` | | `callTool()`, `parallel()`, `getTool()`, `mcpLog()`, `mcpNotify()` | `console`, `fetch`, `setTimeout`, `process`, `require`, `import()`, `globalThis` | | `Math`, `JSON`, `Object`, `Array`, `String`, `Number`, `Date`, and their methods | `eval`, `Function`, `.constructor`, `__proto__`, regular expressions, `Promise`, `Map`, `Set`, `Error` | A tool's name in `callTool()` must be a string literal: `callTool(name, …)` with a variable is refused. To throw an error of your own, `throw "message"`. [AgentScript](https://frontmcp.dev/reference/plugins/codecall#agentscript) lists every rule. A script that passes the checks runs in a fresh sandbox, within limits set by the `vm` option's preset (`secure` by default): | Limit | `secure` | When it's reached | | --- | --- | --- | | Computing time | 3.5 seconds | The script ends with `timeout`. Time spent waiting for tools doesn't count. | | Tool calls | 5,000 per script | `runtime_error`: `Maximum tool call limit exceeded (5000). …` | | Loop iterations | 10,000 per loop, in every preset | `runtime_error`: `Maximum iteration limit exceeded (10000). This limit prevents infinite loops.` | | Items in `parallel()` | 100 | `runtime_error`: `parallel() is limited to 100 items` | [`vm`](https://frontmcp.dev/reference/plugins/codecall#vm) lists the other presets. > **Pitfall: The sandbox stops scripts that look like an attack** > > While a script runs, the sandbox watches the order of its tool calls by tool name, and stops patterns an attacker would use: a `delete…` call soon after a read, a `send…` or `export…` call after a `list…`, and a few more. They match parts of names, so ordinary scripts trip them too. This script, which looks up a customer and deletes it, > > ```js > const customer = await callTool("get_customer", { id: "C-2" }); > return await callTool("delete_customer", { id: customer.id }); > ``` > > ends with `{ "status": "runtime_error", "error": { "source": "script", "message": "Suspicious pattern detected: Delete operation after data access (potential cover-up) [DELETE_AFTER_ACCESS]", "name": "Error" } }`. CodeCall has no option to turn this off. Split such work over two requests, or make the second call with `codecall:invoke`. [Limits](https://frontmcp.dev/reference/plugins/codecall#limits) lists every pattern. ### When a tool fails inside a script A failed call throws, and a script can catch it. The error has a `message`, but not the tool's: to the script, every failure reads `Tool "get_ticket" execution failed` (or `was not found`, `timed out`, `Access denied`): ```js try { return await callTool("get_ticket", { id: "T-9" }); } catch (error) { return { failed: error.message }; } // { "status": "ok", "result": { "failed": "Tool \"get_ticket\" execution failed" } } ``` The tool said `There's no ticket T-9.`, and that doesn't reach the script. Without the `try`, the script ends with `{ "status": "tool_error", "error": { "source": "tool", "toolName": "get_ticket", "message": "Tool \"get_ticket\" execution failed", "code": "EXECUTION" } }`. When the model needs to know why, have the tool return an answer instead of failing, like `{ found: false }`, or call it with `codecall:invoke`, which returns the tool's own error. `callTool(name, input, { throwOnError: false })` returns `{ success, data }` or `{ success, error }` instead of throwing, with the same message. ## Keeping tools away from CodeCall CodeCall may use every tool of the server by default, so a script can call a destructive tool as easily as a harmless one, as often as the limits allow. Keep such tools away from it, with `codecall: { enabledInCodeCall: false }` on the tool, or an `includeTools` rule, which can read the tool's annotations: Initial call configuration: ```js { tool: "codecall:describe", arguments: { toolNames: ["get_ticket", "delete_customer", "purge_closed_tickets"] } } ``` ```ts help-desk.app.ts active import { App } from "@frontmcp/sdk"; import { CodeCallPlugin } from "@frontmcp/plugin-codecall"; import { DeleteCustomer, GetTicket, PurgeClosedTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, DeleteCustomer, PurgeClosedTickets], plugins: [ CodeCallPlugin.init({ mode: "codecall_only", // ✅ No tool marked destructive, whoever wrote it includeTools: (tool) => !tool.annotations?.destructiveHint, }), ], }) export class HelpDeskApp {} ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Tool({ name: "delete_customer", description: "Delete a customer and all their tickets", inputSchema: { id: z.string() }, codecall: { enabledInCodeCall: false }, // ✅ never through CodeCall }) export class DeleteCustomer extends ToolContext { async execute({ id }: { id: string }) { return { deleted: id }; } } @Tool({ name: "purge_closed_tickets", description: "Delete every closed ticket", inputSchema: {}, annotations: { destructiveHint: true }, }) export class PurgeClosedTickets extends ToolContext { async execute() { return { purged: 12 }; } } ``` ```ts away.test.ts import { test, expect } from "@frontmcp/testing"; test("codecall:describe only knows get_ticket", async ({ mcp }) => { const result = await mcp.tools.call("codecall:describe", { toolNames: ["get_ticket", "delete_customer", "purge_closed_tickets"] }); expect(result.json().tools.map((t: { name: string }) => t.name)).toEqual(["get_ticket"]); expect(result.json().notFound).toEqual(["delete_customer", "purge_closed_tickets"]); }); test("codecall:invoke refuses the other two", async ({ mcp }) => { const result = await mcp.tools.call("codecall:invoke", { tool: "purge_closed_tickets", input: {} }); expect(result).toBeError(); expect(result).toHaveTextContent('Tool "purge_closed_tickets" is not available.'); }); test("a client that knows the name can't call them directly either", async ({ mcp }) => { const result = await mcp.tools.call("delete_customer", { id: "C-1" }); expect(result).toBeError("TOOL_NOT_FOUND"); expect(result).toHaveTextContent('Tool "delete_customer" not found'); }); ``` A tool CodeCall may not use isn't found by search, is in `notFound` for `codecall:describe`, is refused by `codecall:invoke`, and gets `Access denied for tool "delete_customer"` in a script. The model can't tell whether such a tool exists. The reverse option, `codecall: { visibleInListTools: true }`, keeps a tool in `tools/list` next to CodeCall's, for one the model should always see, like a health check. In `codecall_only`, as here, a client that sends `tools/call` for `delete_customer` by name gets `Tool "delete_customer" not found` too, as the last test shows: CodeCall takes it out of `tools/list`, and FrontMCP answers a direct call of a tool CodeCall hides as if it didn't exist. Your own tools can still call it with `this.callTool()`. > **Pitfall: In the other modes, a listed tool can be called directly** > > `codecall_opt_in` and `metadata_driven` leave tools in `tools/list`, and a client can call a listed tool by name, even one CodeCall refuses. To decide who may call a tool, use [`authorities`](https://frontmcp.dev/learn/authorizing-calls#declaring-who-may-call-a-tool), which apply to direct calls and to CodeCall's alike. (Before FrontMCP 1.9, a client could call any tool by name, in every mode: hiding it was the only protection.) The [CRM with CodeCall](https://frontmcp.dev/examples/crm-with-codecall) example puts 47 tools behind CodeCall, keeps the destructive ones out of reach, and runs one script that combines eight calls. ## Recap - Every listed tool is read by the model before every answer. A long list costs context and accuracy, and multi-step tasks cost a round trip per call. - `CodeCallPlugin.init({ mode: "codecall_only" })` lists CodeCall's six tools instead of yours. They're about 21 KB, whatever is behind them, so CodeCall pays off for large servers, not small ones. - The model finds tools with `codecall:search` (words, not meaning), reads their schemas with `codecall:describe`, and calls one with `codecall:invoke`. Give tools good descriptions and `examples`. - `codecall:execute` runs an AgentScript program on the server that calls several tools and returns only the answer. Scripts are checked before they run, and run within limits. The Playground runs them in a browser sandbox. - Keep destructive tools away with `enabledInCodeCall: false` or `includeTools`. In `codecall_only`, a client can't call them by name either; in the other modes, use `authorities` for that. - Every option, AgentScript's rules and the sandbox's limits are in the [CodeCall reference](https://frontmcp.dev/reference/plugins/codecall). ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Put the help desk behind CodeCall The help desk lists its tools directly. Put them behind CodeCall, so the model sees CodeCall's tools and reaches the help desk's through them. ```ts help-desk.app.ts active import { App } from "@frontmcp/sdk"; import { CloseTicket, GetCustomer, GetTicket, SearchTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, GetTicket, CloseTicket, GetCustomer], }) export class HelpDeskApp {} ``` **Solution file (alternative to the starter):** ```ts help-desk.app.ts solution import { App } from "@frontmcp/sdk"; import { CodeCallPlugin } from "@frontmcp/plugin-codecall"; import { CloseTicket, GetCustomer, GetTicket, SearchTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, GetTicket, CloseTicket, GetCustomer], plugins: [CodeCallPlugin.init({ mode: "codecall_only" })], }) export class HelpDeskApp {} ``` ```ts tools.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in", status: "open", customerId: "C-1" }, { id: "T-2", title: "Invoice shows the wrong total", status: "open", customerId: "C-2" }, ]; @Tool({ name: "search_tickets", description: "Search support tickets by status", inputSchema: { status: z.enum(["open", "closed"]).optional() } }) export class SearchTickets extends ToolContext { async execute({ status }: { status?: "open" | "closed" }) { return { tickets: tickets.filter((t) => !status || t.status === status) }; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return tickets.find((t) => t.id === id) ?? this.fail(new PublicMcpError(`There's no ticket ${id}.`)); } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "get_customer", description: "Get a customer account by its id", inputSchema: { id: z.string() } }) export class GetCustomer extends ToolContext { async execute({ id }: { id: string }) { return { id, name: id === "C-1" ? "Acme Corp" : "Globex" }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts codecall.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`tools/list` shows CodeCall's tools, not the help desk's", async ({ mcp }) => { const tools = await mcp.tools.list(); expect(tools).toContainTool("codecall:search"); expect(tools).toContainTool("codecall:execute"); expect(tools).not.toContainTool("get_ticket"); }); test("`codecall:describe` knows `get_ticket`", async ({ mcp }) => { const result = await mcp.tools.call("codecall:describe", { toolNames: ["get_ticket"] }); expect(result).toBeSuccessful(); expect(result.json().tools[0].name).toBe("get_ticket"); }); test("`codecall:invoke` calls it", async ({ mcp }) => { const result = await mcp.tools.call("codecall:invoke", { tool: "get_ticket", input: { id: "T-1" } }); expect(result.json()).toMatchObject({ id: "T-1", title: "Cannot log in" }); }); ``` **Hint:** CodeCall is a plugin. Remember to call `init()`. **Solution:** `plugins: [CodeCallPlugin.init({ mode: "codecall_only" })]` registers CodeCall on the app. `tools/list` now shows its six tools, and the four help desk tools are reached through `codecall:search`, `codecall:describe`, `codecall:invoke` and `codecall:execute`. `CodeCallPlugin.init()` works too, since `"codecall_only"` is the default; `plugins: [CodeCallPlugin]` without `init()` starts, but every CodeCall tool then fails with `Provider "CodeCallConfig" is not available`. ### Challenge: Keep destructive tools out of scripts CodeCall can reach `delete_customer` and `purge_closed_tickets`, so a model's script could call them in a loop. Keep every tool annotated `destructiveHint` away from CodeCall, including ones added later, and leave the rest available. Initial call configuration: ```js { tool: "codecall:describe", arguments: { toolNames: ["get_ticket", "delete_customer", "purge_closed_tickets"] } } ``` ```ts help-desk.app.ts active import { App } from "@frontmcp/sdk"; import { CodeCallPlugin } from "@frontmcp/plugin-codecall"; import { CloseTicket, DeleteCustomer, GetTicket, PurgeClosedTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, CloseTicket, DeleteCustomer, PurgeClosedTickets], plugins: [CodeCallPlugin.init({ mode: "codecall_only" })], }) export class HelpDeskApp {} ``` **Solution file (alternative to the starter):** ```ts help-desk.app.ts solution import { App } from "@frontmcp/sdk"; import { CodeCallPlugin } from "@frontmcp/plugin-codecall"; import { CloseTicket, DeleteCustomer, GetTicket, PurgeClosedTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, CloseTicket, DeleteCustomer, PurgeClosedTickets], plugins: [ CodeCallPlugin.init({ mode: "codecall_only", includeTools: (tool) => !tool.annotations?.destructiveHint, }), ], }) export class HelpDeskApp {} ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "delete_customer", description: "Delete a customer and all their tickets", inputSchema: { id: z.string() }, annotations: { destructiveHint: true }, }) export class DeleteCustomer extends ToolContext { async execute({ id }: { id: string }) { return { deleted: id }; } } @Tool({ name: "purge_closed_tickets", description: "Delete every closed ticket", inputSchema: {}, annotations: { destructiveHint: true }, }) export class PurgeClosedTickets extends ToolContext { async execute() { return { purged: 12 }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts away.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance, Tool, ToolContext } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; test("`delete_customer` and `purge_closed_tickets` are out of CodeCall's reach", async ({ mcp }) => { const result = await mcp.tools.call("codecall:describe", { toolNames: ["delete_customer", "purge_closed_tickets"] }); expect(result.json().notFound).toEqual(["delete_customer", "purge_closed_tickets"]); const invoked = await mcp.tools.call("codecall:invoke", { tool: "delete_customer", input: { id: "C-1" } }); expect(invoked).toHaveTextContent('Tool "delete_customer" is not available.'); }); test("`get_ticket` and `close_ticket` are still available", async ({ mcp }) => { const result = await mcp.tools.call("codecall:describe", { toolNames: ["get_ticket", "close_ticket"] }); expect(result.json().notFound).toBeUndefined(); expect(await mcp.tools.call("codecall:invoke", { tool: "close_ticket", input: { id: "T-1" } })).toBeSuccessful(); }); test("so is a destructive tool added later", async () => { @Tool({ name: "wipe_macros", description: "Delete every macro", inputSchema: {}, annotations: { destructiveHint: true } }) class WipeMacros extends ToolContext { async execute() { return { wiped: 3 }; } } @App({ id: "macros", name: "Macros", tools: [WipeMacros] }) class MacrosApp {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, MacrosApp] }); const asReviewer = { authContext: { user: { sub: "reviewer" } } }; const result = await server.callTool("codecall:describe", { toolNames: ["get_ticket", "wipe_macros"] }, asReviewer); await server.dispose(); expect((result.structuredContent as { notFound?: string[] }).notFound).toEqual(["wipe_macros"]); }); ``` **Hint:** Both tools are already annotated. One CodeCall option decides which tools it may use, and it gets each tool's annotations. **Solution:** `includeTools` is called with each tool, including its `annotations`, and CodeCall only uses the tools it returns `true` for. Returning `!tool.annotations?.destructiveHint` keeps out both tools, and any tool annotated the same way later. `codecall: { enabledInCodeCall: false }` on each tool would keep these two out, but not the next one someone adds. Either way, a client can still call them by name: use [`authorities`](https://frontmcp.dev/learn/authorizing-calls) for that. ### Challenge: Show the model the call you'd make When the model describes `search_tickets`, the example it reads is one CodeCall wrote from the schema: open tickets, with no priority. The help desk's most common search is for open tickets with high priority. Give the tool an example of its own for that search, so it's the call the model sees, and CodeCall's guess isn't listed next to it. Initial call configuration: ```js { tool: "codecall:describe", arguments: { toolNames: ["search_tickets"] } } ``` ```ts tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in", status: "open", priority: "high" }, { id: "T-2", title: "Invoice shows the wrong total", status: "open", priority: "low" }, { id: "T-4", title: "Password reset email never arrives", status: "closed", priority: "high" }, ]; @Tool({ name: "search_tickets", description: "Search support tickets by status and priority", inputSchema: { status: z.enum(["open", "closed"]).optional(), priority: z.enum(["low", "high"]).optional() }, }) export class SearchTickets extends ToolContext { async execute({ status, priority }: { status?: "open" | "closed"; priority?: "low" | "high" }) { return { tickets: tickets.filter((t) => (!status || t.status === status) && (!priority || t.priority === priority)) }; } } ``` **Solution file (alternative to the starter):** ```ts tools.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in", status: "open", priority: "high" }, { id: "T-2", title: "Invoice shows the wrong total", status: "open", priority: "low" }, { id: "T-4", title: "Password reset email never arrives", status: "closed", priority: "high" }, ]; @Tool({ name: "search_tickets", description: "Search support tickets by status and priority", inputSchema: { status: z.enum(["open", "closed"]).optional(), priority: z.enum(["low", "high"]).optional() }, examples: [{ description: "Open tickets with high priority", input: { status: "open", priority: "high" } }], }) export class SearchTickets extends ToolContext { async execute({ status, priority }: { status?: "open" | "closed"; priority?: "low" | "high" }) { return { tickets: tickets.filter((t) => (!status || t.status === status) && (!priority || t.priority === priority)) }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CodeCallPlugin } from "@frontmcp/plugin-codecall"; import { SearchTickets } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets], plugins: [CodeCallPlugin.init({ mode: "codecall_only" })] }) export class HelpDeskApp {} ``` **Hidden fixture/test file (runs with the example):** ```ts example.test.ts hidden import { test, expect } from "@frontmcp/testing"; const usageExamples = async (mcp: any) => (await mcp.tools.call("codecall:describe", { toolNames: ["search_tickets"] })).json().tools[0].usageExamples; test("the example asks for open tickets with high priority", async ({ mcp }) => { const [example] = await usageExamples(mcp); expect(example.code).toContain('"status": "open"'); expect(example.code).toContain('"priority": "high"'); }); test("CodeCall's own guess isn't listed next to it", async ({ mcp }) => { const examples = await usageExamples(mcp); expect(examples).toHaveLength(1); expect(examples[0].code).not.toContain("results.items"); }); test("its input finds the ticket", async ({ mcp }) => { const result = await mcp.tools.call("codecall:invoke", { tool: "search_tickets", input: { status: "open", priority: "high" } }); expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-1"]); }); ``` **Hint:** `@Tool` takes `examples`: a list of `{ description, input }`. **Solution:** `examples: [{ description: "Open tickets with high priority", input: { status: "open", priority: "high" } }]` gives `codecall:describe` a usage example of your own, and it shows yours alone: when a tool has examples, CodeCall doesn't write one next to them. Without it, the model would have copied the guess, which asks for open tickets and leaves out the priority. The examples are indexed for `codecall:search` too, so their descriptions help the model find the tool. --- # Wrapping an OpenAPI Service > Turn an OpenAPI 3 specification into MCP tools. Choose operations, configure server-side credentials and handle API errors. Source: https://frontmcp.dev/learn/wrapping-an-openapi-service [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 The help desk's tickets don't live in your server. They live in the Desk API, a REST service another team runs, and that team publishes an OpenAPI description of it. You could write a tool for each endpoint with [`this.fetch()`](https://frontmcp.dev/learn/calling-other-services), but the description already says everything those tools would repeat: the paths, the parameters, the bodies, the responses. FrontMCP's **OpenAPI adapter**, from `@frontmcp/adapters`, reads it and gives your app one tool per operation, which builds and sends the request the description says, with credentials you configure. **You will learn** - How to turn an OpenAPI description into tools - What a call sends to the API, and what the model gets back - How to give the adapter the API's credentials, without the model seeing them - How to choose which operations become tools, and name them for the model - What to do when an API is too big for one tool per operation ## Turning a spec into tools Install the adapters package: ```bash npm install @frontmcp/adapters ``` Then give the adapter a name, the API's address and its description, the **spec**, and list it in the app's `adapters`: Initial call configuration: ```js { tool: "getTicket", arguments: { id: "T-1" } } ``` ```ts desk.app.ts active import "./desk.example"; import { App } from "@frontmcp/sdk"; import { OpenapiAdapter } from "@frontmcp/adapters"; import { spec } from "./desk-spec"; @App({ id: "desk", name: "Desk", adapters: [OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec })], }) export class DeskApp {} ``` ```ts desk-spec.ts const ticket = { type: "object", properties: { id: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["open", "closed"] } }, }; const id = { name: "id", in: "path", required: true, schema: { type: "string" } }; export const spec = { openapi: "3.0.3", info: { title: "Desk API", version: "1.0.0" }, paths: { "/tickets": { get: { operationId: "listTickets", summary: "List support tickets, newest first", parameters: [{ name: "status", in: "query", schema: { type: "string", enum: ["open", "closed"] } }], responses: { "200": { description: "The tickets", content: { "application/json": { schema: { type: "array", items: ticket } } } } }, }, post: { operationId: "createTicket", summary: "Open a support ticket", requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["title"], properties: { title: { type: "string" }, priority: { type: "string", enum: ["low", "high"] } }, }, }, }, }, responses: { "201": { description: "Created", content: { "application/json": { schema: ticket } } } }, }, }, "/tickets/{id}": { get: { operationId: "getTicket", summary: "Get one support ticket by its id", parameters: [id], responses: { "200": { description: "The ticket", content: { "application/json": { schema: ticket } } } }, }, delete: { operationId: "deleteTicket", summary: "Delete a support ticket", parameters: [id], responses: { "204": { description: "Deleted" } }, }, }, }, }; ``` ```ts desk.example.ts // Plays the Desk API at https://desk.example, which the Playground can't reach. // It answers requests in-process and records them, so tests can check what the API received. export const received: { method: string; url: string; body?: unknown }[] = []; const tickets: Record = { "T-1": { id: "T-1", title: "Cannot log in", status: "open" }, "T-2": { id: "T-2", title: "Invoice total is wrong", status: "closed" }, "T-3": { id: "T-3", title: "Login link expired", status: "open" }, }; async function desk(request: Request): Promise { const text = await request.text(); received.push({ method: request.method, url: request.url, ...(text ? { body: JSON.parse(text) } : {}) }); const url = new URL(request.url); if (request.method === "POST") return Response.json({ id: "T-4", status: "open", ...JSON.parse(text) }, { status: 201 }); if (request.method === "DELETE") return new Response(null, { status: 204 }); if (url.pathname === "/v1/tickets") { const status = url.searchParams.get("status"); return Response.json(Object.values(tickets).filter((t) => !status || t.status === status)); } const ticket = tickets[url.pathname.replace("/v1/tickets/", "")]; return ticket ? Response.json(ticket) : Response.json({ message: "No ticket with that id" }, { status: 404 }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); return url.hostname === "desk.example" ? desk(new Request(input, init)) : realFetch(input, init); }; ``` ```ts desk.test.ts import { test, expect } from "@frontmcp/testing"; import { received } from "./desk.example"; test("each operation is a tool, named by its operationId", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t: { name: string }) => t.name); expect(names).toEqual(["createTicket", "deleteTicket", "getTicket", "listTickets"]); }); test("its parameters and body fields make one input", async ({ mcp }) => { const create = (await mcp.tools.list()).find((t: { name: string }) => t.name === "createTicket"); expect(create.description).toBe("Open a support ticket"); expect(Object.keys(create.inputSchema.properties)).toEqual(["title", "priority"]); expect(create.inputSchema.required).toEqual(["title"]); }); test("a call sends the request the spec describes", async ({ mcp }) => { await mcp.tools.call("listTickets", { status: "open" }); expect(received.at(-1)).toEqual({ method: "GET", url: "https://desk.example/v1/tickets?status=open" }); await mcp.tools.call("createTicket", { title: "Printer on fire", priority: "high" }); expect(received.at(-1)).toEqual({ method: "POST", url: "https://desk.example/v1/tickets", body: { title: "Printer on fire", priority: "high" } }); }); test("the model gets the API's status and body", async ({ mcp }) => { const result = await mcp.tools.call("getTicket", { id: "T-1" }); expect(result.json()).toEqual({ status: 200, ok: true, data: { id: "T-1", title: "Cannot log in", status: "open" } }); }); ``` > **Note** > > The Playground can't reach a real API, so every example in this lesson has a `desk.example.ts` that plays the Desk API at `https://desk.example`. It replaces `globalThis.fetch`, which the adapter calls, answers in-process, and records what it received, so the tests can check what the API was sent. Your server doesn't need it. Open the **Capabilities** tab: four tools, one per operation, with no tool code of yours. - **`name`** names the adapter. It must be unique among all the adapters in the process. - **`baseUrl`** is where the API is. Each operation's path is added to it. - **`spec`** is the OpenAPI 3.0 or 3.1 document, as an object: import the JSON file, or pass `url` instead to download it when the server starts. - **Each tool** is named by the operation's `operationId` and described by its `summary`. Its input has one property for each path, query, header and cookie parameter and each field of the JSON body, and the adapter puts each value back where the spec says it goes. - **The result** is `{ status, ok, data }`: the API's HTTP status and its parsed JSON body. That already works, and it already has problems, which the rest of this lesson fixes: `deleteTicket` is a tool the model can call, the names are the API's, and the real Desk API wants a token, which nothing sends yet. ## What the model gets back The API's answers pass through as they are, errors included. A `4xx` or `5xx` is an error result that still carries the status and the body, so the model can tell a ticket that doesn't exist from an API that's down: Initial call configuration: ```js { tool: "getTicket", arguments: { id: "T-9" } } ``` ```ts errors.test.ts active import { test, expect } from "@frontmcp/testing"; import { received } from "./desk.example"; test("a 404 is an error result, with the API's status and body", async ({ mcp }) => { const result = await mcp.tools.call("getTicket", { id: "T-9" }); expect(result).toBeError(); expect(JSON.parse(result.text())).toEqual({ status: 404, error: "No ticket with that id", data: { message: "No ticket with that id" } }); }); test("a 204 comes back with empty data", async ({ mcp }) => { expect((await mcp.tools.call("deleteTicket", { id: "T-2" })).json()).toEqual({ status: 204, ok: true, data: "" }); }); test("a missing required argument is refused before anything is sent", async ({ mcp }) => { const before = received.length; const result = await mcp.tools.call("getTicket", {}); expect(result).toBeError("INVALID_INPUT"); expect(result).toHaveTextContent("Invalid arguments for tool 'getTicket': id: Invalid input: expected string, received undefined"); expect(received.length).toBe(before); }); test("so is a value the spec doesn't allow, or a key it doesn't have", async ({ mcp }) => { const before = received.length; const urgent = await mcp.tools.call("listTickets", { status: "urgent" }); expect(urgent).toBeError("INVALID_INPUT"); expect(urgent).toHaveTextContent(`Invalid arguments for tool 'listTickets': status: Invalid option: expected one of "open"|"closed"`); const unknownKey = await mcp.tools.call("listTickets", { assignee: "sam" }); expect(unknownKey).toHaveTextContent(`Invalid arguments for tool 'listTickets': input: Unrecognized key: "assignee"`); expect(received.length).toBe(before); }); ``` ```ts desk.app.ts import "./desk.example"; import { App } from "@frontmcp/sdk"; import { OpenapiAdapter } from "@frontmcp/adapters"; import { spec } from "./desk-spec"; @App({ id: "desk", name: "Desk", adapters: [OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec })], }) export class DeskApp {} ``` ```ts desk-spec.ts const ticket = { type: "object", properties: { id: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["open", "closed"] } }, }; const id = { name: "id", in: "path", required: true, schema: { type: "string" } }; export const spec = { openapi: "3.0.3", info: { title: "Desk API", version: "1.0.0" }, paths: { "/tickets": { get: { operationId: "listTickets", summary: "List support tickets, newest first", parameters: [{ name: "status", in: "query", schema: { type: "string", enum: ["open", "closed"] } }], responses: { "200": { description: "The tickets", content: { "application/json": { schema: { type: "array", items: ticket } } } } }, }, }, "/tickets/{id}": { get: { operationId: "getTicket", summary: "Get one support ticket by its id", parameters: [id], responses: { "200": { description: "The ticket", content: { "application/json": { schema: ticket } } } }, }, delete: { operationId: "deleteTicket", summary: "Delete a support ticket", parameters: [id], responses: { "204": { description: "Deleted" } }, }, }, }, }; ``` ```ts desk.example.ts // Plays the Desk API at https://desk.example, which the Playground can't reach. export const received: { method: string; url: string }[] = []; const tickets: Record = { "T-1": { id: "T-1", title: "Cannot log in", status: "open" }, "T-2": { id: "T-2", title: "Invoice total is wrong", status: "closed" }, }; async function desk(request: Request): Promise { received.push({ method: request.method, url: request.url }); const url = new URL(request.url); if (request.method === "DELETE") return new Response(null, { status: 204 }); if (url.pathname === "/v1/tickets") return Response.json(Object.values(tickets)); const ticket = tickets[url.pathname.replace("/v1/tickets/", "")]; return ticket ? Response.json(ticket) : Response.json({ message: "No ticket with that id" }, { status: 404 }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); return url.hostname === "desk.example" ? desk(new Request(input, init)) : realFetch(input, init); }; ``` The error's text is `{"status":404,"error":"No ticket with that id","data":{…}}`: `error` is the body's `message` field, or the body itself when it's text. A redirect isn't followed, and a request that takes longer than `requestTimeoutMs` (30 seconds by default) fails the call. [What the model gets back](https://frontmcp.dev/reference/adapters/openapi#what-the-model-gets-back) covers each case. Before it sends anything, the adapter checks the arguments against the spec, as the last two tests show. A missing required parameter, a value outside an `enum`, or a key the operation doesn't have is refused with the code `INVALID_INPUT`, and the message names the argument and what's wrong with it, so the model can fix the call and try again. The API receives nothing. [Arguments are checked against the spec](https://frontmcp.dev/reference/adapters/openapi#arguments-are-checked-against-the-spec) lists what the check covers. > **Pitfall: The check is only as strict as the spec** > > The adapter checks what the spec says, and nothing more. A `status` declared as a plain `string`, with no `enum`, lets `"urgent"` through to the API, which then answers `400`: an error result, like the `404` above. Keep validating input in the API, as you would for any client, and keep the spec as exact as the API: an `enum` in the spec is also what tells the model which values to send. ## Giving the adapter the API's credentials The real Desk API wants a bearer token on every request, and its spec says so, with a security scheme. An adapter with no credentials doesn't guess: it refuses the call, and sends nothing. Give it the token the Desk team issued for your server, as `staticAuth`: Initial call configuration: ```js { tool: "getTicket", arguments: { id: "T-1" } } ``` ```ts desk.app.ts active import "./desk.example"; import { App } from "@frontmcp/sdk"; import { OpenapiAdapter } from "@frontmcp/adapters"; import { spec } from "./desk-spec"; @App({ id: "desk", name: "Desk", adapters: [ OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec, // In a real server: { jwt: process.env.DESK_API_TOKEN } staticAuth: { jwt: "desk-service-token" }, }), ], }) export class DeskApp {} ``` ```ts desk-spec.ts const ticket = { type: "object", properties: { id: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["open", "closed"] } }, }; export const spec = { openapi: "3.0.3", info: { title: "Desk API", version: "1.0.0" }, components: { securitySchemes: { DeskToken: { type: "http", scheme: "bearer" } } }, security: [{ DeskToken: [] }], paths: { "/tickets/{id}": { get: { operationId: "getTicket", summary: "Get one support ticket by its id", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "The ticket", content: { "application/json": { schema: ticket } } } }, }, }, }, }; ``` ```ts desk.example.ts // Plays the Desk API at https://desk.example, which the Playground can't reach. // It wants the token the Desk team issued for the help desk server. export const received: { url: string; authorization: string | null }[] = []; async function desk(request: Request): Promise { const authorization = request.headers.get("authorization"); received.push({ url: request.url, authorization }); if (authorization !== "Bearer desk-service-token") return Response.json({ message: "Invalid token" }, { status: 401 }); return Response.json({ id: "T-1", title: "Cannot log in", status: "open" }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); return url.hostname === "desk.example" ? desk(new Request(input, init)) : realFetch(input, init); }; ``` ```ts credentials.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { OpenapiAdapter } from "@frontmcp/adapters"; import { received } from "./desk.example"; import { spec } from "./desk-spec"; test("the API gets the token as a bearer token", async ({ mcp }) => { expect((await mcp.tools.call("getTicket", { id: "T-1" })).json()).toMatchObject({ status: 200, ok: true }); expect(received.at(-1)?.authorization).toBe("Bearer desk-service-token"); }); test("the model never sees it: it's in no tool's input", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t: { name: string }) => t.name === "getTicket"); expect(Object.keys(tool.inputSchema.properties)).toEqual(["id"]); }); test("🚩 without credentials, calls are refused, and nothing is sent", async () => { @App({ id: "desk", name: "Desk", adapters: [OpenapiAdapter.init({ name: "desk-without-token", baseUrl: "https://desk.example/v1", spec })] }) class DeskWithoutToken {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [DeskWithoutToken] }); const before = received.length; await expect(server.callTool("getTicket", { id: "T-1" })).rejects.toThrow( "Authentication required for tool 'getTicket': no credential for its security schemes.", ); await server.dispose(); expect(received.length).toBe(before); }); ``` The adapter reads the spec's security scheme to know where a credential goes: `DeskToken` is an HTTP bearer scheme, so `jwt` is sent as `Authorization: Bearer desk-service-token`. An `apiKey` scheme would get `apiKey` in the header, query parameter or cookie it names. The token never appears in a tool's input or result, so the model can't see it, repeat it, or be talked into sending a different one. `staticAuth` is one account for every caller, which suits an API that trusts your server as a whole. When the API has an account per user or per customer, pick the credential per call instead, from who's calling: ### A token per tenant `securityResolver(tool, ctx)` returns the credentials for one call. `ctx.authInfo.user` holds the claims of the caller's token, so a tenant claim can pick the tenant's API token. The tests call the server in-process as two signed-in agents: `nour`, whose tenant has a token, and `sam`, whose tenant has none: ```ts main.ts active import "./desk.example"; import { App, FrontMcp } from "@frontmcp/sdk"; import { OpenapiAdapter } from "@frontmcp/adapters"; import { spec } from "./desk-spec"; // In a real server these come from a secret store const deskTokens: Record = { acme: "desk-token-acme" }; @App({ id: "desk", name: "Desk", adapters: [ OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec, securityResolver: (tool, ctx) => { const tenant = (ctx.authInfo?.user as { tenant?: string } | undefined)?.tenant; return tenant && deskTokens[tenant] ? { jwt: deskTokens[tenant] } : {}; }, }), ], }) class DeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [DeskApp] }; @FrontMcp(config) export default class Server {} ``` ```ts desk-spec.ts export const spec = { openapi: "3.0.3", info: { title: "Desk API", version: "1.0.0" }, components: { securitySchemes: { DeskToken: { type: "http", scheme: "bearer" } } }, security: [{ DeskToken: [] }], paths: { "/tickets/{id}": { get: { operationId: "getTicket", summary: "Get one support ticket by its id", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "The ticket", content: { "application/json": { schema: { type: "object" } } } } }, }, }, }, }; ``` ```ts desk.example.ts // Plays the Desk API at https://desk.example, which the Playground can't reach. export const received: { url: string; authorization: string | null }[] = []; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const request = new Request(input, init); if (new URL(request.url).hostname !== "desk.example") return realFetch(input, init); received.push({ url: request.url, authorization: request.headers.get("authorization") }); return Promise.resolve(Response.json({ id: "T-1", title: "Cannot log in", status: "open" })); }; ``` ```ts tenant.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { received } from "./desk.example"; import { config } from "./main"; const nour = { authContext: { user: { sub: "nour", tenant: "acme" } } }; const sam = { authContext: { user: { sub: "sam", tenant: "globex" } } }; test("nour's call uses her tenant's token", async () => { const server = await FrontMcpInstance.createDirect(config); const result = await server.callTool("getTicket", { id: "T-1" }, nour); await server.dispose(); expect(result.structuredContent).toMatchObject({ status: 200, ok: true }); expect(received.at(-1)?.authorization).toBe("Bearer desk-token-acme"); }); test("sam's tenant has no token, so his call is refused, and nothing is sent", async () => { const server = await FrontMcpInstance.createDirect(config); const before = received.length; await expect(server.callTool("getTicket", { id: "T-1" }, sam)).rejects.toThrow("Authentication required for tool 'getTicket'"); await server.dispose(); expect(received.length).toBe(before); }); ``` Returning `{}` gives the call no credential, so it's refused before anything is sent. `authProviderMapper` does the same with one function per security scheme, and falls back to `staticAuth` for a caller it has nothing for. [How credentials are chosen](https://frontmcp.dev/reference/adapters/openapi#how-credentials-are-chosen) lists every option, and the order they're tried in. > **Pitfall: Don't send the caller's token to the API** > > The token a client sends to your server was issued for your server. `passthroughCallerToken: true` forwards it to the API as the bearer token, and then whoever runs the API, or reads its logs, holds a token that works on your server as that user. The MCP specification forbids that kind of token passthrough. The adapter never sends the caller's token unless you set the option; set it only for an API that accepts the same tokens as your server, from the same identity provider, for the same audience. ## Choosing which operations become tools Most APIs have operations the model shouldn't call. `deleteTicket` is one: a support agent who asks the model to "clean up the duplicate" shouldn't end up with a ticket deleted for good. Leave it out with `generateOptions`, which decides which operations become tools. `filterFn` is called with each operation, its `method` and its `path`: Initial call configuration: ```js { tool: "listTickets", arguments: {} } ``` ```ts desk.app.ts active import "./desk.example"; import { App } from "@frontmcp/sdk"; import { OpenapiAdapter } from "@frontmcp/adapters"; import { spec } from "./desk-spec"; @App({ id: "desk", name: "Desk", adapters: [ OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec, generateOptions: { // ✅ No deletes, and nothing tagged admin filterFn: (op) => op.method !== "delete" && !op.tags?.includes("admin"), }, }), ], }) export class DeskApp {} ``` ```ts desk-spec.ts const ok = { "200": { description: "OK", content: { "application/json": { schema: { type: "object" } } } } }; const id = [{ name: "id", in: "path", required: true, schema: { type: "string" } }]; export const spec = { openapi: "3.0.3", info: { title: "Desk API", version: "1.0.0" }, paths: { "/tickets": { get: { operationId: "listTickets", summary: "List support tickets, newest first", tags: ["tickets"], responses: ok }, post: { operationId: "createTicket", summary: "Open a support ticket", tags: ["tickets"], responses: ok }, }, "/tickets/{id}": { get: { operationId: "getTicket", summary: "Get one support ticket by its id", tags: ["tickets"], parameters: id, responses: ok }, delete: { operationId: "deleteTicket", summary: "Delete a support ticket", tags: ["tickets"], parameters: id, responses: ok }, }, "/admin/agents": { get: { operationId: "listAgents", summary: "List support agents, with their roles", tags: ["admin"], responses: ok }, }, }, }; ``` ```ts desk.example.ts // Plays the Desk API at https://desk.example, which the Playground can't reach. export const received: { method: string; url: string }[] = []; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const request = new Request(input, init); if (new URL(request.url).hostname !== "desk.example") return realFetch(input, init); received.push({ method: request.method, url: request.url }); return Promise.resolve(Response.json({ ok: true })); }; ``` ```ts select.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { OpenapiAdapter } from "@frontmcp/adapters"; import { received } from "./desk.example"; import { spec } from "./desk-spec"; test("only the operations the filter keeps are tools", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t: { name: string }) => t.name); expect(names).toEqual(["createTicket", "getTicket", "listTickets"]); }); test("a left-out operation can't be called at all", async ({ mcp }) => { const result = await mcp.tools.call("deleteTicket", { id: "T-2" }); expect(result).toBeError(); expect(result).toHaveTextContent('Tool "deleteTicket" not found'); expect(received.some((r) => r.method === "DELETE")).toBe(false); }); test("excludeMethods and excludeTags keep the same operations", async () => { @App({ id: "desk", name: "Desk", adapters: [ OpenapiAdapter.init({ name: "desk-by-fields", baseUrl: "https://desk.example/v1", spec, generateOptions: { excludeMethods: ["delete"], excludeTags: ["admin"] }, }), ], }) class DeskByFields {} const server = await FrontMcpInstance.createDirect({ info: { name: "desk", version: "1.0.0" }, apps: [DeskByFields] }); expect((await server.listTools()).tools.map((t) => t.name).sort()).toEqual(["createTicket", "getTicket", "listTickets"]); await server.dispose(); }); ``` An operation the filter leaves out isn't a tool: it's not listed, and a client that calls it by name gets `Tool "deleteTicket" not found`. That's the difference from hiding a tool, below. Two simpler options pick operations by `operationId`: `includeOperations: ["listTickets", "getTicket"]` keeps only those, and `excludeOperations: ["deleteTicket"]` leaves those out. An operation without an `operationId` can't be listed, so `includeOperations` leaves it out; `filterFn` sees every operation. > **Note** > > `generateOptions` also has fields for the usual cases: `includeTags` and `excludeTags`, `includeMethods` and `excludeMethods` (in any case, `"delete"` or `"DELETE"`; `OpenapiAdapter.init()` throws for a name that isn't an HTTP method), `includePaths` and `excludePaths`, and `readOnlyOnly`. `excludeMethods: ["delete"], excludeTags: ["admin"]` keeps the same operations as the `filterFn` above, as the last test shows. See [`generateOptions`](https://frontmcp.dev/reference/adapters/openapi#generateoptions). (Before FrontMCP 1.9, the adapter didn't pass these fields on, so they did nothing.) ## Naming tools for the model `listTickets` is the API's name, in the API's style. Next to your own tools, like `search_tickets`, the model reads a list in two styles. `toolTransforms` rewrites what the spec gave each tool: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts desk.app.ts active import "./desk.example"; import { App } from "@frontmcp/sdk"; import { OpenapiAdapter } from "@frontmcp/adapters"; import { spec } from "./desk-spec"; @App({ id: "desk", name: "Desk", adapters: [ OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec, descriptionMode: "combined", // the summary, then the description toolTransforms: { // A POST here only adds: it never changes or deletes what's there generator: (tool) => (tool.metadata.method === "post" ? { annotations: { destructiveHint: false } } : undefined), perTool: { listTickets: { name: "list_tickets" }, getTicket: { name: "get_ticket" }, createTicket: { name: "create_ticket", description: (d) => `${d} Search with list_tickets first, to avoid duplicates.` }, }, }, }), ], }) export class DeskApp {} ``` ```ts desk-spec.ts const ok = { "200": { description: "OK", content: { "application/json": { schema: { type: "object" } } } } }; export const spec = { openapi: "3.0.3", info: { title: "Desk API", version: "1.0.0" }, paths: { "/tickets": { get: { operationId: "listTickets", summary: "List support tickets.", description: "Newest first, 20 at a time.", parameters: [{ name: "status", in: "query", schema: { type: "string", enum: ["open", "closed"] } }], responses: ok, }, post: { operationId: "createTicket", summary: "Open a support ticket.", requestBody: { content: { "application/json": { schema: { type: "object", properties: { title: { type: "string" } } } } } }, responses: ok, }, }, "/tickets/{id}": { get: { operationId: "getTicket", summary: "Get one support ticket by its id.", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: ok, }, }, }, }; ``` ```ts desk.example.ts // Plays the Desk API at https://desk.example, which the Playground can't reach. const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.hostname !== "desk.example") return realFetch(input, init); return Promise.resolve(Response.json({ id: "T-1", title: "Cannot log in", status: "open" })); }; ``` ```ts names.test.ts import { test, expect } from "@frontmcp/testing"; const byName = async (mcp: any) => Object.fromEntries((await mcp.tools.list()).map((t: any) => [t.name, t])); test("the tools have the names you gave them", async ({ mcp }) => { expect(Object.keys(await byName(mcp))).toEqual(["create_ticket", "get_ticket", "list_tickets"]); }); test("descriptions combine the spec's summary and description, plus your words", async ({ mcp }) => { const tools = await byName(mcp); expect(tools.list_tickets.description).toBe("List support tickets.\n\nNewest first, 20 at a time."); expect(tools.create_ticket.description).toBe("Open a support ticket. Search with list_tickets first, to avoid duplicates."); }); test("annotations come from the HTTP method, and the generator corrects the POSTs", async ({ mcp }) => { const tools = await byName(mcp); expect(tools.get_ticket.annotations).toEqual({ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }); expect(tools.create_ticket.annotations).toEqual({ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }); }); test("the spec's summary is each tool's title", async ({ mcp }) => { expect((await byName(mcp)).list_tickets.title).toBe("List support tickets."); }); ``` - **`perTool`** is keyed by the tool's name before the transform. `description` takes a string, or a function that gets the current description. - **Annotations** start from each operation's HTTP method: a `GET` is `readOnlyHint` and `idempotentHint`, and a `POST`, `PUT`, `PATCH` or `DELETE` is `destructiveHint`. That's a guess from HTTP's rules. A `POST` that only adds, like `createTicket`, destroys nothing, so the `generator` says so, and what a transform sets is merged over the guess. - **`generator`** is called with every generated tool and returns a transform or `undefined`. `tool.metadata` has the operation's `method`, `path`, `operationId` and `tags`. - **The title** is the operation's `summary`, for clients to show people. The model reads the name and the description. - **`descriptionMode`** decides how the spec's `summary` and `description` make the tool's description. The default, `"summaryOnly"`, uses the `summary` alone when there is one. `toolTransforms` can also set `hideFromDiscovery: true`, which leaves a tool out of `tools/list`. It's still called by anyone who knows its name, so it's no way to protect an operation: leave the operation out with `generateOptions`, as above, or [require authorities](https://frontmcp.dev/learn/authorizing-calls). To fill in an argument on the server, like a tenant id, rather than asking the model for it, see [`inputTransforms`](https://frontmcp.dev/reference/adapters/openapi#filling-in-inputs-on-the-server). ## An API too big to list The Desk API has four operations; a billing or CRM API can have four hundred. One tool per operation then has the problem the [CodeCall lesson](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall) starts with: a tool list too long for the model to read. Three ways out, from the least change to the most: | | What the model sees | Use it when | | --- | --- | --- | | **Filter** with `generateOptions` | The operations you keep, as tools | The model needs a known handful of operations. | | **CodeCall** in front of the adapter | CodeCall's tools; it searches the operations and calls them, or runs a script | The model needs many operations, and the spec's summaries are good enough to search. | | **Skilled OpenAPI** instead of the adapter | Three tools that find and load **skills**: groups of operations with instructions you write | The API needs explaining, like which call comes first, or you want to limit who may run each operation. | The adapter's tools are ordinary tools, so CodeCall treats them like any other. Here the model finds `closeTicket` by searching, and calls it; the token still comes from `staticAuth`: Initial call configuration: ```js { tool: "codecall:search", arguments: { queries: ["close ticket"] } } ``` ```ts desk.app.ts active import "./desk.example"; import { App } from "@frontmcp/sdk"; import { OpenapiAdapter } from "@frontmcp/adapters"; import { CodeCallPlugin } from "@frontmcp/plugin-codecall"; import { spec } from "./desk-spec"; @App({ id: "desk", name: "Desk", adapters: [OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec, staticAuth: { jwt: "desk-service-token" } })], plugins: [CodeCallPlugin.init({ mode: "codecall_only" })], }) export class DeskApp {} ``` ```ts desk-spec.ts const ticket = { type: "object", properties: { id: { type: "string" }, title: { type: "string" }, status: { type: "string" } } }; const id = [{ name: "id", in: "path", required: true, schema: { type: "string" } }]; const returns = (description: string) => ({ "200": { description, content: { "application/json": { schema: ticket } } } }); export const spec = { openapi: "3.0.3", info: { title: "Desk API", version: "1.0.0" }, components: { securitySchemes: { DeskToken: { type: "http", scheme: "bearer" } } }, security: [{ DeskToken: [] }], paths: { "/tickets/{id}": { get: { operationId: "getTicket", summary: "Get one support ticket by its id", parameters: id, responses: returns("The ticket") } }, "/tickets/{id}/close": { post: { operationId: "closeTicket", summary: "Close a support ticket", parameters: id, responses: returns("The closed ticket") } }, "/tickets/{id}/reopen": { post: { operationId: "reopenTicket", summary: "Reopen a closed support ticket", parameters: id, responses: returns("The reopened ticket") } }, }, }; ``` ```ts desk.example.ts // Plays the Desk API at https://desk.example, which the Playground can't reach. export const received: { method: string; url: string; authorization: string | null }[] = []; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const request = new Request(input, init); const url = new URL(request.url); if (url.hostname !== "desk.example") return realFetch(input, init); received.push({ method: request.method, url: request.url, authorization: request.headers.get("authorization") }); const status = url.pathname.endsWith("/close") ? "closed" : "open"; return Promise.resolve(Response.json({ id: "T-1", title: "Cannot log in", status })); }; ``` ```ts behind-codecall.test.ts import { test, expect } from "@frontmcp/testing"; import { received } from "./desk.example"; test("the operations are behind CodeCall's tools", async ({ mcp }) => { const tools = await mcp.tools.list(); expect(tools).toContainTool("codecall:search"); expect(tools).not.toContainTool("closeTicket"); }); test("the model finds an operation by searching", async ({ mcp }) => { const { tools } = (await mcp.tools.call("codecall:search", { queries: ["close ticket"] })).json(); expect(tools[0]).toMatchObject({ name: "closeTicket", appId: "desk" }); }); test("and calls it, with the adapter's credentials", async ({ mcp }) => { const result = await mcp.tools.call("codecall:invoke", { tool: "closeTicket", input: { id: "T-1" } }); expect(result.json()).toEqual({ status: 200, ok: true, data: { id: "T-1", title: "Cannot log in", status: "closed" } }); expect(received.at(-1)).toEqual({ method: "POST", url: "https://desk.example/v1/tickets/T-1/close", authorization: "Bearer desk-service-token" }); }); ``` Skilled OpenAPI goes further. Instead of the spec, it serves a **bundle**: the API's operations, and skills, each a named group of operations with instructions in Markdown, like "read the invoice before refunding it, and refund at most its amount". The model sees three tools: `search_skill` finds a skill, `load_skill` reads its instructions and the operations it offers, and `run_workflow` runs a short script that calls them, each call checked against the operation's schema and the caller's authorities, and sent with credentials the model never sees: Initial call configuration: ```js { tool: "search_skill", arguments: { query: "refund an invoice" } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { SkilledOpenApiPlugin } from "@frontmcp/plugin-skilled-openapi"; import { bundle } from "./bundle"; @App({ id: "billing", name: "Billing" }) class BillingApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [BillingApp], plugins: [ SkilledOpenApiPlugin.init({ source: { type: "inline", content: bundle }, requireSignature: false, // a real server only accepts signed bundles credentials: { "billing-token": "billing-api-token" }, // in a real server, from the environment }), ], }) export default class Server {} ``` ```ts bundle.ts const invoice = { type: "object", properties: { id: { type: "string" }, amount: { type: "number" }, status: { type: "string" } } }; export const bundle = { schemaVersion: 1, bundleId: "acme:billing", version: "1.0.0", generatedAt: "2026-09-01T12:00:00.000Z", sourceDigest: "0".repeat(64), services: [{ id: "billing", baseUrl: "https://203.0.113.10/v1" }], authBindings: { api: { kind: "bearer", vaultRef: "billing-token" } }, skills: [ { id: "invoices", name: "Invoices", description: "Look up customer invoices and refund them.", instructions: "# Invoices\n\nRead an invoice with getInvoice before refunding it. Refund at most its amount.", operationIds: ["getInvoice", "refundInvoice"], }, ], operations: { getInvoice: { operationId: "getInvoice", serviceId: "billing", httpMethod: "GET", pathTemplate: "/invoices/{id}", summary: "Get an invoice", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, outputSchema: invoice, mapper: [{ inputKey: "id", type: "path", key: "id", required: true }], authBindingRef: "api", }, refundInvoice: { operationId: "refundInvoice", serviceId: "billing", httpMethod: "POST", pathTemplate: "/invoices/{id}/refunds", summary: "Refund part or all of an invoice", inputSchema: { type: "object", properties: { id: { type: "string" }, amount: { type: "number" } }, required: ["id", "amount"] }, outputSchema: { type: "object" }, mapper: [ { inputKey: "id", type: "path", key: "id", required: true }, { inputKey: "amount", type: "body", key: "amount", required: true }, ], authBindingRef: "api", }, }, }; ``` ```ts skills.test.ts import { test, expect } from "@frontmcp/testing"; test("the model sees three tools, not one per operation", async ({ mcp }) => { expect((await mcp.tools.list()).map((t: { name: string }) => t.name)).toEqual(["load_skill", "run_workflow", "search_skill"]); }); test("it finds a skill by what it does, and loads its instructions and operations", async ({ mcp }) => { const { skills } = (await mcp.tools.call("search_skill", { query: "refund an invoice" })).json(); expect(skills[0]).toMatchObject({ skillId: "invoices", name: "Invoices" }); const { skill } = (await mcp.tools.call("load_skill", { skillId: "invoices" })).json(); expect(skill.instructions).toContain("Refund at most its amount."); expect(skill.actions.map((a: { actionId: string }) => a.actionId)).toEqual(["getInvoice", "refundInvoice"]); }); ``` The price is the bundle: you write the skills, and a production server only applies bundles signed by a key it trusts. `compileSkilledBundleFromOpenApi()` builds a bundle's operations from the spec you already have. The plugin and the adapter work side by side, so a server can list the few operations the model needs in every conversation as adapter tools, and serve the long tail as skills. [Skilled OpenAPI](https://frontmcp.dev/reference/plugins/skilled-openapi) covers bundles, signing, credentials and authorization. The [Pet Store from OpenAPI](https://frontmcp.dev/examples/pet-store-from-openapi) example wraps the Swagger Petstore the same way, and shows what to check when you point the adapter at a live API. ## Recap - `OpenapiAdapter.init({ name, baseUrl, spec })` in an app's `adapters` turns every operation of an OpenAPI 3 spec into a tool, named by its `operationId`, whose input has a property for each parameter and body field. - A call sends the request the spec describes, once its arguments match the spec: one that doesn't is refused with `INVALID_INPUT`, and nothing is sent. The model gets `{ status, ok, data }`, or an error result that still carries the API's status and body. - Credentials go where the spec's security scheme says: `staticAuth` for one account, `securityResolver` or `authProviderMapper` per caller. Without them a secured call is refused, and the caller's own token is never sent unless you set `passthroughCallerToken`. - `generateOptions` decides which operations become tools at all: `filterFn`, or fields like `includeOperations`, `excludeMethods` and `excludeTags`. Annotations come from each operation's HTTP method; `toolTransforms` renames the tools, rewrites descriptions and corrects annotations. - For an API too big to list, put CodeCall in front of the adapter's tools, or serve the API as skills with Skilled OpenAPI. - Every option, and exactly what a call sends, is in the [OpenAPI adapter reference](https://frontmcp.dev/reference/adapters/openapi). ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Wrap the Desk API The Desk app has the Desk API's spec, but no tools. Turn the spec's operations into tools that call the API at `https://desk.example/v1`. ```ts desk.app.ts active import "./desk.example"; import { App } from "@frontmcp/sdk"; @App({ id: "desk", name: "Desk" }) export class DeskApp {} ``` **Solution file (alternative to the starter):** ```ts desk.app.ts solution import "./desk.example"; import { App } from "@frontmcp/sdk"; import { OpenapiAdapter } from "@frontmcp/adapters"; import { spec } from "./desk-spec"; @App({ id: "desk", name: "Desk", adapters: [OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec })], }) export class DeskApp {} ``` ```ts desk-spec.ts const ticket = { type: "object", properties: { id: { type: "string" }, title: { type: "string" }, status: { type: "string" } } }; export const spec = { openapi: "3.0.3", info: { title: "Desk API", version: "1.0.0" }, paths: { "/tickets": { get: { operationId: "listTickets", summary: "List support tickets, newest first", responses: { "200": { description: "The tickets", content: { "application/json": { schema: { type: "array", items: ticket } } } } }, }, }, "/tickets/{id}": { get: { operationId: "getTicket", summary: "Get one support ticket by its id", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "The ticket", content: { "application/json": { schema: ticket } } } }, }, }, }, }; ``` ```ts desk.example.ts // Plays the Desk API at https://desk.example, which the Playground can't reach. export const received: { method: string; url: string }[] = []; const tickets: Record = { "T-1": { id: "T-1", title: "Cannot log in", status: "open" } }; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const request = new Request(input, init); const url = new URL(request.url); if (url.hostname !== "desk.example") return realFetch(input, init); received.push({ method: request.method, url: request.url }); if (url.pathname === "/v1/tickets") return Promise.resolve(Response.json(Object.values(tickets))); const ticket = tickets[url.pathname.replace("/v1/tickets/", "")]; return Promise.resolve(ticket ? Response.json(ticket) : Response.json({ message: "No ticket with that id" }, { status: 404 })); }; ``` **Hidden fixture/test file (runs with the example):** ```ts desk.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { received } from "./desk.example"; test("`listTickets` and `getTicket` are tools", async ({ mcp }) => { const tools = await mcp.tools.list(); expect(tools).toContainTool("listTickets"); expect(tools).toContainTool("getTicket"); }); test("`getTicket` returns the API's ticket", async ({ mcp }) => { const result = await mcp.tools.call("getTicket", { id: "T-1" }); expect(result.json()).toEqual({ status: 200, ok: true, data: { id: "T-1", title: "Cannot log in", status: "open" } }); }); test("the request goes to https://desk.example/v1", async ({ mcp }) => { await mcp.tools.call("getTicket", { id: "T-1" }); expect(received.at(-1)).toEqual({ method: "GET", url: "https://desk.example/v1/tickets/T-1" }); }); ``` **Hint:** `@App` has an `adapters` list, next to `tools`. The adapter needs three options. **Solution:** `OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec })` reads the spec when the server starts and makes a tool for each of its two operations. Each call adds the operation's path to `baseUrl`, with `{id}` filled in from the tool's input, and returns the API's status and body. ### Challenge: Send the API's token The Desk API requires a bearer token, and every call fails with `Authentication required for tool 'getTicket'`. The Desk team issued the token `desk-service-token` for the help desk server. Make the adapter send it, without the model ever seeing it. Initial call configuration: ```js { tool: "getTicket", arguments: { id: "T-1" } } ``` ```ts desk.app.ts active import "./desk.example"; import { App } from "@frontmcp/sdk"; import { OpenapiAdapter } from "@frontmcp/adapters"; import { spec } from "./desk-spec"; @App({ id: "desk", name: "Desk", adapters: [OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec })], }) export class DeskApp {} ``` **Solution file (alternative to the starter):** ```ts desk.app.ts solution import "./desk.example"; import { App } from "@frontmcp/sdk"; import { OpenapiAdapter } from "@frontmcp/adapters"; import { spec } from "./desk-spec"; @App({ id: "desk", name: "Desk", adapters: [OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec, staticAuth: { jwt: "desk-service-token" } })], }) export class DeskApp {} ``` ```ts desk-spec.ts export const spec = { openapi: "3.0.3", info: { title: "Desk API", version: "1.0.0" }, components: { securitySchemes: { DeskToken: { type: "http", scheme: "bearer" } } }, security: [{ DeskToken: [] }], paths: { "/tickets/{id}": { get: { operationId: "getTicket", summary: "Get one support ticket by its id", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "The ticket", content: { "application/json": { schema: { type: "object" } } } } }, }, }, }, }; ``` ```ts desk.example.ts // Plays the Desk API at https://desk.example, which the Playground can't reach. export const received: { url: string; authorization: string | null }[] = []; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const request = new Request(input, init); if (new URL(request.url).hostname !== "desk.example") return realFetch(input, init); const authorization = request.headers.get("authorization"); received.push({ url: request.url, authorization }); if (authorization !== "Bearer desk-service-token") return Promise.resolve(Response.json({ message: "Invalid token" }, { status: 401 })); return Promise.resolve(Response.json({ id: "T-1", title: "Cannot log in", status: "open" })); }; ``` **Hidden fixture/test file (runs with the example):** ```ts token.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { received } from "./desk.example"; test("`getTicket` works", async ({ mcp }) => { const result = await mcp.tools.call("getTicket", { id: "T-1" }); expect(result).toBeSuccessful(); expect(result.json()).toMatchObject({ status: 200, ok: true }); }); test("the API gets `Authorization: Bearer desk-service-token`", async ({ mcp }) => { await mcp.tools.call("getTicket", { id: "T-1" }); expect(received.at(-1)?.authorization).toBe("Bearer desk-service-token"); }); test("the token isn't part of the tool's input", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t: { name: string }) => t.name === "getTicket"); expect(Object.keys(tool.inputSchema.properties)).toEqual(["id"]); }); ``` **Hint:** The spec names an HTTP bearer scheme. One adapter option gives every call the same credentials, and a bearer scheme reads the `jwt` field. **Solution:** `staticAuth: { jwt: "desk-service-token" }` gives every call that credential, and the adapter sends it where the spec's `DeskToken` scheme says: `Authorization: Bearer desk-service-token`. It stays on the server, out of every tool's input and result. In a real server the value comes from the environment, `staticAuth: { jwt: process.env.DESK_API_TOKEN }`, never from the source code. Adding the scheme to the model's input with `securitySchemesInInput` would make the calls work too, but the model would have to know the token, which is what the last check refuses. ### Challenge: Keep deleting out of the model's reach The model must not be able to delete tickets, or reach the API's admin operations. Make `deleteTicket`, and every operation tagged `admin`, impossible to call through the server, and keep the rest. Initial call configuration: ```js { tool: "listTickets", arguments: {} } ``` ```ts desk.app.ts active import "./desk.example"; import { App } from "@frontmcp/sdk"; import { OpenapiAdapter } from "@frontmcp/adapters"; import { spec } from "./desk-spec"; @App({ id: "desk", name: "Desk", adapters: [OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec })], }) export class DeskApp {} ``` **Solution file (alternative to the starter):** ```ts desk.app.ts solution import "./desk.example"; import { App } from "@frontmcp/sdk"; import { OpenapiAdapter } from "@frontmcp/adapters"; import { spec } from "./desk-spec"; @App({ id: "desk", name: "Desk", adapters: [ OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec, generateOptions: { filterFn: (op) => op.method !== "delete" && !op.tags?.includes("admin") }, }), ], }) export class DeskApp {} ``` ```ts desk-spec.ts const ok = { "200": { description: "OK", content: { "application/json": { schema: { type: "object" } } } } }; const id = [{ name: "id", in: "path", required: true, schema: { type: "string" } }]; export const spec = { openapi: "3.0.3", info: { title: "Desk API", version: "1.0.0" }, paths: { "/tickets": { get: { operationId: "listTickets", summary: "List support tickets, newest first", tags: ["tickets"], responses: ok }, post: { operationId: "createTicket", summary: "Open a support ticket", tags: ["tickets"], responses: ok }, }, "/tickets/{id}": { get: { operationId: "getTicket", summary: "Get one support ticket by its id", tags: ["tickets"], parameters: id, responses: ok }, delete: { operationId: "deleteTicket", summary: "Delete a support ticket", tags: ["tickets"], parameters: id, responses: ok }, }, "/admin/agents": { get: { operationId: "listAgents", summary: "List support agents, with their roles", tags: ["admin"], responses: ok }, }, "/admin/agents/{id}": { put: { operationId: "setAgentRole", summary: "Change a support agent's role", tags: ["admin"], parameters: id, responses: ok }, }, }, }; ``` ```ts desk.example.ts // Plays the Desk API at https://desk.example, which the Playground can't reach. export const received: { method: string; path: string }[] = []; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const request = new Request(input, init); const url = new URL(request.url); if (url.hostname !== "desk.example") return realFetch(input, init); received.push({ method: request.method, path: url.pathname }); return Promise.resolve(Response.json({ ok: true })); }; ``` **Hidden fixture/test file (runs with the example):** ```ts reach.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { received } from "./desk.example"; test("`deleteTicket` can't be called, even by name", async ({ mcp }) => { expect(await mcp.tools.list()).not.toContainTool("deleteTicket"); expect(await mcp.tools.call("deleteTicket", { id: "T-2" })).toBeError(); expect(received.some((r) => r.method === "DELETE")).toBe(false); }); test("neither can the admin operations", async ({ mcp }) => { await mcp.tools.call("listAgents", {}); await mcp.tools.call("setAgentRole", { id: "sam" }); expect(received.some((r) => r.path.startsWith("/v1/admin"))).toBe(false); }); test("the ticket operations that remain still work", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t: { name: string }) => t.name); expect(names).toEqual(["createTicket", "getTicket", "listTickets"]); expect(await mcp.tools.call("getTicket", { id: "T-1" })).toBeSuccessful(); }); ``` **Hint:** Hiding a tool from `tools/list` leaves it callable by name. Look for the option that decides which operations become tools in the first place, and which gets each operation's method and tags. **Solution:** `generateOptions.filterFn` runs for each operation, and an operation it returns `false` for never becomes a tool, so there's nothing to call: `deleteTicket`, `listAgents` and `setAgentRole` get an unknown-tool error, and the API receives nothing. Checking `op.tags` keeps out admin operations added to the spec later too. `excludeOperations: ["deleteTicket", "listAgents", "setAgentRole"]` would pass today, but not after the next admin operation. `toolTransforms` with `hideFromDiscovery` only takes tools out of the list, and the first check calls `deleteTicket` by name. --- # Testing and Shipping > How to test a FrontMCP server end to end with @frontmcp/testing, deploy it somewhere clients can reach, and run several instances of it behind a load balancer. Source: https://frontmcp.dev/learn/testing-and-shipping [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A FrontMCP server that works in the Playground has two more jobs before anyone relies on it: it has to keep working as you change it, and it has to run somewhere clients can reach. This chapter covers both: testing the server end to end, deploying it, and running several copies of it. **In this chapter** - [How to test a server end to end](https://frontmcp.dev/learn/testing-your-server) - [How to build and deploy a server](https://frontmcp.dev/learn/deploying-your-server) - [How to run several instances behind a load balancer](https://frontmcp.dev/learn/running-several-instances) ## Testing your server `@frontmcp/testing` starts your server, connects a real MCP client to it, and gives each test that client as `mcp`. You check what a model would see: which tools are listed, what a call returns, what an error looks like. Open the **Tests** tab: ```ts tickets.test.ts active import { test, expect } from "@frontmcp/testing"; test("lists get_ticket", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("get_ticket"); }); test("returns a ticket by id", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ id: "T-1", title: "Cannot log in", status: "open" }); }); test("fails with a readable message for an unknown ticket", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-9" }); expect(result).toBeError(); expect(result).toHaveTextContent("There's no ticket T-9"); }); ``` ```ts get-ticket.tool.ts 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}.`)); return ticket; } } ``` The same file runs in your project with `npx frontmcp test`. ### [Testing Your Server](https://frontmcp.dev/learn/testing-your-server) Learn the `mcp` fixture and the MCP matchers, what `result.json()` really returns, how to tell tool errors from protocol errors, how to test resources, prompts and tools that ask the user, and how to run tests in a project. ## Building and deploying `frontmcp build` packages your server for a target: a Node bundle, Vercel, AWS Lambda or a Cloudflare Worker. A deployed server runs in production mode, and a few things change there: - The MCP path in `frontmcp.config.ts` reaches the server only through the `frontmcp` command (`frontmcp dev` and the builds). Started any other way, it answers at `/` until you set `http.entryPath`, so a client pointed at `/mcp` gets `404`. - FrontMCP hides the message of any plain `Error` a tool throws. Fail with [`PublicMcpError`](https://frontmcp.dev/learn/your-first-tool#returning-results-and-errors) for anything the model should read. - Clients before MCP 2026-07-28 need `MCP_SESSION_SECRET`. Without it their `initialize` gets a `500` that names the secret (the code is `SESSION_SECRET_REQUIRED`), while clients on MCP 2026-07-28 and `/healthz` still work, so a health check won't notice. ### [Deploying Your Server](https://frontmcp.dev/learn/deploying-your-server) Learn how to choose a target, set the endpoint path, give production mode the secrets it needs, run the build in a container, check a deployed server and point a client at it. ## Running several instances Behind a load balancer, each request goes to whichever instance it likes, and each instance has its own memory. Clients on MCP 2026-07-28 keep no session, so they need only the same secrets on every instance. Anything a provider keeps belongs in a store every instance reaches, and older clients' sessions and rate limits are shared through Redis. ### [Running Several Instances](https://frontmcp.dev/learn/running-several-instances) Learn what instances must share, which secrets every instance needs, how to share sessions and rate limits through Redis, what happens when Redis can't be reached, and the traps to avoid. [Auth in production](https://frontmcp.dev/reference/auth/production) has the full checklist, and [Deployment](https://frontmcp.dev/reference/deployment/production-build) has every target's options. ## What's next? Start the chapter with [Testing Your Server](https://frontmcp.dev/learn/testing-your-server). After it, [Escape Hatches](https://frontmcp.dev/learn/escape-hatches) covers what to do when FrontMCP's usual path isn't enough: reading the raw request, calling other services, and running FrontMCP outside a Node server. --- # Testing Your Server > End-to-end tests for a FrontMCP server with @frontmcp/testing: the mcp fixture, MCP matchers, results, errors, elicitation, plugins and hooks, and frontmcp test. Source: https://frontmcp.dev/learn/testing-your-server [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Calling a tool in the Call tab tells you it works today. A test tells you it still works after the next change. FrontMCP's testing library, `@frontmcp/testing`, starts your server, connects a real MCP client to it, and lets you check what a model would see: which tools are listed, what a call returns, and what an error looks like. **You will learn** - How to write a test with `test`, the `mcp` fixture and `expect` - What `result.json()` returns, and why a plain value comes back as `{ value }` - How to test tool errors and protocol errors, and why they're checked differently - How to test resources, prompts, tools that ask the user, and plugins and hooks - How to run the same tests in your project with `frontmcp test` ## Your first test A test file imports `test` and `expect` from `@frontmcp/testing`. Each `test()` gets an `mcp` fixture: an MCP client, already connected to your server. Open the **Tests** tab and the file runs by itself: ```ts tickets.test.ts active import { test, expect } from "@frontmcp/testing"; test.describe("get_ticket", () => { test("is listed, with a description and a required id", async ({ mcp }) => { const tools = await mcp.tools.list(); expect(tools).toContainTool("get_ticket"); const tool = tools.find((t) => t.name === "get_ticket"); expect(tool?.description).toContain("ticket"); expect(tool?.inputSchema.required).toEqual(["id"]); }); test("returns the ticket", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-2" }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ id: "T-2", title: "Invoice total is wrong", status: "closed" }); }); }); ``` ```ts get-ticket.tool.ts 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; } } ``` Change the expected title and watch the test fail: the Tests tab shows what it expected and what it got. - `test(name, fn)` declares a test. `test.describe(name, fn)` groups tests under a heading. - `mcp.tools.list()` returns the tools exactly as `tools/list` sends them, which is what a model reads. Testing names, descriptions and schemas protects the contract, not only the code. - `mcp.tools.call(name, args)` sends a `tools/call` and wraps the result. - `expect` is Jest's `expect` with MCP matchers added: `toContainTool` checks a tool list, `toBeSuccessful` a call's outcome. Each test sends real JSON-RPC to a running server. Tick **Include requests sent by tests** in the Wire tab to see them. ## Reading results `mcp.tools.call()` returns a wrapper around the result. `result.json()` parses the result's **first text block**, and that has a consequence people don't expect: ```ts count-open.test.ts active import { test, expect } from "@frontmcp/testing"; test("a plain value comes back wrapped in { value }", async ({ mcp }) => { const result = await mcp.tools.call("count_open", {}); expect(result.json()).toEqual({ value: 2 }); expect(result.text()).toBe('{"value":2}'); expect(result).toHaveTextContent('"value":2'); }); test("an object comes back as it is", async ({ mcp }) => { const result = await mcp.tools.call("ticket_stats", {}); expect(result.json()).toEqual({ open: 2, closed: 1 }); expect(result.raw.structuredContent).toEqual({ open: 2, closed: 1 }); }); ``` ```ts count-open.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; import { tickets } from "./store"; @Tool({ name: "count_open", description: "How many tickets are open", inputSchema: {} }) export class CountOpen extends ToolContext { async execute() { return tickets.filter((t) => t.status === "open").length; } } @Tool({ name: "ticket_stats", description: "Count open and closed tickets", inputSchema: {} }) export class TicketStats extends ToolContext { async execute() { const open = tickets.filter((t) => t.status === "open").length; return { open, closed: tickets.length - open }; } } ``` ```ts store.ts export 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" }, ]; ``` `count_open` returns the number `2`, but FrontMCP sends plain values as `{"value":2}`, so that's the text `json()` parses. An object is sent as it is. `result.text()` gives you the raw text, `toHaveTextContent()` checks that it contains a string, and `result.raw` is the whole MCP result: `content`, `structuredContent`, `isError` and `_meta`. Return objects from your tools. They're easier for a model to read, and easier to test. ## Testing errors A call can fail in two ways, and they're checked differently. A **tool error** is a normal result with `isError: true`: the request was fine, but the tool couldn't do it. A **protocol error** is a JSON-RPC `error` response with a numeric code: the request itself couldn't be handled. ```ts errors.test.ts active import { test, expect } from "@frontmcp/testing"; test.describe("tool errors", () => { test("an unknown ticket fails with a message the model can read", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-9" }); expect(result).toBeError(); expect(result).toHaveTextContent("There's no ticket T-9"); expect(result.raw._meta?.code).toBe("PUBLIC_ERROR"); }); test("a malformed id is rejected before execute() runs", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "ticket two" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("INVALID_INPUT"); }); }); test.describe("protocol errors", () => { test("reading a resource that doesn't exist is error -32602", async ({ mcp }) => { const content = await mcp.resources.read("docs://no-such-page"); expect(content).toBeError(-32602); }); }); ``` ```ts get-ticket.tool.ts 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; } } ``` ```ts refund-policy.resource.ts import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "refund-policy", uri: "docs://refund-policy", mimeType: "text/markdown", description: "When customers can get a refund", }) export class RefundPolicy extends ResourceContext { async execute(uri: string) { return { contents: [{ uri, mimeType: "text/markdown", text: "# Refunds\n\nFull refund within 30 days of purchase." }] }; } } ``` - `toBeError()` with no argument passes for either kind. - `toBeError(code)` with a number compares a **JSON-RPC error code**, so it only matches protocol errors, like `-32602` for a resource that doesn't exist. (That's the code under protocol 2026-07-28, which the Playground speaks. Older protocol versions use `-32002` for it.) - Tool errors have no JSON-RPC code. Their kind is in `result.raw._meta.code`, which `toBeError(code)` with a string compares: `INVALID_INPUT` when the arguments didn't match the schema, `PUBLIC_ERROR` for `this.fail(new PublicMcpError(...))` (or the code you gave the error), and `TOOL_EXECUTION_ERROR` when `execute()` threw a plain `Error`. The [error codes](https://frontmcp.dev/reference/errors) page lists them all. > **Pitfall: toBeError(-32602) doesn't match bad tool input** > > Invalid params is `-32602` in JSON-RPC, so it's tempting to write `expect(result).toBeError(-32602)` for a tool call with bad arguments. It fails: FrontMCP reports bad tool input as a tool error, with `_meta.code` set to `"INVALID_INPUT"`, so that the model can read what was wrong and try again. Pass the code as a string instead: `toBeError("INVALID_INPUT")` checks `result.raw._meta?.code`. ## Testing resources and prompts Resources and prompts have their own methods on `mcp` and their own matchers: ```ts content.test.ts active import { test, expect } from "@frontmcp/testing"; test("the refund policy is listed and is Markdown", async ({ mcp }) => { expect(await mcp.resources.list()).toContainResource("docs://refund-policy"); const policy = await mcp.resources.read("docs://refund-policy"); expect(policy).toHaveMimeType("text/markdown"); expect(policy).toHaveTextContent("30 days"); }); test("summarize_ticket asks about the right ticket", async ({ mcp }) => { expect(await mcp.prompts.list()).toContainPrompt("summarize_ticket"); const prompt = await mcp.prompts.get("summarize_ticket", { id: "T-1" }); expect(prompt).toHaveMessages(1); expect(prompt.messages[0].role).toBe("user"); expect(prompt.messages[0].content).toMatchObject({ type: "text", text: expect.stringContaining("T-1") }); }); ``` ```ts refund-policy.resource.ts import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "refund-policy", uri: "docs://refund-policy", mimeType: "text/markdown", description: "When customers can get a refund", }) export class RefundPolicy extends ResourceContext { async execute(uri: string) { return { contents: [{ uri, mimeType: "text/markdown", text: "# Refunds\n\nFull refund within 30 days of purchase." }] }; } } ``` ```ts summarize.prompt.ts import { Prompt, PromptContext } from "@frontmcp/sdk"; @Prompt({ name: "summarize_ticket", description: "Summarize a support ticket for a handover", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class SummarizeTicket extends PromptContext { async execute({ id }: { id: string }) { return { messages: [ { role: "user" as const, content: { type: "text" as const, text: `Summarize ticket ${id} in three bullet points for the next agent.` } }, ], }; } } ``` | Method | Returns | Matchers | | --- | --- | --- | | `mcp.resources.list()` | the resources from `resources/list` | `toContainResource(uri)` | | `mcp.resources.read(uri)` | a wrapper with `text()`, `json()`, `mimeType()` and `raw` | `toHaveMimeType(type)`, `toHaveTextContent(text)`, `toBeError(code)` | | `mcp.prompts.list()` | the prompts from `prompts/list` | `toContainPrompt(name)` | | `mcp.prompts.get(name, args)` | a wrapper with `messages` and `description` | `toHaveMessages(count)` | ## Testing a tool that asks the user When a tool calls `this.elicit()`, nobody is there to answer. `mcp.onElicitation()` sets the answer the test client gives. The handler receives the question, with its `message` and `requestedSchema`, and returns `{ action, content }`: ```ts close-ticket.test.ts active import { test, expect } from "@frontmcp/testing"; test("asks with a confirm checkbox, and closes on yes", async ({ mcp }) => { let schema: unknown; mcp.onElicitation((request) => { schema = request.requestedSchema; return { action: "accept", content: { confirm: true } }; }); const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(schema).toMatchObject({ properties: { confirm: { type: "boolean" } } }); expect(result.json()).toEqual({ id: "T-1", closed: true }); }); test("stays open when the user declines", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "decline" })); const result = await mcp.tools.call("close_ticket", { id: "T-2" }); expect(result.json()).toEqual({ id: "T-2", closed: false }); }); ``` ```ts close-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket. Asks the user to confirm first.", inputSchema: { id: z.string() }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ${id}?`, z.object({ confirm: z.boolean() })); return { id, closed: answer.status === "accept" && answer.content?.confirm === true }; } } ``` Register the handler before the call that asks. A test that answers `decline` and `cancel` as well as `accept` covers the paths users actually take. [Asking the User Mid-Call](https://frontmcp.dev/learn/asking-the-user) explains what happens between the question and the answer. ## Testing plugins and hooks A plugin works around your tools, so a call's result often can't show what it did. A cached answer carries the same data as a fresh one, and a refused call doesn't say whether the tool ran first. Two techniques do most of the work: count what the tool does, and call as more than one user. This help desk caches ticket lookups, freezes ticket changes with a hook of its own, lets only agents close tickets, and allows two exports an hour: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts plugins.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; import { FreezePlugin } from "./freeze.plugin"; import { desk } from "./desk"; const asNour = { authContext: { user: { sub: "nour", roles: ["agent"] } } }; test("a repeat lookup is answered from the cache", async ({ mcp }) => { const before = desk.lookups; const first = await mcp.tools.call("get_ticket", { id: "T-2" }); const second = await mcp.tools.call("get_ticket", { id: "T-2" }); expect(desk.lookups - before).toBe(1); expect(first.raw._meta?.cache).toBeUndefined(); expect(second.raw._meta?.cache).toBe("hit"); expect(second.json()).toEqual(first.json()); }); test("only an agent sees close_ticket", async ({ mcp }) => { expect(await mcp.tools.list()).not.toContainTool("close_ticket"); const server = await FrontMcpInstance.createDirect(config); try { const { tools } = await server.listTools(asNour); expect(tools.map((t) => t.name)).toContain("close_ticket"); } finally { await server.dispose(); } }); test("the freeze refuses an agent's call before execute() runs", async () => { const server = await FrontMcpInstance.createDirect(config); try { // createDirect() throws the error a client would get as a result await expect(server.callTool("close_ticket", { id: "T-1" }, asNour)).rejects.toMatchObject({ code: "CHANGE_FREEZE" }); expect(desk.tickets.get("T-1")?.status).toBe("open"); } finally { await server.dispose(); } }); test("with the freeze lifted, the agent can close a ticket", async () => { const server = await FrontMcpInstance.createDirect({ ...config, plugins: [FreezePlugin.init({ frozen: false })] }); try { const result = await server.callTool("close_ticket", { id: "T-3" }, asNour); expect(result.structuredContent).toEqual({ id: "T-3", status: "closed" }); } finally { await server.dispose(); } }); test("the third export in an hour is refused", async ({ mcp }) => { expect(await mcp.tools.call("export_tickets")).toBeSuccessful(); expect(await mcp.tools.call("export_tickets")).toBeSuccessful(); expect(await mcp.tools.call("export_tickets")).toBeError("RATE_LIMIT_EXCEEDED"); }); ``` ```ts freeze.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, PublicMcpError, ToolHook } from "@frontmcp/sdk"; type FreezeOptions = { frozen: boolean }; @Plugin({ name: "freeze", description: "Refuses every tool that changes tickets during a change freeze" }) export class FreezePlugin extends DynamicPlugin { constructor(private readonly options: FreezeOptions) { super(); } @ToolHook.Will("execute", { filter: (ctx) => !ctx.state.tool?.metadata.annotations?.readOnlyHint }) async refuse(ctx: FlowCtxOf<"tools:call-tool">) { if (!this.options.frozen) return; throw new PublicMcpError(`Ticket changes are frozen until the release ships, so ${ctx.state.tool?.metadata.name} is unavailable.`, "CHANGE_FREEZE"); } } ``` ```ts tools.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { desk } from "./desk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() }, annotations: { readOnlyHint: true }, cache: { ttl: 300 }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return desk.lookup(id); } } @Tool({ name: "close_ticket", description: "Close a support ticket. Support agents only.", inputSchema: { id: z.string() }, authorities: "agent" }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = desk.tickets.get(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); ticket.status = "closed"; return { id, status: ticket.status }; } } @Tool({ name: "export_tickets", description: "Export every ticket as CSV. At most 2 exports an hour.", inputSchema: {}, annotations: { readOnlyHint: true }, rateLimit: { maxRequests: 2, windowMs: 60 * 60_000 }, }) export class ExportTickets extends ToolContext { async execute() { return { csv: [...desk.tickets.values()].map((t) => `${t.id},${t.status}`).join("\n") }; } } ``` ```ts desk.ts import { PublicMcpError } from "@frontmcp/sdk"; // Stands in for the ticket database, and counts the lookups it answers. export const desk = { lookups: 0, tickets: new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }], ["T-3", { id: "T-3", title: "Login link expired", status: "open" }], ]), lookup(id: string) { desk.lookups++; const ticket = desk.tickets.get(id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); return { ...ticket }; }, }; ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { CachePlugin } from "@frontmcp/plugin-cache"; import { CloseTicket, ExportTickets, GetTicket } from "./tools"; import { FreezePlugin } from "./freeze.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, CloseTicket, ExportTickets], plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })], }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [FreezePlugin.init({ frozen: true })], authorities: { claimsMapping: { roles: "roles" }, profiles: { agent: { roles: { any: ["agent"] } } }, }, }; @FrontMcp(config) export default class Server {} ``` What each test relies on: - **Count the work behind the tool.** `desk` counts the lookups it answers, so the cache test can tell a hit from a miss: two calls, one lookup. The cache also marks a hit with `_meta.cache: "hit"`. The freeze test checks the ticket is still open, which proves the hook stopped the call before `execute()` changed anything, and not after. - **Call as the user the plugin cares about.** The Playground's client is anonymous and has no roles, so `authorities` hides `close_ticket` from it, and a call by name is refused with `AUTHORITY_DENIED` before the freeze hook is reached. [`createDirect()`](https://frontmcp.dev/learn/running-frontmcp-anywhere) runs the same configuration in-process as `nour`, an agent: a second client, with its own list. `createDirect()` throws a failed call's error rather than returning it, so the test checks its `code` with `rejects`. The cache cares about the caller too: it keeps one entry per caller unless `keyByIdentity` is `false`, as here, and under MCP 2026-07-28 the Playground's client is a new anonymous caller on every request ([Caching Results](https://frontmcp.dev/learn/caching-results#sharing-answers-between-callers)). - **Build a second server to test another setting.** The freeze is on in `config`, so the test that lifts it starts a server with `FreezePlugin.init({ frozen: false })` in place of the original. - **Tests share a server, and its memory.** A cache entry stored by one test answers the next test's call, and a rate limit counts every test's calls. So the cache test measures `desk.lookups - before` and uses a ticket no other test looks up, and only one test calls `export_tickets`. Under `frontmcp test`, the tests of a file share one server in the same way. [Limiting and Timing Out Calls](https://frontmcp.dev/learn/limiting-calls) covers what a refused call returns. In a project, `createDirect()` works the same way. To call over HTTP as a signed-in user instead, `server.createClient({ token })` connects another client with a token from the `auth` fixture: see [Calling as a signed-in user](https://frontmcp.dev/reference/testing/auth#calling-as-a-signed-in-user). ## Running tests in your project The tests on this page are the same code you run in a project. There, instead of a Playground wrapping your files, `test.use()` points at your server's entry file. A project made by `frontmcp create` already has what `frontmcp test` needs, Jest 30 included; in another project, install the package, Jest and `tsx`, which starts your server: ```bash npm install -D @frontmcp/testing jest tsx ``` ```ts e2e/tickets.e2e.spec.ts import { test, expect } from "@frontmcp/testing"; test.use({ server: "./src/main.ts" }); test.describe("get_ticket", () => { test("returns the ticket", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-2" }); expect(result).toBeSuccessful(); expect(result.json()).toMatchObject({ id: "T-2", status: "closed" }); }); }); ``` ```bash npx frontmcp test ``` `@frontmcp/testing` starts the server from `./src/main.ts` on a free port, connects the client, runs the tests, and stops the server. `frontmcp test` sets up Jest for you, and by default it looks for `e2e/**/*.e2e.spec.ts` and `src/**/*.spec.ts`, so name your files that way (the Playground also accepts `*.test.ts`). Useful flags: ```bash npx frontmcp test --runInBand # one file at a time, the usual choice for E2E tests npx frontmcp test --watch # rerun on change npx frontmcp test --coverage # collect coverage ``` `test.use()` takes more options, such as `port`, `env` and `publicMode`; [`test.use(options)`](https://frontmcp.dev/reference/testing#testuseoptions) lists them. The [`@frontmcp/testing` reference](https://frontmcp.dev/reference/testing) covers the rest of the test API. > **Note: Where the Playground and frontmcp test differ** > > The Playground runs your tests with its own client, which speaks MCP 2026-07-28. The Node client in `@frontmcp/testing` can't speak 2026-07-28, and differs in ways you can see in these tests: > > - It speaks protocol version 2025-06-18, where a resource that doesn't exist is error `-32002` rather than `-32602`. > - It asks for progress only after `mcp.notifications.collectProgress()`, and for log messages only after a `logging/setLevel` request, and the notifications can arrive after the call returns. [Checking notifications](https://frontmcp.dev/reference/testing#checking-notifications) shows a test that works with both clients. > - Without `mcp.onElicitation()`, it answers every question with `decline`, where the Playground fails the test. ## Recap - `test(name, async ({ mcp }) => ...)` runs against your server with a connected client. `test.describe` groups tests, and `test.use({ server })` points at your entry file in a project. - Test what the model sees: `mcp.tools.list()` with `toContainTool`, and each tool's description and schema. - `result.json()` parses the first text block. A plain value `5` comes back as `{ value: 5 }`, so return objects. - `toBeError()` passes for any error. `toBeError(-32602)` checks a JSON-RPC code, which only protocol errors have; `toBeError("INVALID_INPUT")` checks a tool error's `_meta.code`. - `toContainResource`, `toHaveMimeType`, `toContainPrompt` and `toHaveMessages` cover resources and prompts. `mcp.onElicitation()` answers a tool's questions. - To test a plugin or a hook, count the work behind the tool, and call as each kind of user it treats differently, with `createDirect()` in the Playground. Tests share a server, so cache entries and rate-limit counts carry over from one test to the next. - Run the tests with `npx frontmcp test`. ## Try some challenges Each challenge runs hidden checks against your code, and runs your visible tests too. Edit the code, then press **Check**. ### Challenge: Fix the tests, not the server The server is right, and both tests are wrong: one misreads what `json()` returns, and the other expects the wrong kind of error. Fix the tests so they pass, without changing the tools. ```ts tickets.test.ts active import { test, expect } from "@frontmcp/testing"; test("counts the open tickets", async ({ mcp }) => { const result = await mcp.tools.call("count_open", {}); expect(result.json()).toBe(2); }); test("rejects an id that isn't T-", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "ticket two" }); expect(result).toBeError(-32602); }); ``` **Solution file (alternative to the starter):** ```ts tickets.test.ts solution import { test, expect } from "@frontmcp/testing"; test("counts the open tickets", async ({ mcp }) => { const result = await mcp.tools.call("count_open", {}); expect(result.json()).toEqual({ value: 2 }); }); test("rejects an id that isn't T-", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "ticket two" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("INVALID_INPUT"); }); ``` ```ts tickets.tool.ts 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" }, { id: "T-3", title: "Login link expired", status: "open" }, ]; @Tool({ name: "count_open", description: "How many tickets are open", inputSchema: {} }) export class CountOpen extends ToolContext { async execute() { return tickets.filter((t) => t.status === "open").length; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", 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}.`)); return ticket; } } ``` **Hidden fixture/test file (runs with the example):** ```ts server.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`count_open` still returns the plain number 2", async ({ mcp }) => { const result = await mcp.tools.call("count_open", {}); expect(result.raw.structuredContent).toEqual({ value: 2 }); }); test("`get_ticket` still rejects a malformed id with INVALID_INPUT", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "ticket two" }); expect(result.raw.isError).toBe(true); expect(result.raw._meta?.code).toBe("INVALID_INPUT"); }); ``` **Hint:** Look at the failure messages in the Checks tab. What text does `count_open` send? And does a tool error have a JSON-RPC code? **Solution:** `count_open` returns a plain number, which FrontMCP sends as `{"value":2}`, so `json()` is `{ value: 2 }`. And a bad argument is a tool error, not a protocol error: the result has `isError: true` and `_meta.code: "INVALID_INPUT"`, but no JSON-RPC code for `toBeError(-32602)` to match. The hidden checks make sure the tools weren't changed to fit the old tests. ### Challenge: Catch the bug, then fix it Users say `close_ticket` closes tickets even when they click **Decline**. Write a test that declines and expects `closed: false` (use `T-2`), watch it fail, then fix the tool so it passes. ```ts close-ticket.test.ts active import { test, expect } from "@frontmcp/testing"; test("closes the ticket when the user confirms", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "accept", content: { confirm: true } })); const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.json()).toEqual({ id: "T-1", closed: true }); }); // Your test: the user declines, and the ticket stays open. ``` **Solution file (alternative to the starter):** ```ts close-ticket.test.ts solution import { test, expect } from "@frontmcp/testing"; test("closes the ticket when the user confirms", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "accept", content: { confirm: true } })); const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.json()).toEqual({ id: "T-1", closed: true }); }); test("leaves the ticket open when the user declines", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "decline" })); const result = await mcp.tools.call("close_ticket", { id: "T-2" }); expect(result.json()).toEqual({ id: "T-2", closed: false }); }); ``` ```ts close-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const closed = new Set(); @Tool({ name: "close_ticket", description: "Close a support ticket. Asks the user to confirm first.", inputSchema: { id: z.string().regex(/^T-\d+$/) }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ${id}?`, z.object({ confirm: z.boolean() })); if (answer.content?.confirm === false) return { id, closed: false }; closed.add(id); return { id, closed: true }; } } ``` **Solution file (alternative to the starter):** ```ts close-ticket.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; const closed = new Set(); @Tool({ name: "close_ticket", description: "Close a support ticket. Asks the user to confirm first.", inputSchema: { id: z.string().regex(/^T-\d+$/) }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ${id}?`, z.object({ confirm: z.boolean() })); if (answer.status !== "accept" || answer.content?.confirm !== true) return { id, closed: false }; closed.add(id); return { id, closed: true }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts checks.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("declining leaves the ticket open", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "decline" })); expect((await mcp.tools.call("close_ticket", { id: "T-3" })).json()).toEqual({ id: "T-3", closed: false }); }); test("cancelling leaves the ticket open", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "cancel" })); expect((await mcp.tools.call("close_ticket", { id: "T-4" })).json()).toEqual({ id: "T-4", closed: false }); }); test("confirming still closes it", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "accept", content: { confirm: true } })); expect((await mcp.tools.call("close_ticket", { id: "T-5" })).json()).toEqual({ id: "T-5", closed: true }); }); ``` **Hint:** A declined question has `status: "decline"` and no `content` at all. What does `answer.content?.confirm === false` do when `content` is missing? **Solution:** The tool only refused when `confirm` was exactly `false`. A decline or a cancel has no `content`, so `answer.content?.confirm` is `undefined`, the check didn't match, and the tool closed the ticket. The fix turns the condition around: close only on `accept` with `confirm === true`. Your decline test failed before the fix and passes after it, which is what makes it worth keeping. ### Challenge: Make the tests pass These tests describe what the refund policy and the handover prompt should be. The server doesn't match them yet. Change the resource and the prompt, not the tests, until every test passes. ```ts content.test.ts active import { test, expect } from "@frontmcp/testing"; test("the refund policy is Markdown", async ({ mcp }) => { expect(await mcp.resources.list()).toContainResource("docs://refund-policy"); const policy = await mcp.resources.read("docs://refund-policy"); expect(policy).toHaveMimeType("text/markdown"); expect(policy).toHaveTextContent("# Refunds"); }); test("the handover prompt is a single user message about the ticket", async ({ mcp }) => { expect(await mcp.prompts.list()).toContainPrompt("summarize_ticket"); const prompt = await mcp.prompts.get("summarize_ticket", { id: "T-7" }); expect(prompt).toHaveMessages(1); expect(prompt.messages[0].role).toBe("user"); expect(prompt.messages[0].content).toMatchObject({ text: expect.stringContaining("T-7") }); }); ``` ```ts refund-policy.resource.ts import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "refund-policy", uri: "docs://refund-policy", mimeType: "text/plain", description: "When customers can get a refund", }) export class RefundPolicy extends ResourceContext { async execute(uri: string) { return { contents: [{ uri, mimeType: "text/plain", text: "Refunds: full refund within 30 days of purchase." }] }; } } ``` **Solution file (alternative to the starter):** ```ts refund-policy.resource.ts solution import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "refund-policy", uri: "docs://refund-policy", mimeType: "text/markdown", description: "When customers can get a refund", }) export class RefundPolicy extends ResourceContext { async execute(uri: string) { return { contents: [{ uri, mimeType: "text/markdown", text: "# Refunds\n\nFull refund within 30 days of purchase." }] }; } } ``` ```ts summarize.prompt.ts import { Prompt, PromptContext } from "@frontmcp/sdk"; @Prompt({ name: "summarize_ticket", description: "Summarize a support ticket for a handover", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class SummarizeTicket extends PromptContext { async execute({ id }: { id: string }) { return { messages: [ { role: "user" as const, content: { type: "text" as const, text: "You are a support agent handing over a ticket." } }, { role: "user" as const, content: { type: "text" as const, text: `Summarize ticket ${id} in three bullet points.` } }, ], }; } } ``` **Solution file (alternative to the starter):** ```ts summarize.prompt.ts solution import { Prompt, PromptContext } from "@frontmcp/sdk"; @Prompt({ name: "summarize_ticket", description: "Summarize a support ticket for a handover", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class SummarizeTicket extends PromptContext { async execute({ id }: { id: string }) { return { messages: [ { role: "user" as const, content: { type: "text" as const, text: `You're handing over a support ticket. Summarize ticket ${id} in three bullet points.` }, }, ], }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts checks.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("`docs://refund-policy` is text/markdown and starts with a heading", async ({ mcp }) => { const policy = await mcp.resources.read("docs://refund-policy"); expect(policy).toHaveMimeType("text/markdown"); expect(policy.text()?.startsWith("# ")).toBe(true); }); test("the resource still mentions the 30 days", async ({ mcp }) => { expect(await mcp.resources.read("docs://refund-policy")).toHaveTextContent("30 days"); }); test("`summarize_ticket` returns one user message naming the ticket", async ({ mcp }) => { const prompt = await mcp.prompts.get("summarize_ticket", { id: "T-8" }); expect(prompt).toHaveMessages(1); expect(prompt.messages[0]).toMatchObject({ role: "user", content: { type: "text", text: expect.stringContaining("T-8") } }); }); ``` **Hint:** The resource's MIME type appears twice: in `@Resource` and in what `execute()` returns. The prompt needs its two messages combined into one. **Solution:** The resource now says `text/markdown` in both places and its text is Markdown, and the prompt sends one user message that includes the ticket id. Writing the tests first and changing the server until they pass is a good way to pin down what a resource or prompt should look like before a client depends on it. --- # Deploying Your Server > How to take a FrontMCP server from frontmcp build to a running server clients can reach. Choosing a target, the endpoint path, production mode and its secrets, running in a container, health checks, and connecting a client. Source: https://frontmcp.dev/learn/deploying-your-server [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `frontmcp dev`, from the FrontMCP CLI, runs your server on your machine, restarts it when a file changes, and forgives a lot. A deployed server runs from a build, in production mode, somewhere clients can reach, and nobody is watching its log. This lesson takes the help desk from `frontmcp build` to a running server a client can connect to, and stops at the places where a server that worked in development stops working once it's deployed. **You will learn** - How `frontmcp build` packages your server, and which target to choose - Where the MCP endpoint is, and why to set `http.entryPath` in the server - What production mode hides from callers, and which secrets it needs - How to run the build in a container - How to check a deployed server, and point a client at it ## Building the server `frontmcp build` compiles your TypeScript and packages it for a **target**. Without `--target`, it builds each entry of `deployments` in `frontmcp.config.ts`, and a project from `frontmcp create --target node` lists one, `node`: ```bash npx frontmcp build ``` ```text [build:exec] bundle created: dist/node/help-desk.bundle.js (6.6 KB) [build:exec] manifest: dist/node/help-desk.manifest.json [build:exec] runner: dist/node/help-desk [build:exec] installer: dist/node/install-help-desk.sh ``` The file names come from `name` in `frontmcp.config.ts`. Start the bundle and ask it whether it's alive: ```bash PORT=3000 node dist/node/help-desk.bundle.js curl http://127.0.0.1:3000/healthz ``` ```json {"status":"ok","server":{"name":"help-desk","version":"1.0.0"},"runtime":{"platform":"darwin","runtime":"node","deployment":"standalone","env":"development"},"uptime":2.698} ``` The bundle is a few kilobytes because it's only your code. It imports `@frontmcp/sdk` and FrontMCP's other packages from `node_modules`, so it runs in a folder where your production dependencies are installed, with `npm ci --omit=dev`. ### Choosing a target The same `@FrontMcp` entry file builds for several platforms. Pick one with `--target`, or list it in `deployments` so that `npm run build` builds it: | Target | Runs as | Know this first | | --- | --- | --- | | `node` | An HTTP server, on a machine, a VM or in a container | Needs `node_modules` beside it. The rest of this lesson uses it. | | `vercel` | A Vercel function | One bundle with FrontMCP inside. In production mode when `NODE_ENV=production`, which Vercel sets. No `/readyz`. | | `lambda` | An AWS Lambda handler | Like `vercel`, but Lambda doesn't set `NODE_ENV`: set it yourself. The build needs `@codegenie/serverless-express` installed, which `frontmcp create --target lambda` adds. | | `cloudflare` | A Cloudflare Worker | Experimental. Can't use Redis, and keeps no sessions for clients before MCP 2026-07-28. | Each target has a page in the Reference: [Node.js and Docker](https://frontmcp.dev/reference/deployment/node), [Vercel](https://frontmcp.dev/reference/deployment/vercel), [AWS Lambda](https://frontmcp.dev/reference/deployment/aws-lambda) and [Cloudflare Workers](https://frontmcp.dev/reference/deployment/cloudflare-workers). ## Where the endpoint is A project from `frontmcp create` sets the MCP path in `frontmcp.config.ts`, as `transport: { http: { port: 3000, path: "/mcp" } }`. `frontmcp dev` reads it and says so when it starts: ```text [dev] listening on port: 3000 [dev] MCP endpoint path: /mcp ``` So you point your client at `http://localhost:3000/mcp`, and it works. But `frontmcp.config.ts` is read by the `frontmcp` command, never by your server. `frontmcp dev` passes the path on when it starts the server, and `frontmcp build` writes it into what it builds: the `node` bundle sets it as it starts, unless `FRONTMCP_HTTP_ENTRY_PATH` is already set, and the serverless builds write it into their setup file. Start the server any other way, like with `tsx` or from your own `tsc` output, and it serves MCP at `/`, so every request to `/mcp` fails: ```bash PORT=3000 npx tsx src/main.ts curl -s -w '%{http_code}\n' -X POST http://127.0.0.1:3000/mcp -H 'content-type: application/json' -d '{}' ``` ```text Error
              Cannot POST /mcp
              404 ``` A server you embed in other code with `createFetchHandler()`, as the tests below do, serves `/` too: it's given a configuration, and nothing else. Put the path in the server itself, and every way of starting it agrees: ```ts src/main.ts import "reflect-metadata"; import { FrontMcp } from "@frontmcp/sdk"; import { HelpDesk } from "./help-desk.app"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], http: { entryPath: "/mcp" }, // ✅ the same path however the server is started }) export default class Server {} ``` > **Note: Changed in 1.9: the node bundle serves the configured path** > > In 1.8.7, `node dist/node/.bundle.js` didn't pass the path on, so a container built from the Dockerfile that `frontmcp create` writes served MCP at `/`, and answered clients at `/mcp` with `404` until `http.entryPath` was set. A server has one MCP endpoint. The tests below send a call the way a client on MCP 2026-07-28 does, to `/mcp` and to `/`. Delete the `http` line in `config.ts` and watch them fail the way a server started without the path does: ```ts endpoint.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; // One tools/call, as a 2026-07-28 client sends it to this URL function getTicket(url: string) { return new Request(url, { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "get_ticket" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "get_ticket", arguments: { id: "T-1" }, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); } test("the server answers at /mcp", async () => { const handler = await FrontMcpInstance.createFetchHandler(config); const response = await handler(getTicket("https://desk.example.com/mcp")); expect(response.status).toBe(200); expect((await response.json()).result.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" }); }); test("and nowhere else", async () => { const handler = await FrontMcpInstance.createFetchHandler(config); const response = await handler(getTicket("https://desk.example.com/")); expect(response.status).toBe(404); expect(await response.json()).toEqual({ error: "Not Found", entryPaths: ["/mcp"] }); }); ``` ```ts config.ts import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], http: { entryPath: "/mcp" }, }; ``` ```ts help-desk.app.ts import { App, 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}.`)); return ticket; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` The tests use [`createFetchHandler()`](https://frontmcp.dev/learn/running-frontmcp-anywhere#serving-mcp-from-any-runtime), which takes the same configuration as `@FrontMcp`, so they can send requests without a port. Its `404` is JSON and names the endpoint; FrontMCP's Node server answers a wrong path with Express's `Cannot POST /` page instead. ## Production mode A server is in production when `NODE_ENV` is `production` as it starts. (Vercel sets it for its functions, Lambda doesn't, and a Worker is in production once `wrangler deploy` bundles it.) Two things change that you'll notice. ### Errors the model can read In development, a tool that throws a plain `Error` sends the model its message and stack. In production, FrontMCP sends an error ID instead, and writes the message to the log next to it. That's right for a database error, and wrong for a message the model needs: Initial call configuration: ```js { tool: "sync_ticket", arguments: { id: "T-1" } } ``` ```ts tools.ts active import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "sync_ticket", description: "Copy a ticket to the CRM.", inputSchema: { id: z.string() } }) export class SyncTicket extends ToolContext { async execute({ id }: { id: string }): Promise<{ synced: string }> { throw new Error(`connect ECONNREFUSED 10.0.4.12:5432 while syncing ${id}`); } } @Tool({ name: "get_ticket", description: "Get one support ticket by its 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}. Ticket ids look like T-1.`)); return { id, title: "Cannot log in", status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SyncTicket, GetTicket] }) export class HelpDesk {} ``` ```ts production.test.ts import { test, expect } from "@frontmcp/testing"; import { createErrorHandler, FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDesk } from "./tools"; // Formats errors the way tools/call does with NODE_ENV=production const production = createErrorHandler({ isDevelopment: false }); async function errorFrom(name: string, args: Record): Promise { const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] }); try { await server.callTool(name, args); } catch (error) { return error; } finally { await server.dispose(); } throw new Error(`${name} didn't fail`); } test("a plain Error reaches the model as an error ID", async () => { const result = production.handle(await errorFrom("sync_ticket", { id: "T-1" })); expect(result.content[0].text).toBe(`Internal FrontMCP error. Please contact support with error ID: ${result._meta?.errorId}`); expect(result._meta?.code).toBe("TOOL_EXECUTION_ERROR"); }); test("a PublicMcpError keeps its message", async () => { const result = production.handle(await errorFrom("get_ticket", { id: "T-9" })); expect(result.content[0].text).toBe("There's no ticket T-9. Ticket ids look like T-1."); expect(result._meta?.code).toBe("PUBLIC_ERROR"); }); ``` The Call tab shows what development sends: the database's address, and a stack. The Playground formats errors the way development does, so the tests format the same errors with `createErrorHandler({ isDevelopment: false })`, which is what `NODE_ENV=production` means to `tools/call`. The `node` build, started with `NODE_ENV=production`, returns the same result: ```json {"content":[{"type":"text","text":"Internal FrontMCP error. Please contact support with error ID: err_e4fad6ec3f5b44e9"}],"isError":true,"_meta":{"errorId":"err_e4fad6ec3f5b44e9","code":"TOOL_EXECUTION_ERROR",…}} ``` Its log has `Internal error: Tool "sync_ticket" execution failed: connect ECONNREFUSED 10.0.4.12:5432 while syncing T-1`, with the stack and the same `errorId`. So fail with [`PublicMcpError`](https://frontmcp.dev/learn/your-first-tool#returning-results-and-errors) for anything the model should read, and let everything else become an ID you can look up. ### The secrets it needs In development, FrontMCP makes up the secrets it needs and logs a warning. In production it refuses to, so a deployed server needs them in its environment: | Variable | Needed when | Without it, in production | | --- | --- | --- | | `MCP_SESSION_SECRET` | Clients on protocol versions before 2026-07-28 may connect. | Their `initialize` gets `500`, with `server_misconfigured` and the secret's name. | | `JWT_SECRET` | The server issues its own tokens: `local` or `remote` [auth](https://frontmcp.dev/learn/authenticating-clients). At least 32 bytes. | The server doesn't start. | | `VAULT_SECRET` | A tool [asks the user](https://frontmcp.dev/learn/asking-the-user) more than one question, and the server runs as [several instances](https://frontmcp.dev/learn/running-several-instances). | See the next lesson. | A missing `MCP_SESSION_SECRET` is easy to miss, because most of the server still works. Started with `NODE_ENV=production` and no secret, the help desk answers `/healthz` with `200`, and a call from a client on MCP 2026-07-28 too. An older client's first request fails: ```bash NODE_ENV=production PORT=3000 node dist/node/help-desk.bundle.js curl -s -w '\n%{http_code}\n' http://127.0.0.1:3000/mcp \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"desk-check","version":"1"}}}' ``` ```json {"error":"server_misconfigured","code":"SESSION_SECRET_REQUIRED","message":"Set MCP_SESSION_SECRET in the deployment environment (e.g. `wrangler secret put MCP_SESSION_SECRET` on Cloudflare, or the platform environment settings on Node, Vercel and Lambda). Session IDs are encrypted with it, and production refuses the development machine-id fallback."} 500 ``` The response names the secret; the log also says `Session secret is required for session ID encryption`, with the code `SESSION_SECRET_REQUIRED`. Generate the secret once, keep it in your platform's secret store, and give it to the process: ```bash openssl rand -hex 32 # store the output as MCP_SESSION_SECRET ``` With it set, the same `initialize` answers `200` with an `Mcp-Session-Id` header. Keep secrets out of the image and the repository: they belong in the environment the platform starts the process with. > **Note** > > FrontMCP logs `JWT_SECRET is not set` when a server with `static` or `local` auth starts, and not for a public one. Only `local` and `remote` auth use it, and a production server with `local` auth and no `JWT_SECRET` doesn't start. In production the server also prints a few `[Security]` lines about CORS, the address it listens on and host checks: they describe the configuration and change nothing. ## Running it in a container `frontmcp create --target node` writes a `ci/Dockerfile` that builds in one stage and runs in another. This is it, with its comments left out; the last line runs the bundle the build writes, named after the `name` in your `frontmcp.config.ts`: ```text title="ci/Dockerfile" FROM node:24-slim AS builder WORKDIR /app COPY package*.json package-lock.json* ./ RUN npm ci COPY . . RUN npm run build RUN npm prune --omit=dev FROM node:24-slim AS runner WORKDIR /app ENV NODE_ENV=production ENV FRONTMCP_BIND_ADDRESS=all RUN chown node:node /app COPY --from=builder --chown=node:node /app/node_modules ./node_modules COPY --from=builder --chown=node:node /app/dist ./dist COPY --from=builder --chown=node:node /app/package.json ./ USER node EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + '/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))" CMD ["node", "dist/node/help-desk.bundle.js"] ``` ```bash docker build -f ci/Dockerfile -t help-desk . docker run -p 127.0.0.1:3000:3000 -e MCP_SESSION_SECRET="$(openssl rand -hex 32)" help-desk ``` The container logs `MCP HTTP (Express) on 0.0.0.0:3000`, and `docker inspect` reports it `healthy`. Three lines matter more than they look: - **`FRONTMCP_BIND_ADDRESS=all`.** FrontMCP listens on `127.0.0.1` unless told otherwise, and a published container port can't reach that address: `curl` gets `Empty reply from server`. - **`USER node`.** The server runs as the image's unprivileged user, not root. - **`HEALTHCHECK`.** It calls `/health` with Node's `fetch`, because the `node:24-slim` image has neither `curl` nor `wget`. `/health` answers like `/healthz`, and stays where it is if you move `/healthz` with `health.healthzPath`. (In 1.9.2 the check called `/healthz`.) You don't need `--init`: the server handles `SIGTERM` itself, finishes the requests in flight, and exits, so `docker stop` returns at once with exit code `0`. (Before 1.9.2 the generated Dockerfile had no health check and ran as root, and Node ignored `SIGTERM` as the container's first process, so `docker stop` waited ten seconds unless the container ran with `--init`.) ## Checking that it works A server that deploys isn't necessarily one clients can use. Check three things, in this order: 1. **`GET /healthz`** answers `200` while the process runs. It checks nothing else, so point liveness checks at it. 2. **`GET /readyz`** runs readiness probes, like pinging Redis once the server uses it, and answers `503` when one fails. Point a load balancer's readiness check at it. (Vercel and Lambda functions answer it with `404`, and so does a Worker until you turn it on.) 3. **A real MCP call**, because neither health check sees a missing secret or a wrong path. With the key, if the server has one: ```bash curl -s http://127.0.0.1:3000/mcp \ -H 'content-type: application/json' \ -H 'mcp-protocol-version: 2026-07-28' \ -H 'mcp-method: tools/call' \ -H 'mcp-name: get_ticket' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_ticket","arguments":{"id":"T-1"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}' ``` ```json {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"id\":\"T-1\",\"title\":\"Cannot log in\",\"status\":\"open\"}"}],"structuredContent":{"id":"T-1","title":"Cannot log in","status":"open"},"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"help-desk","version":"1.0.0"}}}} ``` Then send the older client's `initialize` from [the secrets section](https://frontmcp.dev/learn/deploying-your-server#the-secrets-it-needs), which is the request a missing `MCP_SESSION_SECRET` breaks. Health checks answer without credentials, even on a server that requires a key. That's what lets a load balancer check it, and it's also why a `200` from `/healthz` says nothing about whether your client can call the server: ```ts checks.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; function getTicket(headers: Record = {}) { return new Request("https://desk.example.com/mcp", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "get_ticket", ...headers }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "get_ticket", arguments: { id: "T-1" }, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); } test("/healthz answers without a key", async () => { const handler = await FrontMcpInstance.createFetchHandler(config); const response = await handler(new Request("https://desk.example.com/healthz")); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ status: "ok", server: { name: "help-desk", version: "1.0.0" } }); }); test("🚩 an MCP call without the key is refused", async () => { const handler = await FrontMcpInstance.createFetchHandler(config); const response = await handler(getTicket()); expect(response.status).toBe(401); expect(response.headers.get("www-authenticate")).toContain("Bearer"); }); test("✅ with the key, the call works", async () => { const handler = await FrontMcpInstance.createFetchHandler(config); const response = await handler(getTicket({ authorization: "Bearer desk-key-1" })); expect(response.status).toBe(200); expect((await response.json()).result.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" }); }); ``` ```ts config.ts import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], http: { entryPath: "/mcp" }, // In a real server: tokens: [process.env.DESK_API_KEY!] auth: { mode: "static" as const, tokens: ["desk-key-1"] }, }; ``` ```ts help-desk.app.ts import { App, 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}.`)); return ticket; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` A fetch handler answers `/healthz` with a fixed `{ status, server, transport }`; FrontMCP's Node server answers with `runtime` and `uptime` in place of `transport`, as above. [Health checks and metrics](https://frontmcp.dev/reference/deployment/health-and-metrics) covers `/readyz` probes, Kubernetes and Prometheus. ## Connecting a client A client connects to the server's public address plus its endpoint: `https://desk.example.com/mcp`. Most clients keep their servers in a JSON file with an `mcpServers` object: ```json { "mcpServers": { "help-desk": { "url": "https://desk.example.com/mcp" } } } ``` The field that marks an entry as a URL (`type`, `transport`, or none) differs between clients, so check your client's settings. For a server with a key, clients that support it send a header: ```json { "mcpServers": { "help-desk": { "url": "https://desk.example.com/mcp", "headers": { "Authorization": "Bearer ${DESK_API_KEY}" } } } } ``` A desktop client can also start the server itself, as a process on the user's machine, and talk to it over stdin and stdout. Point it at the start script the `node` build writes, with `--stdio`: ```json { "mcpServers": { "help-desk": { "command": "/opt/help-desk/dist/node/help-desk", "args": ["--stdio"] } } } ``` The script checks the Node version, and serves one client on stdio with no port open; the log goes to stderr. It needs the project's `node_modules` next to `dist/`, like any `node` build. `node dist/node/help-desk.bundle.js --stdio` does the same since 1.9. [Connecting MCP clients](https://frontmcp.dev/reference/deployment/mcp-clients) has the details. ### Behind a proxy FrontMCP speaks plain HTTP, so TLS ends in front of it: a load balancer, your platform's edge, or a proxy like nginx on the same machine. Before anything else, the server checks each request's `Host` header against the names it answers to. A server listening on `127.0.0.1` answers only its local names, like `localhost` and `127.0.0.1`, so a proxy that forwards the public host name is refused: ```bash PORT=3000 node dist/node/help-desk.bundle.js curl http://127.0.0.1:3000/healthz -H 'Host: desk.example.com' ``` ```json {"error":"Forbidden","message":"Invalid Host header"} ``` List the names clients use in `FRONTMCP_ALLOWED_HOSTS`. The list replaces the default one, so add the address your own health checks use, too. The image above checks `127.0.0.1:3000`, and with a list that leaves it out, Docker reports the container `unhealthy`: ```bash FRONTMCP_ALLOWED_HOSTS=desk.example.com,127.0.0.1:3000 PORT=3000 node dist/node/help-desk.bundle.js ``` In code, the same list is `http.security.dnsRebindingProtection.allowedHosts`, which wins over the variable. A server listening on all interfaces, like the container above, answers any host name until you set one of them, and its log says so: `DNS-rebinding protection is not enforcing a Host allow-list`. With `local` or `remote` auth behind a proxy, also set `FRONTMCP_PUBLIC_URL`, as [Auth in production](https://frontmcp.dev/reference/auth/production#https-and-the-public-url) explains. ## Recap - `frontmcp build` builds the targets in `frontmcp.config.ts`, or `--target`. The `node` bundle is only your code, and runs next to your production `node_modules`. - A server serves MCP at `http.entryPath`, else the `transport.http.path` of `frontmcp.config.ts` when the `frontmcp` command passes it on (`frontmcp dev` and every build), else `/`. Set `http: { entryPath: "/mcp" }` in `@FrontMcp`, so every way of starting it agrees. - With `NODE_ENV=production`, a plain `Error` reaches the model as an error ID. Fail with `PublicMcpError` for messages the model should read. - Production needs `MCP_SESSION_SECRET` for clients before MCP 2026-07-28 (their `initialize` gets a `500` that names it, and health checks don't notice), and `JWT_SECRET` for `local` and `remote` auth. Lambda doesn't set `NODE_ENV`: set it there. - The Dockerfile `frontmcp create` writes runs the bundle as an unprivileged user, with `FRONTMCP_BIND_ADDRESS=all` and a health check. Give the container `MCP_SESSION_SECRET`; `docker stop` ends it cleanly. - Check `/healthz`, `/readyz`, then a real call. Behind a proxy, list your host names in `FRONTMCP_ALLOWED_HOSTS`. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Serve the path the clients use The help desk also runs inside the company's API gateway, which builds it with `createFetchHandler(config)`, as the checks do. Its clients are set up with `https://desk.example.com/mcp`, and the gateway answers them with `404`. Change the configuration so the server serves MCP at `/mcp`. ```ts config.ts active import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], }; ``` **Solution file (alternative to the starter):** ```ts config.ts solution import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], http: { entryPath: "/mcp" }, }; ``` ```ts frontmcp.config.ts // Read by the frontmcp command (dev, build, test), never by the server. export default { name: "help-desk", entry: "./src/main.ts", deployments: [{ target: "node" }], transport: { default: "http", http: { port: 3000, path: "/mcp" } }, }; ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` **Hidden fixture/test file (runs with the example):** ```ts endpoint.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; function getTicket(url: string) { return new Request(url, { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "get_ticket" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "get_ticket", arguments: { id: "T-1" }, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); } test("a call to `/mcp` is answered", async () => { const handler = await FrontMcpInstance.createFetchHandler(config); const response = await handler(getTicket("https://desk.example.com/mcp")); expect(response.status).toBe(200); expect((await response.json()).result.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" }); }); test("the 404 for other paths names `/mcp`", async () => { const handler = await FrontMcpInstance.createFetchHandler(config); expect(await (await handler(getTicket("https://desk.example.com/api"))).json()).toEqual({ error: "Not Found", entryPaths: ["/mcp"] }); }); ``` **Hint:** `frontmcp.config.ts` already says `/mcp`. Who reads that file, and who reads `config.ts`? **Solution:** `transport.http.path` in `frontmcp.config.ts` is read by the `frontmcp` command, which passes it on to the server it starts and the builds it writes, but `createFetchHandler()` is given `config.ts` and nothing else. A server reads its own configuration, so the path goes in `http.entryPath`. Now `frontmcp dev`, every build, the gateway and these tests serve the same path. ### Challenge: Tell the model what went wrong, in production `get_customer` fails with a plain `Error` when a customer doesn't exist. In development the model reads the message; in production it only gets an error ID, and can't tell the user to check the id. Make the message reach the model in production, and keep the database error hidden. Initial call configuration: ```js { tool: "get_customer", arguments: { id: "C-9" } } ``` ```ts get-customer.tool.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { findCustomer } from "./db"; @Tool({ name: "get_customer", description: "Get a customer by id, like C-1.", inputSchema: { id: z.string() }, }) export class GetCustomer extends ToolContext { async execute({ id }: { id: string }) { const customer = await findCustomer(id); if (!customer) throw new Error(`There's no customer ${id}. Customer ids look like C-1.`); return customer; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetCustomer] }) export class HelpDesk {} ``` **Solution file (alternative to the starter):** ```ts get-customer.tool.ts solution import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { findCustomer } from "./db"; @Tool({ name: "get_customer", description: "Get a customer by id, like C-1.", inputSchema: { id: z.string() }, }) export class GetCustomer extends ToolContext { async execute({ id }: { id: string }) { const customer = await findCustomer(id); if (!customer) this.fail(new PublicMcpError(`There's no customer ${id}. Customer ids look like C-1.`)); return customer; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetCustomer] }) export class HelpDesk {} ``` ```ts db.ts const customers = [ { id: "C-1", name: "Acme Corp", plan: "business" }, { id: "C-2", name: "Globex", plan: "starter" }, ]; export async function findCustomer(id: string) { if (id === "C-500") throw new Error("connect ECONNREFUSED 10.0.4.12:5432"); // the database is down return customers.find((c) => c.id === id); } ``` **Hidden fixture/test file (runs with the example):** ```ts production.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { createErrorHandler, FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDesk } from "./get-customer.tool"; const production = createErrorHandler({ isDevelopment: false }); async function inProduction(id: string) { const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] }); try { return { ok: await server.callTool("get_customer", { id }) }; } catch (error) { return { error: production.handle(error) }; } finally { await server.dispose(); } } test("in production, an unknown customer's message reaches the model", async () => { const { error } = await inProduction("C-9"); expect(error?.content[0].text).toBe("There's no customer C-9. Customer ids look like C-1."); }); test("in production, the database error is still hidden", async () => { const { error } = await inProduction("C-500"); expect(error?.content[0].text).toMatch(/^Internal FrontMCP error\. Please contact support with error ID: err_/); }); test("a known customer is still returned", async ({ mcp }) => { const result = await mcp.tools.call("get_customer", { id: "C-1" }); expect(result.json()).toEqual({ id: "C-1", name: "Acme Corp", plan: "business" }); }); ``` **Hint:** In production, FrontMCP keeps the message of one kind of error. Which one does `get_ticket` use? **Solution:** `this.fail(new PublicMcpError(...))` marks the message as meant for the caller, so production sends it as it is. The database error in `db.ts` is still a plain `Error`, so it still becomes an error ID, and its message stays in the log. ### Challenge: Answer only your own host name The help desk runs behind a proxy that forwards requests for `desk.example.com`. Make the server answer requests for that host, health checks included, and refuse requests sent to any other name with `403`. ```ts config.ts active import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], http: { entryPath: "/mcp" }, }; ``` **Solution file (alternative to the starter):** ```ts config.ts solution import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], http: { entryPath: "/mcp", security: { dnsRebindingProtection: { allowedHosts: ["desk.example.com"] } }, }, }; ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` **Hidden fixture/test file (runs with the example):** ```ts hosts.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; function getTicket(url: string) { return new Request(url, { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "get_ticket" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "get_ticket", arguments: { id: "T-1" }, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); } test("a call for `desk.example.com` is answered", async () => { const handler = await FrontMcpInstance.createFetchHandler(config); expect((await handler(getTicket("https://desk.example.com/mcp"))).status).toBe(200); }); test("a call for another host name gets `403`", async () => { const handler = await FrontMcpInstance.createFetchHandler(config); const response = await handler(getTicket("https://desk.attacker.example/mcp")); expect(response.status).toBe(403); expect(await response.json()).toEqual({ error: "Forbidden", message: "Invalid Host header" }); }); test("`/healthz` for `desk.example.com` still answers", async () => { const handler = await FrontMcpInstance.createFetchHandler(config); expect((await handler(new Request("https://desk.example.com/healthz"))).status).toBe(200); }); ``` **Hint:** On a Node server you'd set `FRONTMCP_ALLOWED_HOSTS`. The same list has a place in `http`, under `security`. **Solution:** `allowedHosts` lists the host names the server answers, and anything else gets `403` before any tool runs, health checks included. The list is enforced on its own, under `createFetchHandler()` and on FrontMCP's Node server alike; `enabled: false` would turn it off. A real server lists every name its clients and health checks use, like `127.0.0.1:3000` for a container's `HEALTHCHECK`. --- # Running Several Instances > Run multiple FrontMCP instances behind a load balancer. Share required secrets and state, accounting for each client's MCP revision. Source: https://frontmcp.dev/learn/running-several-instances [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 One server is one process. When it restarts, it's gone for a moment, and when it's busy, every client waits. Running several copies of it, **instances**, behind a load balancer fixes both, and the load balancer sends each request to whichever instance it likes. That's only safe for what every instance can answer the same way. This lesson shows what a FrontMCP server's instances must share, why clients on MCP 2026-07-28 need less of it than older clients, and how Redis carries the rest. **You will learn** - Why an instance's memory is its own, and what that breaks - What clients on MCP 2026-07-28 need from several instances, and what older clients need - Which secrets every instance must share - How to share sessions and rate limits through Redis, with a load balancer in front - The traps: `REDIS_URL` alone, and rate limits that need Redis of their own ## Each instance has its own memory Support agents add notes to tickets. `add_note` keeps them in a provider, which lives in the server's memory. That's fine for one instance. The tests below start two instances from the same configuration, as two processes behind a load balancer would be, and send calls to each. Open the **Tests** tab: Initial call configuration: ```js { tool: "add_note", arguments: { id: "T-1", note: "Asked for a screenshot" } } ``` ```ts instances.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; // One tools/call, as a 2026-07-28 client sends it function call(name: string, args: Record) { return new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); } async function send(instance: (request: Request) => Promise, name: string, args: Record) { return (await (await instance(call(name, args))).json()).result.structuredContent; } test("either instance answers a call that carries what it needs", async () => { const a = await FrontMcpInstance.createFetchHandler(config); const b = await FrontMcpInstance.createFetchHandler(config); expect(await send(a, "get_ticket", { id: "T-1" })).toEqual({ id: "T-1", title: "Cannot log in", status: "open" }); expect(await send(b, "get_ticket", { id: "T-1" })).toEqual({ id: "T-1", title: "Cannot log in", status: "open" }); }); test("🚩 a note added on one instance is missing on the other", async () => { const a = await FrontMcpInstance.createFetchHandler(config); const b = await FrontMcpInstance.createFetchHandler(config); await send(a, "add_note", { id: "T-1", note: "Asked for a screenshot" }); expect(await send(a, "get_notes", { id: "T-1" })).toEqual({ id: "T-1", notes: ["Asked for a screenshot"] }); expect(await send(b, "get_notes", { id: "T-1" })).toEqual({ id: "T-1", notes: [] }); }); ``` ```ts help-desk.app.ts import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { NoteStore } from "./note-store"; 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.", inputSchema: { id: z.string() } }) 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}.`)); return ticket; } } @Tool({ name: "add_note", description: "Add an internal note to a ticket.", inputSchema: { id: z.string(), note: z.string() } }) export class AddNote extends ToolContext { async execute({ id, note }: { id: string; note: string }) { const notes = this.get(NoteStore); notes.add(id, note); return { id, notes: notes.list(id) }; } } @Tool({ name: "get_notes", description: "Get a ticket's internal notes.", inputSchema: { id: z.string() } }) export class GetNotes extends ToolContext { async execute({ id }: { id: string }) { return { id, notes: this.get(NoteStore).list(id) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, AddNote, GetNotes], providers: [NoteStore] }) export class HelpDesk {} ``` ```ts note-store.ts import { Provider } from "@frontmcp/sdk"; // 🚩 One Map per server: each instance has its own @Provider({ name: "NoteStore" }) export class NoteStore { private notes = new Map(); add(id: string, note: string) { this.notes.set(id, [...this.list(id), note]); } list(id: string) { return this.notes.get(id) ?? []; } } ``` ```ts config.ts import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], }; ``` Each `createFetchHandler()` builds a server of its own, with its own providers, the way each process does. `get_ticket` works on both, because everything it needs is in the code and the request. The note only exists on the instance that took it, so an agent whose next request lands on the other instance sees no notes at all. The fix is to keep the notes somewhere every instance reaches, and give each instance a store that talks to it. A provider can be handed to the server from outside, with `useValue`: Initial call configuration: ```js { tool: "get_notes", arguments: { id: "T-1" } } ``` ```ts note-store.ts active import { Provider } from "@frontmcp/sdk"; // ✅ The notes live wherever `db` is, and every instance is given the same one @Provider({ name: "NoteStore" }) export class NoteStore { constructor(private readonly db: Map) {} add(id: string, note: string) { this.db.set(id, [...this.list(id), note]); } list(id: string) { return this.db.get(id) ?? []; } } ``` ```ts config.ts import { HelpDesk } from "./help-desk.app"; import { NoteStore } from "./note-store"; export function createServer(notes: NoteStore) { return { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], providers: [{ provide: NoteStore, name: "NoteStore", useValue: notes }], }; } ``` ```ts instances.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { createServer } from "./config"; import { NoteStore } from "./note-store"; function call(name: string, args: Record) { return new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); } async function send(instance: (request: Request) => Promise, name: string, args: Record) { return (await (await instance(call(name, args))).json()).result.structuredContent; } test("✅ a note added on one instance is there on the other", async () => { const db = new Map(); // stands in for the database both instances connect to const a = await FrontMcpInstance.createFetchHandler(createServer(new NoteStore(db))); const b = await FrontMcpInstance.createFetchHandler(createServer(new NoteStore(db))); await send(a, "add_note", { id: "T-1", note: "Asked for a screenshot" }); expect(await send(b, "get_notes", { id: "T-1" })).toEqual({ id: "T-1", notes: ["Asked for a screenshot"] }); }); ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { NoteStore } from "./note-store"; @Tool({ name: "add_note", description: "Add an internal note to a ticket.", inputSchema: { id: z.string(), note: z.string() } }) export class AddNote extends ToolContext { async execute({ id, note }: { id: string; note: string }) { const notes = this.get(NoteStore); notes.add(id, note); return { id, notes: notes.list(id) }; } } @Tool({ name: "get_notes", description: "Get a ticket's internal notes.", inputSchema: { id: z.string() } }) export class GetNotes extends ToolContext { async execute({ id }: { id: string }) { return { id, notes: this.get(NoteStore).list(id) }; } } // No NoteStore in the app: the server provides it @App({ id: "help-desk", name: "Help Desk", tools: [AddNote, GetNotes] }) export class HelpDesk {} ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { createServer } from "./config"; import { NoteStore } from "./note-store"; // The server the Call tab talks to @FrontMcp(createServer(new NoteStore(new Map()))) export default class Server {} ``` Both instances run in this page, so handing them one `Map` is the Playground's stand-in for a database they both connect to. Two real processes share nothing, not even a module variable: in production, `NoteStore` reads and writes your database, or Redis, and each instance is started with one connected to the same place. The tools don't change, which is the point of [putting state in a provider](https://frontmcp.dev/learn/sharing-state-with-providers#swapping-a-provider-in-tests). ## What clients need from several instances The same question, "does another instance know about this?", decides what FrontMCP's own state needs, and the answer depends on the client. A client on **MCP 2026-07-28** keeps no session. Every request carries what the server needs to answer it, the way `get_ticket` did above. A client on an **older protocol version** starts with `initialize`, gets an `Mcp-Session-Id`, and sends it with every request after that. The session lives in the memory of the instance that created it. To see the difference, the help desk gets two more tools next to `get_ticket`: one that [asks the user](https://frontmcp.dev/learn/asking-the-user) two questions, and one with a [rate limit](https://frontmcp.dev/learn/limiting-calls): ```ts src/help-desk.app.ts @Tool({ name: "assign_ticket", description: "Assign a ticket to a support agent. Asks the user who, then asks to confirm.", inputSchema: { id: z.string() }, }) export class AssignTicket extends ToolContext { async execute({ id }: { id: string }) { const who = await this.elicit(`Who should take ${id}?`, z.object({ agent: z.enum(["Nour", "Sam", "Priya"]) })); const agent = who.status === "accept" ? who.content?.agent : undefined; if (!agent) return { id, assigned: null }; const sure = await this.elicit(`Assign ${id} to ${agent}?`, z.object({ confirm: z.boolean() })); return { id, assigned: sure.status === "accept" && sure.content?.confirm === true ? agent : null }; } } @Tool({ name: "export_tickets", description: "Export every support ticket as CSV. At most 2 exports an hour.", inputSchema: {}, rateLimit: { maxRequests: 2, windowMs: 60 * 60_000 }, }) export class ExportTickets extends ToolContext { async execute() { return { csv: ["id,title,status", ...tickets.map((t) => `${t.id},${t.title},${t.status}`)].join("\n") }; } } ``` Built with the `main.ts` from [the next section](https://frontmcp.dev/learn/running-several-instances#sharing-sessions-and-limits-through-redis), and started as two instances on one machine with no shared state and nothing set but the port: ```bash PORT=4001 node dist/node/help-desk.bundle.js & PORT=4002 node dist/node/help-desk.bundle.js & ``` What a client sees when its requests reach both: | The client | Sends | Gets | | --- | --- | --- | | On MCP 2026-07-28 | `get_ticket` to 4001, then to 4002 | The ticket, from both. | | On MCP 2025-06-18 | `initialize` to 4001, then `get_ticket` with its session id to 4002 | `404` `{"code":-32000,"message":"invalid session id"}` from 4002, which can't read an id that 4001 encrypted with its own secret. | | On MCP 2026-07-28 | `assign_ticket` to 4001, the answer to its first question to 4002, the answer to its second to 4001 | The first question again, `Who should take T-1?`, and 4001 logs `rejected requestState` with `reason: 'bad-signature'` and a hint that names `VAULT_SECRET`. | | Either | `export_tickets` five times, alternating | Four exports: each instance counts its own two. | The second row shows what a session id is: it's encrypted, and an instance serves only an id it can decrypt. Give both instances the same `MCP_SESSION_SECRET` and 4002 reads the id, but it still has no such session, so it answers `404` `session not initialized`. Keeping the session where 4002 can find it is Redis's job, [below](https://frontmcp.dev/learn/running-several-instances#sharing-sessions-and-limits-through-redis). The third row is the surprising one. Between questions, a 2026-07-28 client carries the earlier answers in a `requestState` the server signed, and each instance made up its own signing key. Nothing is stored, so Redis doesn't help; a shared secret does ([`elicit()`](https://frontmcp.dev/reference/sdk/elicit) says which one signs it). With the same `VAULT_SECRET` on both instances, the same client gets `{"id":"T-1","assigned":"Priya"}`. Without it, the rejected state is logged with the fix: ```text WARN mcp-20260728: rejected requestState { method: 'tools/call', reason: 'bad-signature', hint: 'requestState is signed with a per-process key; set VAULT_SECRET (or JWT_SECRET) to the same value on every instance so a round can land on any of them' } ``` So a 2026-07-28 client needs every instance to run the same build with the same secrets. An older client also needs its session to be found wherever its request lands. ### The secrets every instance shares | Variable | Every instance needs the same one when | Otherwise | | --- | --- | --- | | `MCP_SESSION_SECRET` | Clients before MCP 2026-07-28 connect. Production requires it anyway ([previous lesson](https://frontmcp.dev/learn/deploying-your-server#the-secrets-it-needs)). | It encrypts their session ids. An instance answers `404` `invalid session id` to an id made under another secret ([High availability](https://frontmcp.dev/reference/deployment/high-availability#caveats)). | | `VAULT_SECRET`, or `JWT_SECRET` | A tool asks the user more than one question. | The first question is asked again, as above. | | `JWT_SECRET` | The server uses `local` or `remote` auth. | Tokens issued by one instance fail on another with `signature verification failed`. | Generate each once, keep it in your platform's secret store, and give every instance the same value. [Auth in production](https://frontmcp.dev/reference/auth/production#running-several-instances) lists the rest of what authenticated servers share. A production server that shares state through `redis`, or through a `transport.persistence` object, and has neither `VAULT_SECRET` nor `JWT_SECRET`, warns once as it starts: ```text WARN requestState is signed with a per-process key (neither VAULT_SECRET nor JWT_SECRET is set). Multi-round tools (elicit/sample) restart when a round lands on another instance; set VAULT_SECRET (or JWT_SECRET) to the same value on every instance. ``` A server without either option gets no warning at startup, and the `hint` above is the only sign, when a round lands on the wrong instance. A blank value counts as unset. > **Note: Changed in 1.8.6: a session id made under another secret is refused** > > In 1.8.5, a public server served a session id that another instance had made under a different `MCP_SESSION_SECRET`. Now the id has to decrypt under this instance's secret: any other id gets `404` `invalid session id`, and the log says `mcp-session-id is not a session this server verified`. So when you rotate `MCP_SESSION_SECRET`, each client that still holds an old id gets that `404` once, and starts a new session with `initialize`. Clients on MCP 2026-07-28 have no session id and don't notice. ## Sharing sessions and limits through Redis `@FrontMcp({ redis })` moves the state FrontMCP keeps between requests into Redis: older clients' sessions, background tasks, and questions waiting for an answer. Rate-limit counts have an option of their own, `throttle.storage`. Read the address from the environment, so the same build runs with or without Redis: ```ts src/main.ts import "reflect-metadata"; import { FrontMcp } from "@frontmcp/sdk"; import { HelpDesk } from "./help-desk.app"; const url = process.env.REDIS_URL; // like redis://redis:6379, or rediss://default:…@cache.example.com:6380 @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], http: { entryPath: "/mcp" }, elicitation: { enabled: true }, ...(url ? { redis: { url }, throttle: { enabled: true, storage: { type: "redis", redis: { url } } } } : {}), }) export default class Server {} ``` Both options take the URL: `redis` as `{ url }`, and `throttle.storage` under its `redis` key. (`redis` also takes `host`, `port`, `password`, `db` and `tls` as fields.) Docker Compose can run the whole setup on one machine: Redis, two instances of the image from [the last lesson](https://frontmcp.dev/learn/deploying-your-server#running-it-in-a-container), and nginx in front of them, sending requests to each in turn: ```yaml ci/compose.yml services: redis: image: redis:7-alpine healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 3s timeout: 5s retries: 3 desk: build: context: .. dockerfile: ci/Dockerfile init: true deploy: replicas: 2 environment: MCP_SESSION_SECRET: ${MCP_SESSION_SECRET:?set MCP_SESSION_SECRET} VAULT_SECRET: ${VAULT_SECRET:?set VAULT_SECRET} REDIS_URL: redis://redis:6379 depends_on: redis: condition: service_healthy lb: image: nginx:1.27-alpine ports: - "127.0.0.1:3000:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - desk ``` ```text title="ci/nginx.conf" upstream desk { server desk:3000; } server { listen 80; location / { proxy_pass http://desk; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Connection ""; proxy_buffering off; proxy_read_timeout 1h; } } ``` ```bash export MCP_SESSION_SECRET="$(openssl rand -hex 32)" VAULT_SECRET="$(openssl rand -hex 32)" docker compose -f ci/compose.yml up -d --build ``` Each instance logs that it found Redis: ```text [TransportService] redis session store will be initialized for transport persistence [TransportService] Session store connection validated successfully ``` Now every client in the table above is served through `http://127.0.0.1:3000/mcp`. The older client's `initialize` and its next four calls all succeed, though nginx sent some of them to the other instance, which logs `Recreating transport from stored session` as it picks the session up from Redis. `assign_ticket` finishes with `{"id":"T-1","assigned":"Priya"}`, and the third export in the hour is refused, whichever instance gets it. Redis holds a key for each: ```bash docker compose -f ci/compose.yml exec redis redis-cli --scan ``` ```text mcp:session: mcp:guard:export_tickets:global:rl: ``` `/readyz` now pings Redis too, so a load balancer that checks it stops sending traffic to an instance that can't reach it. [Redis](https://frontmcp.dev/reference/deployment/redis) has every option, `tls` for managed Redis among them, and [High availability](https://frontmcp.dev/reference/deployment/high-availability) the rest of what instances share. > **Pitfall: REDIS_URL or REDIS_HOST alone doesn't share sessions** > > The `ci/docker-compose.yml` that `frontmcp create` writes sets `REDIS_HOST=redis` for the app, and the `main.ts` it writes doesn't read it. FrontMCP picks up `REDIS_HOST`, or `REDIS_URL`, for background tasks and pending questions, not for sessions or rate limits (`Created task store { type: 'redis', … }`), so the log looks as if Redis is in use. Behind the load balancer above, with that `main.ts`, the older client's calls fail whenever they reach the other instance, with `404` `session not initialized`, and four exports get through where two should. Read the variable into `redis` and `throttle.storage`, as the `main.ts` above does. ### What else lives in one instance's memory | State | Shared through | | --- | --- | | Sessions of clients before MCP 2026-07-28 | `redis` | | Background tasks, and questions waiting for an answer | `redis` | | Rate-limit and concurrency counts | `throttle.storage` ([Guard options](https://frontmcp.dev/reference/sdk/guard#storage-and-keyprefix)) | | Plugin stores, like the [Cache plugin's](https://frontmcp.dev/reference/plugins/cache#stores) | The plugin's `type: "global-store"`, which uses `redis` | | Job runs | `jobs.store` ([Where runs are kept](https://frontmcp.dev/learn/running-jobs-in-the-background#where-runs-are-kept)) | | Sign-ins and refresh tokens of `local` and `remote` auth | `auth.tokenStorage` ([Local auth](https://frontmcp.dev/reference/auth/local#storage)) | | Your own state, like the notes | Your database, or Redis, given to every instance | FrontMCP checks `redis` as the server is built, and refuses some of these at startup when they're wrong, which a test can catch before you deploy. The Playground can't reach Redis, but it can build the server: ```ts config.test.ts active import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance, redisOptionsSchema } from "@frontmcp/sdk"; import { CachePlugin } from "@frontmcp/plugin-cache"; import { GetTicket, HelpDesk } from "./help-desk.app"; const info = { name: "help-desk", version: "1.0.0" }; test("`redis` takes the URL a managed Redis hands you", () => { expect(redisOptionsSchema.parse({ url: "rediss://default:s3cr3t@cache.example.com:6380/2" })).toMatchObject({ host: "cache.example.com", port: 6380, password: "s3cr3t", db: 2, tls: true, }); }); test("a `redis` URL that names another user is refused", async () => { const server = FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk], redis: { url: "rediss://support:s3cr3t@cache.example.com:6380" } }); await expect(server).rejects.toThrow("only the default user is supported"); }); test("🚩 a plugin's global store needs `redis` on the server", async () => { @App({ id: "cached", name: "Cached", tools: [GetTicket], plugins: [CachePlugin.init({ type: "global-store" })] }) class Cached {} await expect(FrontMcpInstance.createFetchHandler({ info, apps: [Cached] })).rejects.toThrow( 'Plugin "CachePlugin" requires global "redis" configuration.', ); }); ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` `redis` takes a `redis://` or `rediss://` URL, the form managed Redis services hand you, and splits it into `host`, `port`, `password`, `db` and `tls`. A URL that names a user other than `default` is refused: the error says `redis.url names the ACL user "support"; only the default user is supported`, and a deployed server exits with it at startup. The last test is about plugins: one whose store is `"global-store"` uses the server's `redis`, and a server without it doesn't start. > **Note: Changed in 1.9: `redis` takes a URL** > > In 1.8.7, `redis: { url }` failed with a `ZodError` saying `host` was missing, and a deployed server exited with it, so a managed Redis's URL had to be split into fields by hand. > **Note: When Redis can't be reached** > > An instance whose `redis` can't be reached when it starts still starts, keeps sessions in its memory, logs `Failed to connect to redis - session persistence disabled`, and answers `/readyz` with `503`. FrontMCP keeps trying, with a growing delay, and when Redis answers it logs `Session store recovered after startup failure`: the instance uses Redis from then on, and `/readyz` is `200` again, with no restart. A Redis that goes away while the instance runs does the same: sessions stay in memory, `/readyz` answers `503`, and the log has one `[RedisStorage] Redis connection error` line every 30 seconds or so. > > Rate limits are stricter, because they're a security control: in production, an instance whose `throttle.storage` can't reach its Redis doesn't start. It exits with: > > ```text > GuardStorageUnavailableError: throttle.storage (redis) is unavailable: Failed to connect to Redis: connect ECONNREFUSED 127.0.0.1:6379. Rate limits fail closed, so the server will not start without it. Set throttle.storage.fallback: 'memory' to start with per-instance counters instead. > ``` > > If Redis goes away while the instance runs, a call to a tool with a limit fails with the tool error `GUARD_STORAGE_UNAVAILABLE`, and works again when Redis does; tools without a limit aren't affected. To start anyway, and to keep serving meanwhile, with each instance counting its own limits, add `fallback: "memory"` next to `type`: two instances then let through four exports an hour, not two, until Redis answers, and share one count again after that. Point the load balancer's readiness check at `/readyz` either way, so an instance without Redis gets no traffic. [When Redis can't be reached](https://frontmcp.dev/reference/deployment/redis#when-redis-cant-be-reached) has the details, and [the guard reference](https://frontmcp.dev/reference/sdk/guard#the-server-doesnt-start-throttlestorage-redis-is-unavailable) shows the errors in Playgrounds. ### What distributed mode adds The `node` build above is all several instances need: any of them loads an older client's session from Redis. `frontmcp build --target distributed` (or `FRONTMCP_DEPLOYMENT_MODE=distributed`) does it the other way round: an instance keeps serving the sessions it made, and another instance that gets one of their requests relays it to that instance over Redis and streams the answer back. Each instance writes a heartbeat to Redis. One stopped with `SIGTERM` deletes its heartbeat as it shuts down, so whichever instance gets its sessions' next requests takes them over at once; one that crashed has them taken over once its heartbeat expires, 30 seconds after it stopped, and until then those requests answer `503` with `Retry-After: 30`. Every answer names the instance that served it in an `X-FrontMCP-Machine-Id` header, for load balancers that route by it. [High availability](https://frontmcp.dev/reference/deployment/high-availability#distributed-mode) has the details. Changed in 1.9: in 1.8.7 a request that carried a session another instance had made answered `500` (`RemoteTransporter.handleRequest() is not implemented`), so in distributed mode an older client had to reach the instance that made its session. Vercel and Lambda functions are several instances by themselves: each can run many copies at once, and none keeps anything between invocations. They need the same things: shared secrets, and for older clients' sessions a store every copy reaches, like a Redis in the function's network or, on Vercel, [Vercel KV](https://frontmcp.dev/reference/deployment/vercel#storage), which can't serve background tasks and questions waiting for an answer: FrontMCP skips tasks with a warning, and refuses `elicitation` unless it has a Redis of its own. ### What about sticky sessions? A load balancer can pin each client to one instance, usually with a cookie, so an older client's session is always where its requests go. That avoids Redis for sessions, but an instance that stops or restarts takes its sessions with it, and the rest of the state in the table above still isn't shared. Redis doesn't make pinning useless either: a client that holds an event stream open, like one on the older SSE transport, holds it with one instance, and only that instance can write to it. Give such clients affinity at the load balancer. ## Recap - Each instance has its own memory: its providers, and FrontMCP's own state. A request that lands on another instance doesn't see it. - Clients on MCP 2026-07-28 carry everything in each request, so they need every instance to run the same build with the same secrets. Older clients also need their session found, through `redis` or a load balancer that pins them. - Give every instance the same `MCP_SESSION_SECRET`, the same `VAULT_SECRET` for tools that ask more than one question, and the same `JWT_SECRET` for `local` and `remote` auth. - `redis` shares sessions, tasks and pending questions, `throttle.storage` shares rate limits, and your own state belongs in a database or Redis that every instance is given. Both options take a `redis://` URL. - `REDIS_URL` or `REDIS_HOST` alone moves tasks and pending questions, but not sessions or rate limits: read it into `redis` and `throttle.storage`. - A Redis that's down at startup or goes away later is retried: the instance keeps working in memory, and rate-limited tools fail with `GUARD_STORAGE_UNAVAILABLE` unless `throttle.storage` has `fallback: "memory"`. ## Try some challenges Each challenge runs hidden checks against your code. Edit the code, then press **Check**. ### Challenge: Give every instance the same on-call list `set_on_call` records which agent is on call, and `who_is_on_call` tells the model. The checks start two instances with `createServer(db)`, handing both the same `db`, and `createServer` ignores it, so each instance keeps its own list. Make every instance keep the on-call agent in the `db` it's given. Initial call configuration: ```js { tool: "who_is_on_call", arguments: {} } ``` ```ts config.ts active import { HelpDesk } from "./on-call.tools"; export function createServer(db: Map) { return { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], }; } ``` **Solution file (alternative to the starter):** ```ts config.ts solution import { HelpDesk } from "./on-call.tools"; import { OnCallStore } from "./on-call-store"; export function createServer(db: Map) { return { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], providers: [{ provide: OnCallStore, name: "OnCallStore", useValue: new OnCallStore(db) }], }; } ``` ```ts on-call-store.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "OnCallStore" }) export class OnCallStore { private db = new Map(); set(agent: string) { this.db.set("on-call", agent); } get() { return this.db.get("on-call") ?? "nobody"; } } ``` **Solution file (alternative to the starter):** ```ts on-call-store.ts solution import { Provider } from "@frontmcp/sdk"; @Provider({ name: "OnCallStore" }) export class OnCallStore { constructor(private readonly db: Map) {} set(agent: string) { this.db.set("on-call", agent); } get() { return this.db.get("on-call") ?? "nobody"; } } ``` ```ts on-call.tools.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { OnCallStore } from "./on-call-store"; @Tool({ name: "set_on_call", description: "Record which support agent is on call.", inputSchema: { agent: z.enum(["Nour", "Sam", "Priya"]) }, }) export class SetOnCall extends ToolContext { async execute({ agent }: { agent: string }) { this.get(OnCallStore).set(agent); return { onCall: agent }; } } @Tool({ name: "who_is_on_call", description: "Which support agent is on call right now.", inputSchema: {} }) export class WhoIsOnCall extends ToolContext { async execute() { return { onCall: this.get(OnCallStore).get() }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SetOnCall, WhoIsOnCall], providers: [OnCallStore] }) export class HelpDesk {} ``` **Solution file (alternative to the starter):** ```ts on-call.tools.ts solution import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { OnCallStore } from "./on-call-store"; @Tool({ name: "set_on_call", description: "Record which support agent is on call.", inputSchema: { agent: z.enum(["Nour", "Sam", "Priya"]) }, }) export class SetOnCall extends ToolContext { async execute({ agent }: { agent: string }) { this.get(OnCallStore).set(agent); return { onCall: agent }; } } @Tool({ name: "who_is_on_call", description: "Which support agent is on call right now.", inputSchema: {} }) export class WhoIsOnCall extends ToolContext { async execute() { return { onCall: this.get(OnCallStore).get() }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SetOnCall, WhoIsOnCall] }) export class HelpDesk {} ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { createServer } from "./config"; // The server the Call tab talks to @FrontMcp(createServer(new Map())) export default class Server {} ``` **Hidden fixture/test file (runs with the example):** ```ts on-call.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { createServer } from "./config"; function call(name: string, args: Record) { return new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); } async function send(instance: (request: Request) => Promise, name: string, args: Record = {}) { return (await (await instance(call(name, args))).json()).result.structuredContent; } test("an agent set on one instance is on call on the other", async () => { const db = new Map(); const a = await FrontMcpInstance.createFetchHandler(createServer(db)); const b = await FrontMcpInstance.createFetchHandler(createServer(db)); await send(a, "set_on_call", { agent: "Priya" }); expect(await send(b, "who_is_on_call")).toEqual({ onCall: "Priya" }); }); test("a change on the second instance reaches the first", async () => { const db = new Map(); const a = await FrontMcpInstance.createFetchHandler(createServer(db)); const b = await FrontMcpInstance.createFetchHandler(createServer(db)); await send(a, "set_on_call", { agent: "Nour" }); await send(b, "set_on_call", { agent: "Sam" }); expect(await send(a, "who_is_on_call")).toEqual({ onCall: "Sam" }); }); test("the agent is kept in the `db` the server is given", async () => { const db = new Map(); const a = await FrontMcpInstance.createFetchHandler(createServer(db)); await send(a, "set_on_call", { agent: "Nour" }); expect([...db.values()]).toEqual(["Nour"]); }); ``` **Hint:** The second example in [Each instance has its own memory](https://frontmcp.dev/learn/running-several-instances#each-instance-has-its-own-memory) does the same for notes. Three files change, and one of the changes is a removal. **Solution:** `OnCallStore` now keeps the agent in the `db` it's constructed with, and `createServer` hands every server a store built on the `db` it was given, with `useValue`. The app's own `providers: [OnCallStore]` had to go: an app's provider comes before the server's, so each instance kept building a store of its own. On a real server, `db` would be a connection to the same database or Redis in every process. ### Challenge: Page through tickets on any instance `list_tickets` returns open tickets two at a time, and remembers in the server's memory where the last page ended. Behind a load balancer, the next page comes from whichever instance answers. Make each answer include a `nextCursor` (a string, or `null` on the last page) and make the tool take an optional `cursor`, so that the next page works on any instance. Initial call configuration: ```js { tool: "list_tickets", arguments: {} } ``` ```ts list-tickets.tool.ts active import { App, Provider, Tool, ToolContext } from "@frontmcp/sdk"; import { tickets } from "./tickets"; // 🚩 Where the last page ended, in this instance's memory @Provider({ name: "Paging" }) export class Paging { next = 0; } @Tool({ name: "list_tickets", description: "List open tickets, two at a time. Call it again for the next two.", inputSchema: {}, }) export class ListTickets extends ToolContext { async execute() { const paging = this.get(Paging); const page = tickets.slice(paging.next, paging.next + 2); paging.next += 2; return { tickets: page }; } } @App({ id: "help-desk", name: "Help Desk", tools: [ListTickets], providers: [Paging] }) export class HelpDesk {} ``` **Solution file (alternative to the starter):** ```ts list-tickets.tool.ts solution import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @Tool({ name: "list_tickets", description: "List open tickets, two at a time. For the next two, call it again with the nextCursor of the last answer.", inputSchema: { cursor: z.string().optional().describe("The nextCursor of the previous answer") }, }) export class ListTickets extends ToolContext { async execute({ cursor }: { cursor?: string }) { const start = cursor ? Number(cursor) : 0; const next = start + 2; return { tickets: tickets.slice(start, next), nextCursor: next < tickets.length ? String(next) : null }; } } @App({ id: "help-desk", name: "Help Desk", tools: [ListTickets] }) export class HelpDesk {} ``` ```ts tickets.ts export const tickets = [ { id: "T-1", title: "Cannot log in" }, { id: "T-3", title: "Login link expired" }, { id: "T-4", title: "Export is empty" }, { id: "T-6", title: "Wrong time zone in emails" }, { id: "T-7", title: "Invoice PDF won't open" }, ]; ``` ```ts config.ts import { HelpDesk } from "./list-tickets.tool"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] }; ``` **Hidden fixture/test file (runs with the example):** ```ts paging.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; function list(args: Record) { return new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "list_tickets" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "list_tickets", arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); } async function page(instance: (request: Request) => Promise, args: Record) { return (await (await instance(list(args))).json()).result.structuredContent; } const ids = (answer: { tickets: { id: string }[] }) => answer.tickets.map((t) => t.id); test("the first page has two tickets and a `nextCursor`", async () => { const a = await FrontMcpInstance.createFetchHandler(config); const first = await page(a, {}); expect(ids(first)).toEqual(["T-1", "T-3"]); expect(typeof first.nextCursor).toBe("string"); }); test("the `nextCursor` from one instance gets the next page from another", async () => { const a = await FrontMcpInstance.createFetchHandler(config); const b = await FrontMcpInstance.createFetchHandler(config); const second = await page(b, { cursor: (await page(a, {})).nextCursor }); expect(ids(second)).toEqual(["T-4", "T-6"]); }); test("the last page has `nextCursor: null`", async () => { const a = await FrontMcpInstance.createFetchHandler(config); const b = await FrontMcpInstance.createFetchHandler(config); const second = await page(b, { cursor: (await page(a, {})).nextCursor }); const last = await page(a, { cursor: second.nextCursor }); expect(ids(last)).toEqual(["T-7"]); expect(last.nextCursor).toBeNull(); }); test("a call without a cursor always starts at the first page", async ({ mcp }) => { await mcp.tools.call("list_tickets", {}); expect(ids((await mcp.tools.call("list_tickets", {})).json())).toEqual(["T-1", "T-3"]); }); ``` **Hint:** The instance can't remember where the page ended, so the model has to. What could each answer give it to send back? **Solution:** The position moves out of the server and into the conversation: each answer says where the next page starts, and the model sends that back. No instance has to remember anything, so the next page works wherever the request lands, and the `Paging` provider can go. The description says how to get the next page, so the model knows to pass the cursor. MCP's own lists, like `tools/list`, page the same way. ### Challenge: Count the exports in Redis too The help desk reads `REDIS_URL` into `redis`, so older clients' sessions are shared, but each instance still counts `export_tickets` on its own: behind two instances, four exports an hour get through, not two. Change `createConfig()` so that, with `REDIS_URL` set, rate limits are counted in that Redis too. Without `REDIS_URL`, the server must still start without Redis. Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts config.ts active import { HelpDesk } from "./help-desk.app"; export function createConfig(env: Record) { const url = env.REDIS_URL; return { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], ...(url ? { redis: { url } } : {}), }; } ``` **Solution file (alternative to the starter):** ```ts config.ts solution import { HelpDesk } from "./help-desk.app"; export function createConfig(env: Record) { const url = env.REDIS_URL; return { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], ...(url ? { redis: { url }, throttle: { enabled: true, storage: { type: "redis" as const, redis: { url } } } } : {}), }; } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "export_tickets", description: "Export every support ticket as CSV. At most 2 exports an hour.", inputSchema: {}, rateLimit: { maxRequests: 2, windowMs: 60 * 60_000 }, }) export class ExportTickets extends ToolContext { async execute() { return { csv: "id,title,status\nT-1,Cannot log in,open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets] }) export class HelpDesk {} ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { createConfig } from "./config"; // The server the Call tab talks to: no REDIS_URL here @FrontMcp(createConfig({})) export default class Server {} ``` **Hidden fixture/test file (runs with the example):** ```ts config.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance, redisOptionsSchema } from "@frontmcp/sdk"; import { createConfig } from "./config"; const url = "rediss://default:s3cr3t@cache.example.com:6380"; function exportTickets() { return new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "export_tickets" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "export_tickets", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); } test("with `REDIS_URL`, sessions still go to that Redis", () => { const config = createConfig({ REDIS_URL: url }) as { redis?: unknown }; expect(redisOptionsSchema.parse(config.redis)).toMatchObject({ host: "cache.example.com", port: 6380, tls: true }); }); test("with `REDIS_URL`, rate limits are counted in that Redis", () => { const config = createConfig({ REDIS_URL: url }) as { throttle?: { enabled?: boolean; storage?: unknown } }; expect(config.throttle?.enabled).not.toBe(false); expect(config.throttle?.storage).toEqual({ type: "redis", redis: { url } }); }); test("without `REDIS_URL`, the server starts with no Redis and still limits exports", async () => { const config = createConfig({}) as { redis?: unknown; throttle?: { storage?: unknown } }; expect(config.redis).toBeUndefined(); expect(config.throttle?.storage).toBeUndefined(); const server = await FrontMcpInstance.createFetchHandler(createConfig({})); const codes = []; for (let i = 0; i < 3; i++) codes.push((await (await server(exportTickets())).json()).result._meta?.code ?? "ok"); expect(codes).toEqual(["ok", "ok", "RATE_LIMIT_EXCEEDED"]); }); ``` **Hint:** Rate-limit counts have an option of their own in `@FrontMcp`, under `throttle`. It takes the same URL as `redis`, in a slightly different place. **Solution:** `throttle.storage` is where the guard keeps its counts, and `{ type: "redis", redis: { url } }` puts them in the Redis that `redis` uses, so every instance adds to the same count. `throttle` needs `enabled: true` once it's set. Without `REDIS_URL` neither option is there, and the guard counts in memory, which is what one instance, or a test, wants. --- # Escape Hatches > Stepping outside FrontMCP's declarative model when you have to. Reading the request behind a call, calling other services from a tool, and running FrontMCP without its built-in server. Source: https://frontmcp.dev/learn/escape-hatches [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Most of FrontMCP is declarative. You describe a tool's name, schema and handler, and FrontMCP does the rest: it validates arguments, shapes results, and speaks the protocol over HTTP. Now and then a server has to step outside that model: to look at the request itself, to reach a service on the other side of the network, or to run somewhere FrontMCP's own server doesn't. Most servers never need these. When yours does, this chapter shows how, and what to watch out for. **In this chapter** - [How to read the request behind a call](https://frontmcp.dev/learn/reading-the-request) - [How to call other services from a tool](https://frontmcp.dev/learn/calling-other-services) - [How to run FrontMCP without its built-in server](https://frontmcp.dev/learn/running-frontmcp-anywhere) ## Reading the request A tool gets its arguments in `execute()`. Everything else about the call is on `this`: `this.context` holds an id for the request and its trace, and `this.auth` the caller your authentication established. The request id is what ties a failure the user reports to the line in your logs: Initial call configuration: ```js { tool: "describe_call", arguments: {} } ``` ```ts describe-call.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "describe_call", description: "Describe the request this call arrived in. For debugging.", inputSchema: {}, }) export class DescribeCall extends ToolContext { async execute() { return { requestId: this.context.requestId, traceId: this.context.traceContext.traceId, caller: this.auth.user.sub, }; } } ``` Call it again and all three change: the Playground has no authentication, so every request is a new anonymous caller. Only the caller comes from something your server checked. The arguments, the client's name and its headers are whatever the client chose to send. ### [Reading the Request](https://frontmcp.dev/learn/reading-the-request) Learn what `this.context` and `this.auth` hold, how to tag logs and errors with the request id, where the caller's identity should come from, and why client info and arguments prove nothing. ## Calling other services `this.fetch()` is `fetch` with the current request attached: it tells the service which request is calling, and gives up after 30 seconds. The rest is up to the tool. It sends the service's own credentials rather than the caller's, and passes on only what the model needs: Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-1" } } ``` ```ts get-invoice.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string }; @Tool({ name: "get_invoice", description: "Get an invoice from the billing service: its status, total and due date.", inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") }, annotations: { readOnlyHint: true, openWorldHint: true }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { const res = await this.fetch(`https://billing.example/invoices/${id}`, { headers: { Authorization: "Bearer bk_test_7Hq2" }, // billing's key, from your secrets in a real server signal: AbortSignal.timeout(2_000), }); const invoice = (await res.json()) as Invoice; return { id: invoice.id, status: invoice.status, total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`, due: invoice.due_date, }; } } ``` ```ts billing.example.ts // Stands in for the billing service at https://billing.example, which the // Playground can't reach from your browser. It answers requests for that host // in-process, and passes everything else to the real fetch. A real server // doesn't need this file. // // INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers. const invoices: Record = { "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." }, "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." }, }; /** Every request the billing service received, oldest first. */ export const received: { path: string; headers: Record }[] = []; async function billing(request: Request): Promise { const path = new URL(request.url).pathname; received.push({ path, headers: Object.fromEntries(request.headers) }); switch (path) { case "/invoices/INV-6": return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } }); case "/invoices/INV-7": return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 }); case "/invoices/INV-8": // a connection that stays open and never answers return new Promise((_, reject) => { const open = setInterval(() => {}, 1_000); request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason))); }); } const invoice = invoices[path.replace("/invoices/", "")]; return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost"); return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init); }; ``` The billing service here is played by `billing.example.ts`, because the Playground can't reach the internet. Try `INV-9` or `INV-7`: this version still passes billing's errors off as invoices, which is one of the things the lesson fixes. ### [Calling Other Services](https://frontmcp.dev/learn/calling-other-services) Learn what `this.fetch()` adds to a request, why it keeps the caller's token to itself, and how to turn failures and timeouts into errors the model can act on. ## Running FrontMCP anywhere `@FrontMcp` normally starts an HTTP server for you. Underneath it, `FrontMcpInstance.createFetchHandler()` turns your server into a function from `Request` to `Response`, for Cloudflare Workers and any other runtime that speaks the web's `fetch` API. It's what runs this Playground. Open the **Tests** tab: ```ts worker.ts active import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDesk } from "./help-desk.app"; const handler = FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] }); export default { async fetch(request: Request): Promise { return (await handler)(request); }, }; ``` ```ts worker.test.ts import { test, expect } from "@frontmcp/testing"; import worker from "./worker"; test("the worker answers health checks", async () => { const res = await worker.fetch(new Request("https://desk.example.com/healthz")); expect(await res.json()).toMatchObject({ status: "ok", server: { name: "help-desk" } }); }); test("the worker answers MCP requests", async () => { const res = await worker.fetch( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }), }), ); expect((await res.json()).result.tools).toContainTool("get_ticket"); }); ``` ```ts help-desk.app.ts import { App, 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: "open" }, ]; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", 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}.`, "TICKET_NOT_FOUND")); return ticket; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` Two more entry points skip HTTP altogether: `connect()` gives your own code an MCP client connected in memory, and `createDirect()` runs tools with no client at all, for scripts and jobs. ### [Running FrontMCP Anywhere](https://frontmcp.dev/learn/running-frontmcp-anywhere) Learn when to take over from `@FrontMcp`, how to serve MCP from any web-standard runtime, and how `connect()` and `createDirect()` differ from a real client in identity, errors and formatting. ## What's next? Start the chapter with [Reading the Request](https://frontmcp.dev/learn/reading-the-request). This is the last chapter of Learn. From here, the [Reference](https://frontmcp.dev/reference) covers each API in detail. --- # Reading the Request > What a tool can learn about the call it's handling beyond its input, like the request id, the trace, the caller and the client, and which of those a client can simply make up. Source: https://frontmcp.dev/learn/reading-the-request [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Most tools need nothing but their input. Now and then a tool needs to know about the call itself: which request this is, so its logs and errors can be found later; who is calling, so it can decide what they may do; which client is on the other end. FrontMCP keeps all of that on `this`, next to the helpers you already use. This lesson covers what's there in FrontMCP 1.8, what it's for, and which parts you can't trust. **You will learn** - What `this.context` and `this.auth` tell a tool about the call and the caller - How to tag logs and error messages with the request id - Where the caller's identity should come from - Why arguments, client info and headers are claims, not facts ## What a tool knows about the call `execute()` receives the arguments. Everything else about the call is on `this`. This tool returns the parts you'll use most. You wouldn't ship it, but call it a few times and watch what changes: Initial call configuration: ```js { tool: "describe_call", arguments: {} } ``` ```ts describe-call.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "describe_call", description: "Describe the request this call arrived in. For debugging.", inputSchema: {}, }) export class DescribeCall extends ToolContext { async execute() { return { requestId: this.context.requestId, traceId: this.context.traceContext.traceId, caller: this.auth.user.sub, client: this.clientInfo ?? null, platform: this.platform, }; } } ``` `requestId` and `traceId` are new on every call, and here so is the caller: the Playground has no authentication, so each request comes from a new anonymous caller, `anon:` and a random id. `client` is the Playground's own description of itself, which it sends with every request. Here is what `this` offers: | On `this` | What it is | | --- | --- | | `this.context.requestId` | A UUID that FrontMCP gives each request. It isn't the JSON-RPC `id`, and the client never sees it unless you put it in a result. | | `this.context.traceContext` | The request's W3C trace context: `traceId`, `parentId`, and `raw`, the value of a `traceparent` header. A request without one starts a new trace. | | `this.contextLogger` | A logger whose lines carry the request id and the trace id. | | `this.auth` | The caller, as your server's authentication established it: `user.sub` says who, `isAnonymous` whether they signed in, `scopes` and `roles` what they were granted, and `claims` what their token said, with checks like `hasRole()`. See [Authorizing Calls](https://frontmcp.dev/learn/authorizing-calls). | | `this.context.authInfo` | The raw result of authentication that `this.auth` is built from, including `token`, the caller's token itself. | | `this.clientInfo` and `this.platform` | The client's name and version, and the AI platform FrontMCP guesses from the name, like `"claude"` or `"cursor"`. | | `this.context.metadata` | Parts of the HTTP request: `userAgent`, `clientIp`, and `customHeaders`, which holds every `x-frontmcp-*` header. | Not all of them are filled in for every call. The deep dive below shows what FrontMCP fills in for each way of running a server. ### What each way of running a server fills in What's on `this` depends on how the request reached FrontMCP: | | Node HTTP server (`frontmcp dev`, a Node build) | `createFetchHandler()` (Workers, this Playground) | In-process (`connect()`, `create()`) | | --- | --- | --- | --- | | `traceContext` | Read from the request's `traceparent` header, or new | Read from the request's `traceparent` header, or new | New, unless a `create()` call's `metadata` continues a trace with `x-frontmcp-trace-id` | | `metadata` | User agent, client IP, `x-frontmcp-*` headers | User agent, `x-frontmcp-*` headers, and the client IP when the runtime gives it (Bun, Deno, Cloudflare) or `FRONTMCP_TRUST_PROXY=1` is set | Empty with `connect()`; with `create()`, what each call's `metadata` option passes | | `clientInfo`, `platform` | From `initialize`, or from each 2026-07-28 request's `_meta` | From each request's `_meta` | From `connect()`'s `clientInfo` option; not set with `create()` | | The caller, in `this.auth` | From your auth configuration | From your auth configuration | Whatever the calling code passes: the user and their scopes | Clients before MCP 2026-07-28 open a session with an `initialize` request that includes their `clientInfo`, and FrontMCP keeps it for the rest of the session. MCP 2026-07-28 has no sessions: a client sends its info in every request's `_meta`, as `io.modelcontextprotocol/clientInfo`, and FrontMCP reads it from there. The Playground calls itself `frontmcp.dev playground`, which isn't a platform FrontMCP recognizes, so `this.platform` is `"generic-mcp"`. The tests below send a request to a fetch handler with headers of their own, connect with FrontMCP's in-process client, `connect()`, which sends `initialize`, and call the tool with `create()`, which has no client at all (see [Running FrontMCP Anywhere](https://frontmcp.dev/learn/running-frontmcp-anywhere)): Initial call configuration: ```js { tool: "describe_request", arguments: {} } ``` ```ts help-desk.app.ts active import { App, Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "describe_request", description: "Which client sent this request, and its trace and headers. For debugging.", inputSchema: {} }) export class DescribeRequest extends ToolContext { async execute() { return { client: this.clientInfo ?? null, platform: this.platform, traceparent: this.context.traceContext.raw, headers: this.context.metadata.customHeaders, }; } } @App({ id: "help-desk", name: "Help Desk", tools: [DescribeRequest] }) export class HelpDesk {} ``` ```ts request.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance, connect, create } from "@frontmcp/sdk"; import { DescribeRequest, HelpDesk } from "./help-desk.app"; const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] }; test("the Playground names itself in every request", async ({ mcp }) => { const result = await mcp.tools.call("describe_request", {}); expect(result.json()).toMatchObject({ client: { name: "frontmcp.dev playground", version: "1.0.0" }, platform: "generic-mcp" }); }); test("behind createFetchHandler(), the request's trace and headers reach the tool", async () => { const handler = await FrontMcpInstance.createFetchHandler(config); const traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "describe_request", traceparent, "x-frontmcp-desk": "berlin", }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "describe_request", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { name: "cursor", version: "1.4.0" }, }, }, }), }), ); const { result } = await response.json(); expect(result.structuredContent).toEqual({ client: { name: "cursor", version: "1.4.0" }, platform: "cursor", traceparent, headers: { "x-frontmcp-desk": "berlin" }, }); }); test("connect() sends the clientInfo you give it, and no headers", async () => { const client = await connect(config, { clientInfo: { name: "cursor", version: "1.4.0" } }); const result = await client.callTool("describe_request", {}); await client.close(); expect(result).toMatchObject({ structuredContent: { client: { name: "cursor", version: "1.4.0" }, platform: "cursor", headers: {} } }); }); test("create() has no client, and passes the metadata each call gives it", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [DescribeRequest] }); const result = await server.callTool("describe_request", {}, { metadata: { customHeaders: { "x-frontmcp-desk": "berlin" } } }); await server.dispose(); expect(result.structuredContent).toMatchObject({ client: null, platform: "unknown", headers: { "x-frontmcp-desk": "berlin" } }); }); ``` Either way it's the client's own description of itself, fine for choosing a format and useless as proof. ## Tagging logs and errors with the request id When a tool fails for a reason that isn't the model's fault, like a database that's having a bad day, the model can only tell the user something went wrong. This tool does exactly that: Initial call configuration: ```js { tool: "reopen_ticket", arguments: { id: "T-2" } } ``` ```ts reopen-ticket.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { reopen } from "./store"; @Tool({ name: "reopen_ticket", description: "Reopen a closed support ticket.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, }) export class ReopenTicket extends ToolContext { async execute({ id }: { id: string }) { try { return await reopen(id); } catch { this.fail(new PublicMcpError("Something went wrong. Please try again later.")); } } } ``` ```ts store.ts const tickets = new Map([ ["T-1", { id: "T-1", status: "closed" }], ["T-2", { id: "T-2", status: "closed" }], ]); export async function reopen(id: string) { // T-2 stands in for a database that's having a bad day. if (id === "T-2") throw new Error("SQLITE_BUSY: database is locked (tickets.db)"); const ticket = tickets.get(id); if (!ticket) throw new Error(`no row for ${id}`); ticket.status = "open"; return ticket; } ``` It's safe: the database error stays hidden. But when the user writes to support, "the assistant said something went wrong", nobody can find out which call failed or why. FrontMCP does log the failure, as a warning with an error id, but that line holds only the message the tool wrote: the database error is gone. And the message the model got has nothing for the user to quote. Log the real error with `this.contextLogger`, and give the user a reference to quote: Initial call configuration: ```js { tool: "reopen_ticket", arguments: { id: "T-2" } } ``` ```ts reopen-ticket.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { reopen } from "./store"; @Tool({ name: "reopen_ticket", description: "Reopen a closed support ticket.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, }) export class ReopenTicket extends ToolContext { async execute({ id }: { id: string }) { try { return await reopen(id); } catch (err) { this.contextLogger.error(`reopen ${id} failed: ${String(err)}`); this.fail( new PublicMcpError( `The ticket system couldn't reopen ${id} just now. Try again in a minute. ` + `If it keeps failing, tell the user to give support this reference: ${this.context.requestId}`, "STORE_UNAVAILABLE", ), ); } } } ``` ```ts store.ts const tickets = new Map([ ["T-1", { id: "T-1", status: "closed" }], ["T-2", { id: "T-2", status: "closed" }], ]); export async function reopen(id: string) { // T-2 stands in for a database that's having a bad day. if (id === "T-2") throw new Error("SQLITE_BUSY: database is locked (tickets.db)"); const ticket = tickets.get(id); if (!ticket) throw new Error(`no row for ${id}`); ticket.status = "open"; return ticket; } ``` ```ts reopen.test.ts import { test, expect } from "@frontmcp/testing"; test("the error gives a reference to quote", async ({ mcp }) => { const result = await mcp.tools.call("reopen_ticket", { id: "T-2" }); expect(result.raw._meta?.code).toBe("STORE_UNAVAILABLE"); expect(result.text()).toMatch(/reference: [0-9a-f-]{36}$/); }); test("the database error stays out of the message", async ({ mcp }) => { const result = await mcp.tools.call("reopen_ticket", { id: "T-2" }); expect(result.text()).not.toContain("SQLITE_BUSY"); }); ``` Open the **Logs** tab. After the time, the error line shows two ids in brackets, like `[[1f0c2a9e:5d1b4c7e]]`: the first eight characters of the request id and of the trace id. The message the model got ends with the full request id, so support can search the logs for its first eight characters and find the real error. The same id also reaches other services: [`this.fetch()`](https://frontmcp.dev/learn/calling-other-services) sends it upstream as `x-request-id`. ## Who is calling `this.auth` describes the caller: `user.sub` says who they are, `isAnonymous` whether they signed in, `scopes` and `roles` what they were granted, and `claims` what their token said. It's filled in from what your server's authentication checked, usually a token the client sent, so it's the one part of the request a tool can base a decision on. [Authenticating Clients](https://frontmcp.dev/learn/authenticating-clients) covers where it comes from, and [Authorizing Calls](https://frontmcp.dev/learn/authorizing-calls) what each field holds in each auth mode. This tool lists the tickets assigned to whoever is calling. It has no argument for "who", because the caller doesn't get to choose: Initial call configuration: ```js { tool: "my_tickets", arguments: {} } ``` ```ts my-tickets.tool.ts active import { PublicMcpError, Tool, ToolContext } from "@frontmcp/sdk"; const assigned: Record = { nour: ["T-1", "T-3"], sam: ["T-2"] }; @Tool({ name: "my_tickets", description: "List the support tickets assigned to the signed-in agent.", inputSchema: {}, annotations: { readOnlyHint: true }, }) export class MyTickets extends ToolContext { async execute() { if (this.auth.isAnonymous) { this.fail(new PublicMcpError("Only signed-in agents have tickets. Ask the user to sign in.", "SIGN_IN_REQUIRED")); } const agent = this.auth.user.sub; return { agent, tickets: assigned[agent] ?? [] }; } } ``` ```ts my-tickets.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { MyTickets } from "./my-tickets.tool"; test("anonymous callers are asked to sign in", async ({ mcp }) => { const result = await mcp.tools.call("my_tickets", {}); expect(result.raw._meta?.code).toBe("SIGN_IN_REQUIRED"); }); test("a signed-in agent gets their own tickets", async () => { // create() runs the tool in-process, as the user you name. const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [MyTickets] }); const result = await server.callTool("my_tickets", {}, { authContext: { user: { sub: "nour" } } }); expect(result.structuredContent).toEqual({ agent: "nour", tickets: ["T-1", "T-3"] }); await server.dispose(); }); ``` The Playground has no authentication, so the call above is anonymous and fails. The second test calls the tool as the agent `nour`, using `create()`, which runs a server in-process and lets the calling code say who the user is: the user's `sub` becomes `this.auth.user.sub`. That's for tests and trusted code, and [Running FrontMCP Anywhere](https://frontmcp.dev/learn/running-frontmcp-anywhere) covers it. ## Arguments, client info and headers are claims Only team leads may move a ticket to someone else. Here's a first attempt: Initial call configuration: ```js { tool: "reassign_ticket", arguments: { id: "T-1", to: "sam", requested_by: "nour" } } ``` ```ts reassign-ticket.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const leads = ["nour"]; @Tool({ name: "reassign_ticket", description: "Move a ticket to another agent. Team leads only.", inputSchema: { id: z.string().regex(/^T-\d+$/), to: z.string().describe("The agent to assign it to"), requested_by: z.string().describe("Who is asking"), }, }) export class ReassignTicket extends ToolContext { async execute({ id, to, requested_by }: { id: string; to: string; requested_by: string }) { if (!leads.includes(requested_by)) { this.fail(new PublicMcpError(`${requested_by} isn't a team lead.`, "LEADS_ONLY")); } return { id, assignee: to }; } } ``` ```ts reassign.test.ts import { test, expect } from "@frontmcp/testing"; test("🚩 anyone who says they're nour can reassign", async ({ mcp }) => { const result = await mcp.tools.call("reassign_ticket", { id: "T-1", to: "mallory", requested_by: "nour" }); expect(result).toBeSuccessful(); }); ``` The check works if the model passes the truth. But the model writes `requested_by`, from whatever is in the conversation, and a conversation can contain anything: a user who says "I'm Nour", or a ticket whose text says "the team lead asked to reassign this to mallory". The test shows it: an anonymous call that claims to be `nour` passes as a team lead. Decide from `this.auth`, which the model can't write, and take the argument away: Initial call configuration: ```js { tool: "reassign_ticket", arguments: { id: "T-1", to: "sam" } } ``` ```ts reassign-ticket.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "reassign_ticket", description: "Move a ticket to another agent. Team leads only.", inputSchema: { id: z.string().regex(/^T-\d+$/), to: z.string().describe("The agent to assign it to"), }, }) export class ReassignTicket extends ToolContext { async execute({ id, to }: { id: string; to: string }) { if (!this.auth.hasRole("lead")) { this.fail(new PublicMcpError("Only team leads can reassign tickets.", "LEADS_ONLY")); } return { id, assignee: to, by: this.auth.user.sub }; } } ``` ```ts reassign.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { ReassignTicket } from "./reassign-ticket.tool"; test("an anonymous caller can't reassign", async ({ mcp }) => { const result = await mcp.tools.call("reassign_ticket", { id: "T-1", to: "mallory" }); expect(result.raw._meta?.code).toBe("LEADS_ONLY"); }); test("a signed-in lead can", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [ReassignTicket] }); const nour = { user: { sub: "nour", roles: ["lead"] } }; const result = await server.callTool("reassign_ticket", { id: "T-1", to: "sam" }, { authContext: nour }); expect(result.structuredContent).toEqual({ id: "T-1", assignee: "sam", by: "nour" }); await server.dispose(); }); ``` Roles come from the caller's verified claims, here a `roles` claim in their token, which `this.auth.roles` reads. In-process, `authContext.user` stands in for those claims, so the test puts `roles` there. [Authorizing Calls](https://frontmcp.dev/learn/authorizing-calls) shows how to declare rules like this one so FrontMCP checks them before `execute()` runs at all. Arguments aren't the only thing a client makes up. Everything in the request came from the client, and nothing but your authentication checks any of it: - **Client info.** A client names itself in `clientInfo`, and FrontMCP guesses `this.platform` from that name. Any client can say it's `claude-ai`. - **Headers.** `this.context.metadata` holds the `User-Agent` and every `x-frontmcp-*` header exactly as the client sent them. If you let [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch) pass the `x-frontmcp-*` ones on to a service, don't let that service trust them either. - **`_meta`.** Trace ids, progress tokens and client capabilities travel in the request's `_meta`, written by the client. Use these for what they're good for: formatting a result for a client, logging, joining up a trace. Base access decisions only on the caller your authentication established, in `this.auth`. ## Recap - Everything about a call beyond its arguments is on `this`: `this.context` for the request, `this.auth` for the caller, `this.clientInfo` for the client. - `this.context.requestId` is new for every request. Put it in log lines with `this.contextLogger`, and in errors a person may have to report. - Take the caller's identity and claims from `this.auth`, never from an argument. The model writes the arguments. - Client info, headers and `_meta` are whatever the client sent. Use them for formatting, logging and tracing, not for access. - How much of the request reaches `this` depends on how the server runs: in-process calls have no headers and a new trace, and `create()` has no client info. - Every member of `this.context` and `this.auth`, per entry point, is in the [`this.context`](https://frontmcp.dev/reference/sdk/context) and [`this.auth`](https://frontmcp.dev/reference/sdk/auth) references. ## Try some challenges ### Challenge: Give support a reference `export_tickets` fails when the storage bucket rejects the upload, and its error passes the storage error straight to the model, access key and all. Keep the storage error in the logs, and give the model a message with a reference support can search for: this call's request id. Keep the code `EXPORT_FAILED`. Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts export-tickets.tool.ts active import { PublicMcpError, Tool, ToolContext } from "@frontmcp/sdk"; import { upload } from "./storage"; @Tool({ name: "export_tickets", description: "Export every support ticket to a CSV file and return a link to it.", inputSchema: {}, }) export class ExportTickets extends ToolContext { async execute() { try { return { url: await upload("tickets.csv", "id,title\nT-1,Cannot log in\n") }; } catch (err) { this.fail(new PublicMcpError(`Export failed: ${String(err)}`, "EXPORT_FAILED")); } } } ``` ```ts storage.ts export async function upload(name: string, csv: string): Promise { // Stands in for a bucket whose credentials were just rotated. throw new Error(`AccessDenied: key AKIA4EXAMPLE7Q is not allowed to write ${name} (${csv.length} bytes) to desk-exports`); } ``` **Solution file (alternative to the starter):** ```ts export-tickets.tool.ts solution import { PublicMcpError, Tool, ToolContext } from "@frontmcp/sdk"; import { upload } from "./storage"; @Tool({ name: "export_tickets", description: "Export every support ticket to a CSV file and return a link to it.", inputSchema: {}, }) export class ExportTickets extends ToolContext { async execute() { try { return { url: await upload("tickets.csv", "id,title\nT-1,Cannot log in\n") }; } catch (err) { this.contextLogger.error(`export failed: ${String(err)}`); this.fail( new PublicMcpError( `The export couldn't be saved. Tell the user to try again later, and to give support this reference: ${this.context.requestId}`, "EXPORT_FAILED", ), ); } } } ``` **Hidden fixture/test file (runs with the example):** ```ts export.test.ts hidden import { test, expect } from "@frontmcp/testing"; const uuid = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/; test("the error keeps the code `EXPORT_FAILED`", async ({ mcp }) => { const result = await mcp.tools.call("export_tickets", {}); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("EXPORT_FAILED"); }); test("the storage error stays out of the message", async ({ mcp }) => { const text = (await mcp.tools.call("export_tickets", {})).text() ?? ""; expect(text).not.toContain("AccessDenied"); expect(text).not.toContain("AKIA"); }); test("the message includes the request id as a reference", async ({ mcp }) => { const first = (await mcp.tools.call("export_tickets", {})).text() ?? ""; const second = (await mcp.tools.call("export_tickets", {})).text() ?? ""; expect(first).toMatch(uuid); expect(first.match(uuid)?.[0]).not.toBe(second.match(uuid)?.[0]); }); ``` **Hint:** `this.contextLogger` writes a log line tagged with the request, and `this.context.requestId` is the id to put in the message. **Solution:** The storage error now goes to the log, in a line tagged with the first eight characters of the request id, and the model gets a message it can pass on with the full id. Each call has its own id, so support can find exactly the failure a user reports. The model never sees the key, which would reach it word for word in production too: a `PublicMcpError`'s message is shown as is. ### Challenge: Record who closed the ticket `close_ticket` asks the model who is closing the ticket, so the audit trail says whatever the model wrote. Take the agent from the signed-in caller instead: remove `closed_by` from the input, record the caller's `user.sub`, and refuse anonymous callers with the code `SIGN_IN_REQUIRED`. ```ts close-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket.", inputSchema: { id: z.string().regex(/^T-\d+$/), closed_by: z.string().describe("The agent closing the ticket"), }, }) export class CloseTicket extends ToolContext { async execute({ id, closed_by }: { id: string; closed_by: string }) { return { id, status: "closed", closed_by }; } } ``` **Solution file (alternative to the starter):** ```ts close-ticket.tool.ts solution import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket.", inputSchema: { id: z.string().regex(/^T-\d+$/) }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { if (this.auth.isAnonymous) { this.fail(new PublicMcpError("Only signed-in agents can close tickets. Ask the user to sign in.", "SIGN_IN_REQUIRED")); } return { id, status: "closed", closed_by: this.auth.user.sub }; } } ``` **Hidden fixture/test file (runs with the example):** ```ts close-ticket.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; test("`closed_by` isn't an argument any more", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "close_ticket"); expect(Object.keys(tool?.inputSchema.properties ?? {})).toEqual(["id"]); }); test("anonymous callers get `SIGN_IN_REQUIRED`", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("SIGN_IN_REQUIRED"); }); test("a signed-in agent is recorded as the closer", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [CloseTicket] }); try { const result = await server.callTool("close_ticket", { id: "T-1", closed_by: "mallory" }, { authContext: { user: { sub: "sam" } } }); expect(result.structuredContent).toEqual({ id: "T-1", status: "closed", closed_by: "sam" }); } finally { await server.dispose(); } }); ``` **Hint:** `this.auth.user.sub` is the caller, and `this.auth.isAnonymous` says whether they signed in. Remember that `this.fail()` ends the call, so nothing after it runs. **Solution:** The model can no longer name the closer, because there's nothing to fill in: FrontMCP drops `closed_by` if a client sends it anyway, as the last test does. The agent comes from `this.auth`, which the model can't touch, and an anonymous call fails before anything is recorded. ### Challenge: Don't unlock notes by client name `customer_notes` returns internal notes only to the help desk's own console, which it recognizes by `clientInfo.name`. Any client can use that name. Show the notes to callers whose token has the `agent` role instead, whatever client they use, and refuse everyone else with the code `AGENTS_ONLY`. ```ts help-desk.app.ts import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const notes: Record = { "C-881": "Legacy plan. Don't offer a refund." }; @Tool({ name: "customer_notes", description: "Internal notes about a customer. Help desk staff only.", inputSchema: { customer: z.string().describe("Customer id, like C-881") }, }) export class CustomerNotes extends ToolContext { async execute({ customer }: { customer: string }) { if (this.clientInfo?.name !== "desk-console") { this.fail(new PublicMcpError("Internal notes are only available in the help desk console.", "AGENTS_ONLY")); } return { customer, notes: notes[customer] ?? "" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CustomerNotes] }) export class HelpDesk {} ``` **Solution file (alternative to the starter):** ```ts help-desk.app.ts solution import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const notes: Record = { "C-881": "Legacy plan. Don't offer a refund." }; @Tool({ name: "customer_notes", description: "Internal notes about a customer. Help desk staff only.", inputSchema: { customer: z.string().describe("Customer id, like C-881") }, }) export class CustomerNotes extends ToolContext { async execute({ customer }: { customer: string }) { if (!this.auth.hasRole("agent")) { this.fail(new PublicMcpError("Internal notes are only available to help desk agents.", "AGENTS_ONLY")); } return { customer, notes: notes[customer] ?? "" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CustomerNotes] }) export class HelpDesk {} ``` **Hidden fixture/test file (runs with the example):** ```ts notes.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { connect, create } from "@frontmcp/sdk"; import { CustomerNotes, HelpDesk } from "./help-desk.app"; const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] }; test("a client that calls itself desk-console gets no notes", async () => { const client = await connect(config, { clientInfo: { name: "desk-console", version: "1.0.0" } }); const result = await client.callTool("customer_notes", { customer: "C-881" }); await client.close(); expect(result).toMatchObject({ isError: true, _meta: { code: "AGENTS_ONLY" } }); }); test("a caller with the `agent` role gets the notes", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [CustomerNotes] }); const sam = { user: { sub: "sam", roles: ["agent"] } }; const result = await server.callTool("customer_notes", { customer: "C-881" }, { authContext: sam }); await server.dispose(); expect(result.structuredContent).toEqual({ customer: "C-881", notes: "Legacy plan. Don't offer a refund." }); }); test("anonymous callers get `AGENTS_ONLY`", async ({ mcp }) => { const result = await mcp.tools.call("customer_notes", { customer: "C-881" }); expect(result.raw._meta?.code).toBe("AGENTS_ONLY"); }); ``` **Hint:** The tests use FrontMCP's in-process entry points, which let them choose the client's name and the signed-in user. Only one of those is checked by anything. The caller's roles are in `this.auth.roles`, as in `reassign_ticket` above. **Solution:** The first test connects with the console's name and no user, and the starter gave it the notes. A name is just a string the client sends. The solution checks the caller's claims instead, so the notes follow the signed-in agent wherever they work, and a client that only borrows the console's name gets nothing. --- # Calling Other Services > How a tool calls an HTTP API with this.fetch(), what it adds to each request, whose credentials it sends, and how to turn a slow or failing service into errors the model can act on. Source: https://frontmcp.dev/learn/calling-other-services [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Sooner or later a tool needs data that lives somewhere else: a billing API, a CRM, another team's service. In a FrontMCP tool, `this.fetch()` is the web's `fetch` with the current request attached. Making the call is the easy part. This lesson is about everything around it: a service that's down or slow, a response full of data the model shouldn't see, and credentials that end up where they shouldn't. **You will learn** - What `this.fetch()` adds to a request - Which credentials to send, and why not the caller's - How to turn failures and timeouts into errors the model can act on - How to keep upstream secrets and raw responses away from the model ## Calling a service from a tool Support agents often need a customer's invoice, and invoices live in the billing service. Here's a first version of a tool that fetches one. (If the service has an OpenAPI description, the [OpenAPI adapter](https://frontmcp.dev/learn/wrapping-an-openapi-service) can generate a tool for each operation instead; this lesson writes the tool by hand, which is what you do when you want to shape what the model sees.) Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-1" } } ``` ```ts get-invoice.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_invoice", description: "Get an invoice from the billing service.", inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { const res = await fetch(`https://billing.example/invoices/${id}`); return await res.json(); } } ``` ```ts billing.example.ts // Stands in for the billing service at https://billing.example, which the // Playground can't reach from your browser. It answers requests for that host // in-process, and passes everything else to the real fetch. A real server // doesn't need this file. // // INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers. const invoices: Record = { "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." }, "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." }, }; /** Every request the billing service received, oldest first. */ export const received: { path: string; headers: Record }[] = []; async function billing(request: Request): Promise { const path = new URL(request.url).pathname; received.push({ path, headers: Object.fromEntries(request.headers) }); switch (path) { case "/invoices/INV-6": return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } }); case "/invoices/INV-7": return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 }); case "/invoices/INV-8": // a connection that stays open and never answers return new Promise((_, reject) => { const open = setInterval(() => {}, 1_000); request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason))); }); } const invoice = invoices[path.replace("/invoices/", "")]; return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost"); return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init); }; ``` ```ts get-invoice.test.ts import { test, expect } from "@frontmcp/testing"; test("🚩 the model gets the customer's card token", async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id: "INV-1" }); expect(result.json()).toHaveProperty("card_token"); }); test("🚩 a missing invoice looks like a result", async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id: "INV-9" }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ error: "not_found" }); }); ``` > **Note** > > The Playground runs in your browser and can't reach a real billing service, so every example on this page has a `billing.example.ts` file that plays one. It answers requests for `https://billing.example` in-process, and records what it received so tests can check it. You won't need it in a real server. The tool works, and it has four problems, which the rest of this lesson fixes one at a time: 1. **The model gets everything the service sent**, including a card token and internal notes it should never see. 2. **Failures look like answers.** A 404 comes back as a successful result that says `{"error":"not_found"}`. 3. **Nothing limits how long it waits.** If billing hangs, so does the call. 4. **Billing can't tell who is calling.** Nothing links its logs to yours. ## Using `this.fetch()` Start with the last problem, and the first. Switch `fetch` to `this.fetch()`, and build the result from the fields the model needs: Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-1" } } ``` ```ts get-invoice.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string }; @Tool({ name: "get_invoice", description: "Get an invoice from the billing service: its status, total and due date.", inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") }, annotations: { readOnlyHint: true, openWorldHint: true }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { const res = await this.fetch(`https://billing.example/invoices/${id}`); const invoice = (await res.json()) as Invoice; return { id: invoice.id, status: invoice.status, total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`, due: invoice.due_date, }; } } ``` ```ts billing.example.ts // Stands in for the billing service at https://billing.example, which the // Playground can't reach from your browser. It answers requests for that host // in-process, and passes everything else to the real fetch. A real server // doesn't need this file. // // INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers. const invoices: Record = { "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." }, "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." }, }; /** Every request the billing service received, oldest first. */ export const received: { path: string; headers: Record }[] = []; async function billing(request: Request): Promise { const path = new URL(request.url).pathname; received.push({ path, headers: Object.fromEntries(request.headers) }); switch (path) { case "/invoices/INV-6": return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } }); case "/invoices/INV-7": return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 }); case "/invoices/INV-8": // a connection that stays open and never answers return new Promise((_, reject) => { const open = setInterval(() => {}, 1_000); request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason))); }); } const invoice = invoices[path.replace("/invoices/", "")]; return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost"); return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init); }; ``` ```ts get-invoice.test.ts import { test, expect } from "@frontmcp/testing"; import { received } from "./billing.example"; test("the model gets only what it needs", async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id: "INV-1" }); expect(result.json()).toEqual({ id: "INV-1", status: "paid", total: "120.00 EUR", due: "2026-09-01" }); }); test("billing is told which request is calling", async ({ mcp }) => { await mcp.tools.call("get_invoice", { id: "INV-1" }); const { headers } = received[received.length - 1]; expect(headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/); expect(headers["traceparent"]).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$/); }); ``` `this.fetch()` takes the same arguments as `fetch` and returns the same `Response`. What it adds is the request you're in. Each of these is added only if you haven't set it yourself: | Added | Value | | --- | --- | | `x-request-id` header | [`this.context.requestId`](https://frontmcp.dev/learn/reading-the-request), so billing's logs can be matched with yours. | | `traceparent` header | The request's trace context, so a tracing system can join your server's work and billing's into one trace. | | A timeout | 30 seconds, when you don't pass a `signal` of your own. | What it doesn't add matters as much: the caller's token and their `x-frontmcp-*` headers stay on your server, as the next section shows. The other change is the result: the tool picks four fields and formats the total, and everything else the service sent stays in the tool. The service's JSON is a lot like a database row. It carries whatever billing needed, it can grow new fields without telling you, and [Shaping Tool Results](https://frontmcp.dev/learn/shaping-tool-results) explains why the model should get a result you built on purpose. ## Sending your own credentials Your server authenticates its clients, usually with an OAuth access token. That token was issued for your MCP server, not for billing, and `this.fetch()` doesn't send it on. The Playground has no authentication, so this test uses `create()` to call the tool as a signed-in user whose token is `mcp-token-for-nour`, and looks at what billing received: Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-1" } } ``` ```ts get-invoice.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string }; @Tool({ name: "get_invoice", description: "Get an invoice from the billing service: its status, total and due date.", inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") }, annotations: { readOnlyHint: true, openWorldHint: true }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { const res = await this.fetch(`https://billing.example/invoices/${id}`); const invoice = (await res.json()) as Invoice; return { id: invoice.id, status: invoice.status, total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`, due: invoice.due_date, }; } } ``` ```ts billing.example.ts // Stands in for the billing service at https://billing.example, which the // Playground can't reach from your browser. It answers requests for that host // in-process, and passes everything else to the real fetch. A real server // doesn't need this file. // // INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers. const invoices: Record = { "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." }, "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." }, }; /** Every request the billing service received, oldest first. */ export const received: { path: string; headers: Record }[] = []; async function billing(request: Request): Promise { const path = new URL(request.url).pathname; received.push({ path, headers: Object.fromEntries(request.headers) }); switch (path) { case "/invoices/INV-6": return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } }); case "/invoices/INV-7": return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 }); case "/invoices/INV-8": // a connection that stays open and never answers return new Promise((_, reject) => { const open = setInterval(() => {}, 1_000); request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason))); }); } const invoice = invoices[path.replace("/invoices/", "")]; return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost"); return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init); }; ``` ```ts credentials.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { received } from "./billing.example"; import { GetInvoice } from "./get-invoice.tool"; test("the caller's token stays on your server", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [GetInvoice] }); await server.callTool("get_invoice", { id: "INV-1" }, { authContext: { token: "mcp-token-for-nour", user: { sub: "nour" } } }); await server.dispose(); expect(received[received.length - 1].headers).not.toHaveProperty("authorization"); }); ``` Forwarding it would hand the user's credential to another service, and if that service accepted it, the model could reach it with the user's rights, which nobody agreed to. The MCP specification calls this token passthrough and forbids it. So billing gets no credentials at all. Give each service its own. `this.fetch()` never replaces an `Authorization` header you set: Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-1" } } ``` ```ts get-invoice.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { BILLING_API_KEY } from "./secrets"; type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string }; @Tool({ name: "get_invoice", description: "Get an invoice from the billing service: its status, total and due date.", inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") }, annotations: { readOnlyHint: true, openWorldHint: true }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { const res = await this.fetch(`https://billing.example/invoices/${id}`, { headers: { Authorization: `Bearer ${BILLING_API_KEY}` }, }); const invoice = (await res.json()) as Invoice; return { id: invoice.id, status: invoice.status, total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`, due: invoice.due_date, }; } } ``` ```ts secrets.ts // In a real server, read this from the environment or a secret store. export const BILLING_API_KEY = "bk_test_7Hq2"; ``` ```ts billing.example.ts // Stands in for the billing service at https://billing.example, which the // Playground can't reach from your browser. It answers requests for that host // in-process, and passes everything else to the real fetch. A real server // doesn't need this file. // // INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers. const invoices: Record = { "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." }, "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." }, }; /** Every request the billing service received, oldest first. */ export const received: { path: string; headers: Record }[] = []; async function billing(request: Request): Promise { const path = new URL(request.url).pathname; received.push({ path, headers: Object.fromEntries(request.headers) }); switch (path) { case "/invoices/INV-6": return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } }); case "/invoices/INV-7": return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 }); case "/invoices/INV-8": // a connection that stays open and never answers return new Promise((_, reject) => { const open = setInterval(() => {}, 1_000); request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason))); }); } const invoice = invoices[path.replace("/invoices/", "")]; return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost"); return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init); }; ``` ```ts credentials.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { received } from "./billing.example"; import { GetInvoice } from "./get-invoice.tool"; test("billing gets its own key, not the caller's token", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [GetInvoice] }); await server.callTool("get_invoice", { id: "INV-1" }, { authContext: { token: "mcp-token-for-nour", user: { sub: "nour" } } }); await server.dispose(); expect(received[received.length - 1].headers["authorization"]).toBe("Bearer bk_test_7Hq2"); }); ``` Keep the key where the model can't see it: out of results, out of error messages, and out of URLs, which tend to end up in logs. The same goes for anything the service sends back about itself. > **Note** > > Sometimes a service of your own accepts the same tokens as your MCP server, because both trust the same identity provider. Then you can let the caller's token through to that service, and only to it, by listing its origin in `@FrontMcp({ fetch: { forwardCallerTokenTo: ["https://tickets-api.example.com"] } })`. [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch#forwarding-the-callers-token-to-your-own-service) shows how. For a service where each user has an account of their own, the types accept `credentials: { provider }`, but FrontMCP registers no provider registry for it to read from, so on its own it adds nothing and logs a warning. ## When the service fails Now the second problem. A response isn't an answer until you've checked its status. The obvious fix is to pass billing's error on: Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-7" } } ``` ```ts get-invoice.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { BILLING_API_KEY } from "./secrets"; type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string }; @Tool({ name: "get_invoice", description: "Get an invoice from the billing service: its status, total and due date.", inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") }, annotations: { readOnlyHint: true, openWorldHint: true }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { const res = await this.fetch(`https://billing.example/invoices/${id}`, { headers: { Authorization: `Bearer ${BILLING_API_KEY}` }, }); if (!res.ok) { this.fail(new PublicMcpError(`Billing error ${res.status}: ${await res.text()}`)); } const invoice = (await res.json()) as Invoice; return { id: invoice.id, status: invoice.status, total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`, due: invoice.due_date, }; } } ``` ```ts secrets.ts // In a real server, read this from the environment or a secret store. export const BILLING_API_KEY = "bk_test_7Hq2"; ``` ```ts billing.example.ts // Stands in for the billing service at https://billing.example, which the // Playground can't reach from your browser. It answers requests for that host // in-process, and passes everything else to the real fetch. A real server // doesn't need this file. // // INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers. const invoices: Record = { "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." }, "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." }, }; /** Every request the billing service received, oldest first. */ export const received: { path: string; headers: Record }[] = []; async function billing(request: Request): Promise { const path = new URL(request.url).pathname; received.push({ path, headers: Object.fromEntries(request.headers) }); switch (path) { case "/invoices/INV-6": return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } }); case "/invoices/INV-7": return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 }); case "/invoices/INV-8": // a connection that stays open and never answers return new Promise((_, reject) => { const open = setInterval(() => {}, 1_000); request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason))); }); } const invoice = invoices[path.replace("/invoices/", "")]; return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost"); return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init); }; ``` The model now learns the name and port of billing's database server, and nothing it can do about it. And because the message is in a `PublicMcpError`, it gets it word for word in production too. The model needs to know one of two things: that it asked for something that doesn't exist, so it should fix its request, or that billing is having trouble, so it should wait or carry on without the invoice. Say exactly that, and log the details for yourself: Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-7" } } ``` ```ts get-invoice.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { BILLING_API_KEY } from "./secrets"; type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string }; const unavailable = () => new PublicMcpError( "The billing service isn't answering right now. Try again in a few minutes, or carry on without the invoice.", "BILLING_UNAVAILABLE", ); @Tool({ name: "get_invoice", description: "Get an invoice from the billing service: its status, total and due date.", inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") }, annotations: { readOnlyHint: true, openWorldHint: true }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { let res: Response; try { res = await this.fetch(`https://billing.example/invoices/${id}`, { headers: { Authorization: `Bearer ${BILLING_API_KEY}` }, }); } catch (err) { this.contextLogger.error(`billing request failed: ${String(err)}`); this.fail(unavailable()); } if (res.status === 404) { this.fail(new PublicMcpError(`There's no invoice ${id}. Check the id with the user.`, "INVOICE_NOT_FOUND")); } if (!res.ok) { this.contextLogger.error(`billing answered ${res.status}: ${await res.text()}`); this.fail(unavailable()); } const invoice = (await res.json()) as Invoice; return { id: invoice.id, status: invoice.status, total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`, due: invoice.due_date, }; } } ``` ```ts secrets.ts // In a real server, read this from the environment or a secret store. export const BILLING_API_KEY = "bk_test_7Hq2"; ``` ```ts billing.example.ts // Stands in for the billing service at https://billing.example, which the // Playground can't reach from your browser. It answers requests for that host // in-process, and passes everything else to the real fetch. A real server // doesn't need this file. // // INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers. const invoices: Record = { "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." }, "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." }, }; /** Every request the billing service received, oldest first. */ export const received: { path: string; headers: Record }[] = []; async function billing(request: Request): Promise { const path = new URL(request.url).pathname; received.push({ path, headers: Object.fromEntries(request.headers) }); switch (path) { case "/invoices/INV-6": return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } }); case "/invoices/INV-7": return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 }); case "/invoices/INV-8": // a connection that stays open and never answers return new Promise((_, reject) => { const open = setInterval(() => {}, 1_000); request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason))); }); } const invoice = invoices[path.replace("/invoices/", "")]; return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost"); return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init); }; ``` ```ts errors.test.ts import { test, expect } from "@frontmcp/testing"; test("a missing invoice is INVOICE_NOT_FOUND", async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id: "INV-9" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("INVOICE_NOT_FOUND"); }); test("an outage is BILLING_UNAVAILABLE, without billing's details", async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id: "INV-7" }); expect(result.raw._meta?.code).toBe("BILLING_UNAVAILABLE"); expect(result.text()).not.toContain("pg-billing"); }); test("a real invoice still comes back", async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id: "INV-1" }); expect(result).toBeSuccessful(); }); ``` Each failure gets a code, so programs and tests can tell them apart, and a message that says what to do next. The `catch` handles the other way a call fails: `this.fetch()` throws, rather than returning a response, when the service can't be reached at all. The details go to the log, tagged with the request id, as in [Reading the Request](https://frontmcp.dev/learn/reading-the-request). > **Pitfall: this.fail() throws, and catch catches it** > > `this.fail()` ends the call by throwing. Call it inside a `try`, and your own `catch` receives it. A "no such invoice" error would turn into "billing isn't answering" before the model ever saw it. Keep the `try` around the `this.fetch()` call only, as above, and fail after it. The second challenge shows the bug. ## When the service is slow The last problem left is waiting. `INV-8` never answers. With no `signal`, `this.fetch()` waits 30 seconds before it gives up, and the user waits with it. Decide how long this service may take, pass that as `signal`, and tell the model what happened when the time runs out: Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-8" } } ``` ```ts get-invoice.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { BILLING_API_KEY } from "./secrets"; type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string }; const unavailable = () => new PublicMcpError( "The billing service isn't answering right now. Try again in a few minutes, or carry on without the invoice.", "BILLING_UNAVAILABLE", ); @Tool({ name: "get_invoice", description: "Get an invoice from the billing service: its status, total and due date.", inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") }, annotations: { readOnlyHint: true, openWorldHint: true }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { let res: Response; try { res = await this.fetch(`https://billing.example/invoices/${id}`, { headers: { Authorization: `Bearer ${BILLING_API_KEY}` }, signal: AbortSignal.timeout(2_000), }); } catch (err) { if ((err as Error).name === "TimeoutError") { this.fail(new PublicMcpError("The billing service didn't answer within 2 seconds. Try again later, or carry on without the invoice.", "BILLING_TIMEOUT")); } this.contextLogger.error(`billing request failed: ${String(err)}`); this.fail(unavailable()); } if (res.status === 404) { this.fail(new PublicMcpError(`There's no invoice ${id}. Check the id with the user.`, "INVOICE_NOT_FOUND")); } if (!res.ok) { this.contextLogger.error(`billing answered ${res.status}: ${await res.text()}`); this.fail(unavailable()); } const invoice = (await res.json()) as Invoice; return { id: invoice.id, status: invoice.status, total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`, due: invoice.due_date, }; } } ``` ```ts secrets.ts // In a real server, read this from the environment or a secret store. export const BILLING_API_KEY = "bk_test_7Hq2"; ``` ```ts billing.example.ts // Stands in for the billing service at https://billing.example, which the // Playground can't reach from your browser. It answers requests for that host // in-process, and passes everything else to the real fetch. A real server // doesn't need this file. // // INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers. const invoices: Record = { "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." }, "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." }, }; /** Every request the billing service received, oldest first. */ export const received: { path: string; headers: Record }[] = []; async function billing(request: Request): Promise { const path = new URL(request.url).pathname; received.push({ path, headers: Object.fromEntries(request.headers) }); switch (path) { case "/invoices/INV-6": return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } }); case "/invoices/INV-7": return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 }); case "/invoices/INV-8": // a connection that stays open and never answers return new Promise((_, reject) => { const open = setInterval(() => {}, 1_000); request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason))); }); } const invoice = invoices[path.replace("/invoices/", "")]; return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost"); return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init); }; ``` ```ts timeout.test.ts import { test, expect } from "@frontmcp/testing"; test("a hung service is BILLING_TIMEOUT after 2 seconds", async ({ mcp }) => { const started = Date.now(); const result = await mcp.tools.call("get_invoice", { id: "INV-8" }); expect(result.raw._meta?.code).toBe("BILLING_TIMEOUT"); expect(Date.now() - started).toBeLessThan(4_000); }); ``` `AbortSignal.timeout()` aborts the request, and `this.fetch()` rejects with an error named `TimeoutError`. Passing your own `signal` replaces the 30-second default, so the limit is yours. Keep it well under what a client will wait for a whole tool call, or the client gives up first and the model never sees your message. ### A time limit for the whole call `@Tool` also takes `timeout: { executeMs }`, a limit on the whole of `execute()`, however many requests it makes: Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-8" } } ``` ```ts get-invoice.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_invoice", description: "Get an invoice from the billing service: its status, total and due date.", inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") }, timeout: { executeMs: 1_000 }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { const res = await this.fetch(`https://billing.example/invoices/${id}`); return { status: res.status }; } } ``` ```ts billing.example.ts // Stands in for the billing service at https://billing.example, which the // Playground can't reach from your browser. It answers requests for that host // in-process, and passes everything else to the real fetch. A real server // doesn't need this file. // // INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers. const invoices: Record = { "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." }, "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." }, }; /** Every request the billing service received, oldest first. */ export const received: { path: string; headers: Record }[] = []; async function billing(request: Request): Promise { const path = new URL(request.url).pathname; received.push({ path, headers: Object.fromEntries(request.headers) }); switch (path) { case "/invoices/INV-6": return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } }); case "/invoices/INV-7": return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 }); case "/invoices/INV-8": // a connection that stays open and never answers return new Promise((_, reject) => { const open = setInterval(() => {}, 1_000); request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason))); }); } const invoice = invoices[path.replace("/invoices/", "")]; return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost"); return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init); }; ``` After a second the call fails with the code `EXECUTION_TIMEOUT` and `Execution of "get_invoice" timed out after 1000ms`, in production too. It makes a good backstop, but it doesn't say which service was slow or what to do instead. A timeout on the request, with a message you wrote, tells the model what to do. ## Recap - `this.fetch()` is `fetch` plus the request: it sends `x-request-id` and `traceparent`, and gives up after 30 seconds unless you pass a `signal`. - `this.fetch()` keeps the caller's token and `x-frontmcp-*` headers on your server. Send each service its own credentials in `Authorization`. - Check `res.ok`. Map "doesn't exist" and "not available right now" to different codes and messages that tell the model what to do. - Keep upstream error bodies, host names and keys out of results and error messages. Log them with `this.contextLogger` instead. - Set a timeout per request with `signal: AbortSignal.timeout(ms)`, and catch the `TimeoutError`. - Keep `this.fail()` out of `try` blocks whose `catch` would swallow it. ## Try some challenges ### Challenge: Tell the model how long to wait When billing is busy it answers `429 Too Many Requests` with a `Retry-After` header, the number of seconds to wait. `get_invoice` treats that like an outage. Fail with the code `BILLING_BUSY` instead, and a message that says how many seconds to wait. Try it with `INV-6`. Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-6" } } ``` ```ts get-invoice.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { BILLING_API_KEY } from "./secrets"; type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string }; const unavailable = () => new PublicMcpError( "The billing service isn't answering right now. Try again in a few minutes, or carry on without the invoice.", "BILLING_UNAVAILABLE", ); @Tool({ name: "get_invoice", description: "Get an invoice from the billing service: its status, total and due date.", inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") }, annotations: { readOnlyHint: true, openWorldHint: true }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { let res: Response; try { res = await this.fetch(`https://billing.example/invoices/${id}`, { headers: { Authorization: `Bearer ${BILLING_API_KEY}` }, signal: AbortSignal.timeout(2_000), }); } catch (err) { this.contextLogger.error(`billing request failed: ${String(err)}`); this.fail(unavailable()); } if (res.status === 404) { this.fail(new PublicMcpError(`There's no invoice ${id}. Check the id with the user.`, "INVOICE_NOT_FOUND")); } if (!res.ok) { this.contextLogger.error(`billing answered ${res.status}: ${await res.text()}`); this.fail(unavailable()); } const invoice = (await res.json()) as Invoice; return { id: invoice.id, status: invoice.status, total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`, due: invoice.due_date, }; } } ``` **Solution file (alternative to the starter):** ```ts get-invoice.tool.ts solution import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { BILLING_API_KEY } from "./secrets"; type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string }; const unavailable = () => new PublicMcpError( "The billing service isn't answering right now. Try again in a few minutes, or carry on without the invoice.", "BILLING_UNAVAILABLE", ); @Tool({ name: "get_invoice", description: "Get an invoice from the billing service: its status, total and due date.", inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") }, annotations: { readOnlyHint: true, openWorldHint: true }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { let res: Response; try { res = await this.fetch(`https://billing.example/invoices/${id}`, { headers: { Authorization: `Bearer ${BILLING_API_KEY}` }, signal: AbortSignal.timeout(2_000), }); } catch (err) { this.contextLogger.error(`billing request failed: ${String(err)}`); this.fail(unavailable()); } if (res.status === 404) { this.fail(new PublicMcpError(`There's no invoice ${id}. Check the id with the user.`, "INVOICE_NOT_FOUND")); } if (res.status === 429) { const seconds = Number(res.headers.get("retry-after")) || 60; this.fail(new PublicMcpError(`The billing service is busy. Try again in ${seconds} seconds.`, "BILLING_BUSY")); } if (!res.ok) { this.contextLogger.error(`billing answered ${res.status}: ${await res.text()}`); this.fail(unavailable()); } const invoice = (await res.json()) as Invoice; return { id: invoice.id, status: invoice.status, total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`, due: invoice.due_date, }; } } ``` ```ts secrets.ts // In a real server, read this from the environment or a secret store. export const BILLING_API_KEY = "bk_test_7Hq2"; ``` ```ts billing.example.ts // Stands in for the billing service at https://billing.example, which the // Playground can't reach from your browser. It answers requests for that host // in-process, and passes everything else to the real fetch. A real server // doesn't need this file. // // INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers. const invoices: Record = { "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." }, "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." }, }; /** Every request the billing service received, oldest first. */ export const received: { path: string; headers: Record }[] = []; async function billing(request: Request): Promise { const path = new URL(request.url).pathname; received.push({ path, headers: Object.fromEntries(request.headers) }); switch (path) { case "/invoices/INV-6": return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } }); case "/invoices/INV-7": return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 }); case "/invoices/INV-8": // a connection that stays open and never answers return new Promise((_, reject) => { const open = setInterval(() => {}, 1_000); request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason))); }); } const invoice = invoices[path.replace("/invoices/", "")]; return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost"); return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init); }; ``` **Hidden fixture/test file (runs with the example):** ```ts busy.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("a 429 fails with `BILLING_BUSY`", async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id: "INV-6" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("BILLING_BUSY"); }); test("the message says to wait 30 seconds, as billing asked", async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id: "INV-6" }); expect(result.text()).toContain("30 seconds"); }); test("an outage is still `BILLING_UNAVAILABLE`", async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id: "INV-7" }); expect(result.raw._meta?.code).toBe("BILLING_UNAVAILABLE"); }); ``` **Hint:** Check for `429` before the general `!res.ok` branch. `res.headers.get("retry-after")` returns the header as a string, or `null` if it's missing. **Solution:** A rate limit isn't an outage: billing is fine, and it has said exactly when to come back. Passing that on lets the model wait and retry, or tell the user when to try again, instead of giving up. The fallback of 60 seconds covers a service that forgets the header. ### Challenge: Let the not-found error through This version of `get_invoice` wraps everything in one `try`. Ask it for `INV-9`, which doesn't exist, and the model hears that billing is down. Make a missing invoice fail with `INVOICE_NOT_FOUND` again, without changing how outages are reported. Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-9" } } ``` ```ts get-invoice.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { BILLING_API_KEY } from "./secrets"; type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string }; @Tool({ name: "get_invoice", description: "Get an invoice from the billing service: its status, total and due date.", inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") }, annotations: { readOnlyHint: true, openWorldHint: true }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { try { const res = await this.fetch(`https://billing.example/invoices/${id}`, { headers: { Authorization: `Bearer ${BILLING_API_KEY}` }, signal: AbortSignal.timeout(2_000), }); if (res.status === 404) { this.fail(new PublicMcpError(`There's no invoice ${id}. Check the id with the user.`, "INVOICE_NOT_FOUND")); } if (!res.ok) throw new Error(`billing answered ${res.status}: ${await res.text()}`); const invoice = (await res.json()) as Invoice; return { id: invoice.id, status: invoice.status, total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`, due: invoice.due_date, }; } catch (err) { this.contextLogger.error(`billing request failed: ${String(err)}`); this.fail( new PublicMcpError( "The billing service isn't answering right now. Try again in a few minutes, or carry on without the invoice.", "BILLING_UNAVAILABLE", ), ); } } } ``` **Solution file (alternative to the starter):** ```ts get-invoice.tool.ts solution import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { BILLING_API_KEY } from "./secrets"; type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string }; const unavailable = () => new PublicMcpError( "The billing service isn't answering right now. Try again in a few minutes, or carry on without the invoice.", "BILLING_UNAVAILABLE", ); @Tool({ name: "get_invoice", description: "Get an invoice from the billing service: its status, total and due date.", inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") }, annotations: { readOnlyHint: true, openWorldHint: true }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { let res: Response; try { res = await this.fetch(`https://billing.example/invoices/${id}`, { headers: { Authorization: `Bearer ${BILLING_API_KEY}` }, signal: AbortSignal.timeout(2_000), }); } catch (err) { this.contextLogger.error(`billing request failed: ${String(err)}`); this.fail(unavailable()); } if (res.status === 404) { this.fail(new PublicMcpError(`There's no invoice ${id}. Check the id with the user.`, "INVOICE_NOT_FOUND")); } if (!res.ok) { this.contextLogger.error(`billing answered ${res.status}: ${await res.text()}`); this.fail(unavailable()); } const invoice = (await res.json()) as Invoice; return { id: invoice.id, status: invoice.status, total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`, due: invoice.due_date, }; } } ``` ```ts secrets.ts // In a real server, read this from the environment or a secret store. export const BILLING_API_KEY = "bk_test_7Hq2"; ``` ```ts billing.example.ts // Stands in for the billing service at https://billing.example, which the // Playground can't reach from your browser. It answers requests for that host // in-process, and passes everything else to the real fetch. A real server // doesn't need this file. // // INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers. const invoices: Record = { "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." }, "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." }, }; /** Every request the billing service received, oldest first. */ export const received: { path: string; headers: Record }[] = []; async function billing(request: Request): Promise { const path = new URL(request.url).pathname; received.push({ path, headers: Object.fromEntries(request.headers) }); switch (path) { case "/invoices/INV-6": return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } }); case "/invoices/INV-7": return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 }); case "/invoices/INV-8": // a connection that stays open and never answers return new Promise((_, reject) => { const open = setInterval(() => {}, 1_000); request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason))); }); } const invoice = invoices[path.replace("/invoices/", "")]; return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost"); return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init); }; ``` **Hidden fixture/test file (runs with the example):** ```ts not-found.test.ts hidden import { test, expect } from "@frontmcp/testing"; test("a missing invoice fails with `INVOICE_NOT_FOUND`", async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id: "INV-9" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("INVOICE_NOT_FOUND"); }); test("an outage is still `BILLING_UNAVAILABLE`", async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id: "INV-7" }); expect(result.raw._meta?.code).toBe("BILLING_UNAVAILABLE"); }); test("a real invoice still comes back", async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id: "INV-1" }); expect(result.json()).toEqual({ id: "INV-1", status: "paid", total: "120.00 EUR", due: "2026-09-01" }); }); ``` **Hint:** Call it with `INV-9` and open the Logs tab. The log says only `billing request failed: Error`. What did the `catch` catch? **Solution:** `this.fail()` throws to end the call, so inside the `try` it was caught and replaced. The fix shrinks the `try` to the one line that can fail to reach billing, and checks the response after it, where `this.fail()` goes straight to the client. If you do need a wide `try`, rethrow what you didn't expect rather than turning everything into one error. ### Challenge: Keep up with a new status This `get_invoice` returns billing's JSON as it is, and relies on `outputSchema` to drop the fields the model shouldn't see. It works for `INV-1`. Call it with `INV-2`, then fix it so that every invoice comes back with exactly the four fields in the schema, and `status` may also be `"overdue"`. Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-2" } } ``` ```ts get-invoice.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_invoice", description: "Get an invoice from the billing service: its status and total.", inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") }, outputSchema: z.object({ id: z.string(), status: z.enum(["paid", "open"]), total_cents: z.number(), currency: z.string(), }), }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { const res = await this.fetch(`https://billing.example/invoices/${id}`); return await res.json(); // outputSchema drops the rest } } ``` **Solution file (alternative to the starter):** ```ts get-invoice.tool.ts solution import { Tool, ToolContext, z } from "@frontmcp/sdk"; type Invoice = { id: string; status: "paid" | "open" | "overdue"; total_cents: number; currency: string }; @Tool({ name: "get_invoice", description: "Get an invoice from the billing service: its status and total.", inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") }, outputSchema: z.object({ id: z.string(), status: z.enum(["paid", "open", "overdue"]), total_cents: z.number(), currency: z.string(), }), }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { const res = await this.fetch(`https://billing.example/invoices/${id}`); const invoice = (await res.json()) as Invoice; return { id: invoice.id, status: invoice.status, total_cents: invoice.total_cents, currency: invoice.currency }; } } ``` ```ts billing.example.ts // Stands in for the billing service at https://billing.example, which the // Playground can't reach from your browser. It answers requests for that host // in-process, and passes everything else to the real fetch. A real server // doesn't need this file. // // INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers. const invoices: Record = { "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." }, "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." }, }; /** Every request the billing service received, oldest first. */ export const received: { path: string; headers: Record }[] = []; async function billing(request: Request): Promise { const path = new URL(request.url).pathname; received.push({ path, headers: Object.fromEntries(request.headers) }); switch (path) { case "/invoices/INV-6": return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } }); case "/invoices/INV-7": return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 }); case "/invoices/INV-8": // a connection that stays open and never answers return new Promise((_, reject) => { const open = setInterval(() => {}, 1_000); request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason))); }); } const invoice = invoices[path.replace("/invoices/", "")]; return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost"); return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init); }; ``` **Hidden fixture/test file (runs with the example):** ```ts fields.test.ts hidden import { test, expect } from "@frontmcp/testing"; for (const id of ["INV-1", "INV-2"]) { test(`${id} comes back with only \`id\`, \`status\`, \`total_cents\` and \`currency\``, async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id }); expect(Object.keys(result.raw.structuredContent ?? {}).sort()).toEqual(["currency", "id", "status", "total_cents"]); expect(result.text()).not.toContain("tok_live"); }); } test("`status` can be \"overdue\"", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "get_invoice"); expect(tool?.outputSchema?.properties?.status?.enum).toContain("overdue"); }); ``` **Hint:** Read the error `INV-2` gets, then look at its `status` in `billing.example.ts`. FrontMCP checks every result against `outputSchema` before sending it. **Solution:** Billing added an `overdue` status, `INV-2` stopped matching the schema, and FrontMCP refused to send it: the call failed with `INVALID_OUTPUT`, which in production reads "Output validation failed. Please contact support.", nothing the model can act on. Adding the status to the enum fixes this one. Building the result field by field in `execute()` means the model gets the fields you chose, whatever billing adds next, and the schema only has to describe your own result. Services change their responses without asking you. [Shaping Tool Results](https://frontmcp.dev/learn/shaping-tool-results) covers `outputSchema` in more depth. --- # Running FrontMCP Anywhere > The entry points under @FrontMcp's built-in server. createFetchHandler() serves MCP from Workers and other web-standard runtimes, and connect() and createDirect() call your tools in-process. Source: https://frontmcp.dev/learn/running-frontmcp-anywhere [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 So far, `@FrontMcp` and the `frontmcp` CLI have decided how your server runs: `frontmcp dev` starts an HTTP server, and `frontmcp build` packages the server for a target like Node or Cloudflare Workers. Underneath are a few functions you can call yourself, for when you need to own a part FrontMCP usually owns: the HTTP layer, or the client. Most servers never need them. **You will learn** - What `@FrontMcp` does for you, and which entry points sit under it - How to serve MCP from any runtime that speaks `Request` and `Response` - How to call your tools in-process with `connect()` and `createDirect()` - What changes when you skip the transport: identity, errors and formatting ## What `@FrontMcp` does for you `@FrontMcp({ info, apps })` records your server's configuration and, on Node, starts an HTTP server as soon as the decorated class is defined. The CLI's serverless builds switch that off and wrap the same configuration in the entry point their target needs. When you need something else, these are the pieces to build from: | You want to | Use | You get | | --- | --- | --- | | Serve MCP over HTTP from Node | `@FrontMcp`, with `frontmcp dev` and `frontmcp build` | A running server | | Serve MCP from a runtime that hands you a web `Request`: Cloudflare Workers, Deno, Bun, or a route in another framework | [`FrontMcpInstance.createFetchHandler(config)`](https://frontmcp.dev/reference/sdk/create-fetch-handler) | `(request: Request) => Promise` | | Call your tools from your own code, through an MCP client | [`connect(config, options)`](https://frontmcp.dev/reference/sdk/connect), or `connectClaude()`, `connectOpenAI()`, `connectLangChain()`, `connectVercelAI()` | A `DirectClient` | | Call your tools from your own code, without a client | [`FrontMcpInstance.createDirect(config)`](https://frontmcp.dev/reference/sdk/create), or `create()` for a config without apps | A `DirectMcpServer` | `FrontMcpInstance.runStdio()` and `runUnixSocket()` serve local clients over stdio or a Unix socket. They need Node and aren't covered here. All of them take the configuration you'd give `@FrontMcp`. Keep it in its own module, and decorate a class with it only in the file the CLI runs, because importing a decorated class starts a server: ```ts config.ts import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], http: { entryPath: "/mcp" }, }; ``` ```ts main.ts import "reflect-metadata"; import { FrontMcp } from "@frontmcp/sdk"; import { config } from "./config"; @FrontMcp(config) export default class HelpDeskServer {} ``` A worker, a script or an agent imports `config.ts` and never touches `main.ts`. ## Serving MCP from any runtime `FrontMcpInstance.createFetchHandler(config)` returns a function that takes a web `Request` and returns a `Response`, and knows nothing else about where it runs. This is a complete Cloudflare Worker, and the test sends it the same requests Cloudflare would: ```ts worker.ts active import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; const handler = FrontMcpInstance.createFetchHandler(config); export default { async fetch(request: Request): Promise { return (await handler)(request); }, }; ``` ```ts worker.test.ts import { test, expect } from "@frontmcp/testing"; import worker from "./worker"; // What an MCP client sends under 2026-07-28: the method, and for calls the // tool's name, repeated in headers, and the protocol version in two places. function mcp(method: string, params: Record = {}) { return new Request("https://desk.example.com/mcp", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(method === "tools/call" ? { "mcp-name": String(params.name) } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); } test("lists tools at /mcp", async () => { const body = await (await worker.fetch(mcp("tools/list"))).json(); expect(body.result.tools.map((t: { name: string }) => t.name)).toEqual(["get_ticket"]); }); test("calls a tool", async () => { const body = await (await worker.fetch(mcp("tools/call", { name: "get_ticket", arguments: { id: "T-1" } }))).json(); expect(body.result.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" }); }); test("answers health checks on /healthz", async () => { const res = await worker.fetch(new Request("https://desk.example.com/healthz")); expect(await res.json()).toMatchObject({ status: "ok", transport: "web-fetch" }); }); test("answers 404 anywhere else", async () => { const res = await worker.fetch(new Request("https://desk.example.com/", { method: "POST", body: "{}" })); expect(res.status).toBe(404); }); ``` ```ts config.ts import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], http: { entryPath: "/mcp" }, }; ``` ```ts help-desk.app.ts import { App, 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: "open" }, ]; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", 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}.`, "TICKET_NOT_FOUND")); return ticket; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` `createFetchHandler()` takes the same configuration as `@FrontMcp`. The handler serves MCP at `http.entryPath`, or at `/` if you don't set one, answers `/healthz` and `/readyz` everywhere, and returns 404 for anything else. It returns a promise of the handler, and builds the server when you call it, so a server that can't start fails there. On an edge isolate like a Worker, which can't build a server while the module loads, it builds on the first request instead: see [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler#returns). This site's Playground runs on the same function. It builds a fetch handler from each example and sends it requests from a Web Worker in your browser, and the Wire tab shows those requests. In a project, deploy the Worker like any other; FrontMCP's CLI also has a Cloudflare build target that writes this entry file for you. Any other runtime or framework that gives you a `Request` and takes a `Response` back can call `handler(request)` the same way. Behind `createFetchHandler()`, your tools see the request much as they would behind FrontMCP's own server: a `traceparent` header continues the client's trace, and `this.context.metadata` has the user agent and the `x-frontmcp-*` headers. A deep dive in [Reading the Request](https://frontmcp.dev/learn/reading-the-request) tests it. ## Calling your tools through a client: `connect()` Sometimes your own code is the MCP client: an agent loop that calls a model's API directly, a command-line tool, a job. `connect(config, options)` builds the server in your process and connects a client to it in memory. It goes through the same handshake and requests as a client over HTTP, without a network. The `connectClaude()`, `connectOpenAI()`, `connectLangChain()` and `connectVercelAI()` variants also convert tools and results to the shape each API or library expects: ```ts agent.test.ts active import { test, expect } from "@frontmcp/testing"; import { ToolCallError, connectClaude } from "@frontmcp/sdk"; import { config } from "./config"; test("tools come in the shape Claude's Messages API takes", async () => { const client = await connectClaude(config); const tools = await client.listTools(); await client.close(); expect(tools).toEqual([ { name: "get_ticket", description: "Get one support ticket by its id.", input_schema: expect.objectContaining({ type: "object", required: ["id"] }), }, ]); }); test("results come back as content blocks", async () => { const client = await connectClaude(config); const result = await client.callTool("get_ticket", { id: "T-1" }); await client.close(); expect(result).toEqual([{ type: "text", text: '{"id":"T-1","title":"Cannot log in","status":"open"}' }]); }); test("a failed call rejects with a ToolCallError", async () => { const client = await connectClaude(config); const error = await client.callTool("get_ticket", { id: "T-9" }).catch((err: unknown) => err); await client.close(); expect(error).toBeInstanceOf(ToolCallError); expect((error as ToolCallError).message).toBe("There's no ticket T-9."); expect((error as ToolCallError).result.content).toEqual([{ type: "text", text: "There's no ticket T-9." }]); }); ``` ```ts config.ts import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], }; ``` ```ts help-desk.app.ts import { App, 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: "open" }, ]; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", 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}.`, "TICKET_NOT_FOUND")); return ticket; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` `listTools()` returns tools with `input_schema`, ready to pass to Claude, and `callTool()` returns the result's content blocks, ready for a `tool_result`. Claude's format has no place for MCP's `isError`, so a failed call doesn't return at all: it rejects with a `ToolCallError`, whose `message` is the tool's error text and whose `result` is the full MCP result. An agent that doesn't catch it stops at the first failed call, when the model could have tried another id. The second challenge catches it and sends the failure back as a `tool_result` with `is_error: true`. Plain `connect()` returns the full MCP result instead, `isError` included, for code that wants to convert it itself. `connect()` also takes who the client is and who the user is: ```ts const client = await connect(config, { clientInfo: { name: "desk-agent", version: "1.0.0" }, authToken: userToken, session: { user: { sub: "nour" }, scopes: ["tickets:write"] }, }); ``` `clientInfo` becomes [`this.clientInfo`](https://frontmcp.dev/learn/reading-the-request) in your tools, `session.user` becomes the caller in `this.auth`, `session.scopes` the caller's scopes, so `this.auth.hasScope("tickets:write")` is `true` (since 1.9.3), and `authToken` becomes the request's token, which [`this.fetch()` keeps to itself](https://frontmcp.dev/learn/calling-other-services#sending-your-own-credentials). (`connect()` leaves `this.context.authInfo.clientId` empty, so read the caller from `this.auth`, as this course's tools do.) Nothing checks the user, the scopes or the token: in-process, your code is the authentication, so only pass a user your own code has verified. ## Calling your tools without a client: `createDirect()` A script or a job often doesn't need a client at all, just a way to run a tool. `FrontMcpInstance.createDirect(config)` returns a `DirectMcpServer` with `listTools()`, `callTool()`, `listResources()`, `readResource()`, `listPrompts()`, `getPrompt()` and `dispose()`. `create()` does the same from a flat configuration with `tools` and `providers` at the top level and no `@App`, which is what the tests in [Reading the Request](https://frontmcp.dev/learn/reading-the-request) use. Its results are plain MCP results, and each call can say who the user is with `authContext`: `authContext.user` stands in for the claims of a verified token, so its `sub` becomes `this.auth.user.sub` and its `roles` become `this.auth.roles`. Here a nightly job closes tickets as the user `nightly-cleanup`: ```ts nightly.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance, connect } from "@frontmcp/sdk"; import { config } from "./config"; const asJob = { authContext: { user: { sub: "nightly-cleanup" } } }; test("a job can call tools as a named user", async () => { const server = await FrontMcpInstance.createDirect(config); const result = await server.callTool("close_ticket", { id: "T-1" }, asJob); await server.dispose(); expect(result.structuredContent).toEqual({ id: "T-1", status: "closed", closed_by: "nightly-cleanup" }); }); test("a failed call throws, instead of returning isError", async () => { const server = await FrontMcpInstance.createDirect(config); await expect(server.callTool("close_ticket", { id: "T-9" }, asJob)).rejects.toThrow("There's no ticket T-9."); await expect(server.callTool("close_ticket", { id: 9 }, asJob)).rejects.toThrow("Invalid tool input"); await server.dispose(); }); test("🚩 without authContext, the caller is `direct`, not anonymous", async () => { const server = await FrontMcpInstance.createDirect(config); const result = await server.callTool("close_ticket", { id: "T-2" }); await server.dispose(); expect(result.structuredContent).toEqual({ id: "T-2", status: "closed", closed_by: "direct" }); }); test("one server, several users: each call runs as the user it names", async () => { const server = await FrontMcpInstance.createDirect(config); const ana = await server.callTool("close_ticket", { id: "T-1" }, { authContext: { user: { sub: "ana" } } }); const bo = await server.callTool("close_ticket", { id: "T-2" }, { authContext: { user: { sub: "bo" } } }); const job = await server.callTool("close_ticket", { id: "T-1" }, asJob); await server.dispose(); expect(ana.structuredContent).toMatchObject({ closed_by: "ana" }); expect(bo.structuredContent).toMatchObject({ closed_by: "bo" }); expect(job.structuredContent).toMatchObject({ closed_by: "nightly-cleanup" }); }); test("connect() takes the user from `session.user`", async () => { const client = await connect(config, { session: { user: { sub: "nightly-cleanup" } } }); const result = await client.callTool("close_ticket", { id: "T-2" }); await client.close(); expect(result).toMatchObject({ structuredContent: { closed_by: "nightly-cleanup" } }); }); ``` ```ts config.ts import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], }; ``` ```ts help-desk.app.ts import { App, 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: "open" }, ]; @Tool({ name: "close_ticket", description: "Close a support ticket. Signed-in agents only.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { if (this.auth.isAnonymous) this.fail(new PublicMcpError("Sign in to close tickets.", "SIGN_IN_REQUIRED")); const ticket = tickets.find((t) => t.id === id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`, "TICKET_NOT_FOUND")); ticket.status = "closed"; return { id, status: ticket.status, closed_by: this.auth.user.sub }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] }) export class HelpDesk {} ``` Two things work differently from a client, and the tests show both: - **Failures are exceptions.** Over MCP, a tool that fails returns a result with `isError: true`. `callTool()` here rejects instead, with the `PublicMcpError` the tool failed with, `code` and all, or an `InvalidInputError` for bad arguments. Wrap calls in `try` when one failure shouldn't stop the job. - **No `authContext` isn't anonymous.** Without it, the caller is a signed-in user called `direct`: `this.auth.user.sub` is `"direct"` and `isAnonymous` is `false`, so a tool's check for anonymous callers lets the call through. Always name the user, even for jobs. One server can serve many users. Each call runs as the user its `authContext` names, and gets a `this.context` of its own, with its own `requestId` and `authInfo`. The fourth test closes tickets as Ana, Bo and the job on one server, and each ticket records the right user. A server you build this way inside a Cloudflare Worker, in a Durable Object or a queue consumer, gets no request from a fetch handler, so it doesn't see the Worker's `env` by itself. Pass it as `workerEnv`, in `createDirect()`'s configuration, on one call, or in `connect()`'s options, and your tools read it as `this.workerEnv` (since 1.9.4). [Passing a Worker's bindings](https://frontmcp.dev/reference/sdk/create#passing-a-workers-bindings) shows it. ## Recap - `@FrontMcp` records your configuration and starts a server on import. Keep the configuration in its own module, so other entry points can use it without starting one. - `FrontMcpInstance.createFetchHandler(config)` turns a server into `(request) => Promise`, for Workers and any other web-standard runtime. It serves `http.entryPath`, `/healthz` and `/readyz`. - `connect()` connects an MCP client in memory. The platform variants convert tools and results for an LLM API, and reject a failed call with a `ToolCallError` that carries the result. - `createDirect()` and `create()` run tools with no client: results are MCP results, and failures are thrown. - In-process callers say who the user is and nothing checks it. Pass only users your code has verified, and always pass one. ## Try some challenges ### Challenge: Serve MCP at /mcp The help desk's MCP clients are set up with `https://desk.example.com/mcp`, and this worker answers every one of their requests with a 404. Open the Tests tab, then fix the worker so that MCP requests to `/mcp` work. ```ts worker.ts active import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; const handler = FrontMcpInstance.createFetchHandler(config); export default { async fetch(request: Request): Promise { return (await handler)(request); }, }; ``` ```ts config.ts import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], }; ``` **Solution file (alternative to the starter):** ```ts config.ts solution import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], http: { entryPath: "/mcp" }, }; ``` ```ts help-desk.app.ts import { App, 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: "open" }, ]; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", 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}.`, "TICKET_NOT_FOUND")); return ticket; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` ```ts worker.test.ts import { test, expect } from "@frontmcp/testing"; import worker from "./worker"; function listTools() { return worker.fetch( new Request("https://desk.example.com/mcp", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }), }), ); } test("`tools/list` at `/mcp` answers 200", async () => { const res = await listTools(); expect({ status: res.status, body: await res.text() }).toMatchObject({ status: 200 }); }); test("it lists `get_ticket`", async () => { const body = await (await listTools()).json(); expect(body.result?.tools).toContainTool("get_ticket"); }); test("health checks still answer at `/healthz`", async () => { const res = await worker.fetch(new Request("https://desk.example.com/healthz")); expect(res.status).toBe(200); }); ``` **Hint:** The handler serves MCP at one path and answers 404 everywhere else. Where is that path when the configuration doesn't name one? **Solution:** Without `http.entryPath`, the handler serves MCP at `/`, so a request to `/mcp` got the same 404 as any other unknown path. `http: { entryPath: "/mcp" }` moves the MCP endpoint there, and `/healthz` and `/readyz` keep answering where they were. The fix belongs in the configuration rather than the worker, so every entry point that uses `config.ts` agrees on the path. ### Challenge: Keep failures visible to Claude `toolResult()` in `agent.ts` runs a tool for an agent that talks to Claude's Messages API, and returns the `tool_result` block to send back. When the tool fails, `connectClaude()`'s `callTool()` rejects, and so does `toolResult()`, so the agent never tells the model. Return a `tool_result` for a failed call too, with `is_error: true` and the tool's content blocks. ```ts agent.ts active import { connectClaude } from "@frontmcp/sdk"; import { config } from "./config"; export async function toolResult(toolUseId: string, name: string, input: Record) { const client = await connectClaude(config); try { const content = await client.callTool(name, input); return { type: "tool_result", tool_use_id: toolUseId, content, is_error: false }; } finally { await client.close(); } } ``` **Solution file (alternative to the starter):** ```ts agent.ts solution import { ToolCallError, connectClaude } from "@frontmcp/sdk"; import { config } from "./config"; export async function toolResult(toolUseId: string, name: string, input: Record) { const client = await connectClaude(config); try { const content = await client.callTool(name, input); return { type: "tool_result", tool_use_id: toolUseId, content, is_error: false }; } catch (err) { if (!(err instanceof ToolCallError)) throw err; return { type: "tool_result", tool_use_id: toolUseId, content: err.result.content, is_error: true }; } finally { await client.close(); } } ``` ```ts config.ts import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], }; ``` ```ts help-desk.app.ts import { App, 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: "open" }, ]; @Tool({ name: "get_ticket", description: "Get one support ticket by its id.", 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}.`, "TICKET_NOT_FOUND")); return ticket; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` **Hidden fixture/test file (runs with the example):** ```ts agent.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { toolResult } from "./agent"; test("a failed call has `is_error: true`", async () => { expect(await toolResult("toolu_1", "get_ticket", { id: "T-9" })).toEqual({ type: "tool_result", tool_use_id: "toolu_1", content: [{ type: "text", text: "There's no ticket T-9." }], is_error: true, }); }); test("a good call has `is_error: false` and the same content blocks", async () => { expect(await toolResult("toolu_2", "get_ticket", { id: "T-1" })).toEqual({ type: "tool_result", tool_use_id: "toolu_2", content: [{ type: "text", text: '{"id":"T-1","title":"Cannot log in","status":"open"}' }], is_error: false, }); }); ``` **Hint:** Wrap the call in `try`. What does the error `callTool()` rejects with carry? **Solution:** A failed call rejects with a `ToolCallError`, and its `result` is the MCP result the tool sent, content blocks and all. Those blocks are text blocks Claude accepts, so the `catch` sends them as they are, with `is_error: true`. The model reads "There's no ticket T-9." as a failure and can try another id. Any other error is rethrown: it isn't the tool's answer, so it shouldn't reach the model as one. Plain `connect()` works too: it returns the failure as a result with `isError`, which you'd copy into `is_error`. ### Challenge: Don't let one failure stop the job `closeAll()` in `nightly.ts` closes a list of tickets for the nightly job. One id in tonight's list doesn't exist, and the whole job stops there. Make it close every ticket it can, and report the ones it couldn't with their error `code`. Call the tools as `nightly-cleanup` while you're at it: right now the job runs as `direct`. ```ts nightly.ts active import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; export async function closeAll(ids: string[]) { const server = await FrontMcpInstance.createDirect(config); const closed: unknown[] = []; for (const id of ids) { const result = await server.callTool("close_ticket", { id }); closed.push(result.structuredContent); } await server.dispose(); return { closed, failed: [] as { id: string; code: string }[] }; } ``` **Solution file (alternative to the starter):** ```ts nightly.ts solution import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; export async function closeAll(ids: string[]) { const server = await FrontMcpInstance.createDirect(config); const closed: unknown[] = []; const failed: { id: string; code: string }[] = []; try { for (const id of ids) { try { const result = await server.callTool("close_ticket", { id }, { authContext: { user: { sub: "nightly-cleanup" } } }); closed.push(result.structuredContent); } catch (err) { failed.push({ id, code: (err as { code?: string }).code ?? "UNKNOWN" }); } } } finally { await server.dispose(); } return { closed, failed }; } ``` ```ts config.ts import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], }; ``` ```ts help-desk.app.ts import { App, 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: "open" }, ]; @Tool({ name: "close_ticket", description: "Close a support ticket. Signed-in agents only.", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { if (this.auth.isAnonymous) this.fail(new PublicMcpError("Sign in to close tickets.", "SIGN_IN_REQUIRED")); const ticket = tickets.find((t) => t.id === id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`, "TICKET_NOT_FOUND")); ticket.status = "closed"; return { id, status: ticket.status, closed_by: this.auth.user.sub }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] }) export class HelpDesk {} ``` **Hidden fixture/test file (runs with the example):** ```ts nightly.test.ts hidden import { test, expect } from "@frontmcp/testing"; import { closeAll } from "./nightly"; test("tickets after a missing one still get closed", async () => { const report = await closeAll(["T-1", "T-9", "T-2"]); expect(report.closed).toHaveLength(2); }); test("the missing ticket is reported with its code", async () => { const report = await closeAll(["T-1", "T-9", "T-2"]); expect(report.failed).toEqual([{ id: "T-9", code: "TICKET_NOT_FOUND" }]); }); test("tickets are closed by `nightly-cleanup`", async () => { const report = await closeAll(["T-1"]); expect(report.closed).toEqual([{ id: "T-1", status: "closed", closed_by: "nightly-cleanup" }]); }); ``` **Hint:** `callTool()` rejects when a tool fails, and the error it rejects with has the tool's `code`. Pass the user as the third argument: `{ authContext: { user: { sub: "nightly-cleanup" } } }`. **Solution:** Each call gets its own `try`, so a failure is recorded and the loop moves on. The error is the `PublicMcpError` the tool failed with, so its `code` says why. The outer `try` makes sure the server is disposed even if something unexpected escapes. And the `authContext` puts the job's name on every ticket it closes, instead of the `direct` user FrontMCP fills in when you don't name one. --- # FrontMCP API reference > The FrontMCP API reference for TypeScript MCP servers, from decorators and contexts to auth, plugins, testing and deployment, checked against the SDK it describes, with a runnable example for everything that can run in a browser. Source: https://frontmcp.dev/reference [Covers FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) as of October 26, 2026 Reference is the complete documentation for FrontMCP: every decorator, option and API, what it does, and what to do when it doesn't work. Each page is checked against the FrontMCP version this site documents, and most examples run right on the page. To learn FrontMCP from the beginning, start with the [Quick Start](https://frontmcp.dev/learn); to see how it differs from the official MCP SDK and other TypeScript frameworks, read [Compare](https://frontmcp.dev/compare). Each page follows the same structure: - **Reference**: the signature, every option, what it returns, and caveats. - **Usage**: common tasks, each with a runnable example. - **Troubleshooting**: problems you might hit, titled by what you'll see. ## @frontmcp/sdk Every page of the SDK reference, grouped, with which page answers which task, is in the [SDK overview](https://frontmcp.dev/reference/sdk). ### Decorators Decorators declare what your server exposes. | Decorator | Declares | | --- | --- | | [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp) | The server: its apps and server-wide settings | | [`@App`](https://frontmcp.dev/reference/sdk/app) | A group of tools, resources, prompts and providers | | [`@Tool`](https://frontmcp.dev/reference/sdk/tool) | A function the model can call | | [`@Resource`](https://frontmcp.dev/reference/sdk/resource) | Data at a fixed URI | | [`@ResourceTemplate`](https://frontmcp.dev/reference/sdk/resource-template) | Data at a family of URIs, like `ticket://{id}` | | [`@Prompt`](https://frontmcp.dev/reference/sdk/prompt) | A reusable message template | | [`@Provider`](https://frontmcp.dev/reference/sdk/provider) | A service tools can share, via dependency injection | | [`@Job`](https://frontmcp.dev/reference/sdk/job) | A unit of work that can run in the background, with retries and a run record | | [`@Workflow`](https://frontmcp.dev/reference/sdk/workflow) | Jobs run as steps, in dependency order | | [`@Agent`](https://frontmcp.dev/reference/sdk/agent) | A tool that runs its own model loop, with its own tools and instructions | | [`@Skill`](https://frontmcp.dev/reference/sdk/skill) | Instructions a client's model can find and follow, served as resources | | [`@Plugin`](https://frontmcp.dev/reference/sdk/plugin) | A reusable package of providers, hooks and context extensions | | [`@Channel`](https://frontmcp.dev/reference/sdk/channel) | Events a server pushes to clients | | [Hook decorators](https://frontmcp.dev/reference/sdk/hooks) | Code that runs before, after or around a stage of a call | ### Context Inside `execute()`, `this` is the call's context. [Context classes](https://frontmcp.dev/reference/sdk/contexts) lists what each kind of context has; the most used members have their own pages: | Member | Does | | --- | --- | | [`this.auth`](https://frontmcp.dev/reference/sdk/auth) | Who is calling: user, scopes and roles | | [`this.context`](https://frontmcp.dev/reference/sdk/context) | The request: its id, trace, auth info and headers | | [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit) | Asks the user for input in the middle of a call | | [`this.progress()`](https://frontmcp.dev/reference/sdk/progress) | Reports how far a long call has got | | [`this.notify()`](https://frontmcp.dev/reference/sdk/notify) | Sends a log message to the client | | [`this.get()`](https://frontmcp.dev/reference/sdk/get) | Gets a provider (see [`@Provider`](https://frontmcp.dev/reference/sdk/provider)) | | [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch) | Makes an outbound HTTP request | | [`this.fail()`](https://frontmcp.dev/reference/sdk/fail) | Ends the call with an error the model can read | | [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool) | Calls another tool on the server | | [`this.respond()`](https://frontmcp.dev/reference/sdk/respond) | Ends the call early with a finished result | ### Running a server | API | Does | | --- | --- | | [`FrontMcpInstance`](https://frontmcp.dev/reference/sdk/frontmcp-instance) | Starts the HTTP server a decorated class describes | | [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) | A web-standard `Request → Response` handler, for Workers, Deno, Bun and frameworks | | [`create()` and `createDirect()`](https://frontmcp.dev/reference/sdk/create) | A server in your own process, called without HTTP | | [`connect()` and client adapters](https://frontmcp.dev/reference/sdk/connect) | The server's tools, ready for Claude, OpenAI, LangChain or the Vercel AI SDK | ### Limits, errors and the scope - [Guard options](https://frontmcp.dev/reference/sdk/guard): rate limits, concurrency caps, timeouts and IP filters. - [Error classes](https://frontmcp.dev/reference/sdk/error-classes) and [error codes](https://frontmcp.dev/reference/errors): what to throw, and what a client sees. - [Scope and registries](https://frontmcp.dev/reference/sdk/scope): the server's registries, from inside a call. ## Server and configuration How a server is put together and configured, across decorators: | Page | Covers | | --- | --- | | [Server overview](https://frontmcp.dev/reference/server) | Every page in this section, grouped, and which page answers which task | | [Apps, discovery and splitting](https://frontmcp.dev/reference/server/apps) | Composing a server from apps, what clients discover, and serving apps separately | | [Flows and stages](https://frontmcp.dev/reference/server/flows) | Every flow FrontMCP runs for a request, and the stages hooks attach to | | [Loading apps from npm (ESM)](https://frontmcp.dev/reference/server/esm) | `App.esm()`: apps loaded from a registry at run time | | [Remote servers](https://frontmcp.dev/reference/server/remote) | Another MCP server mounted as an app | | [Configuration files](https://frontmcp.dev/reference/server/config-files) | Config files and every environment variable FrontMCP reads | | [Logging](https://frontmcp.dev/reference/server/logging) | The server's own logs, levels and transports | | [Observability and telemetry](https://frontmcp.dev/reference/server/observability) | Tracing, metrics and health endpoints | | [Environment awareness](https://frontmcp.dev/reference/server/environment) | Capabilities that depend on the runtime and environment | ## Authentication How a server authenticates MCP clients, and decides what each caller may do: | Page | Covers | | --- | --- | | [Authentication overview](https://frontmcp.dev/reference/auth) | Every page in this section, grouped, and which page answers which task | | [Auth modes](https://frontmcp.dev/reference/auth/modes) | Every mode, what it protects, and how to choose | | [Tokens and sessions](https://frontmcp.dev/reference/auth/tokens) | How tokens are verified, sessions, and the secrets behind them | | [Local auth](https://frontmcp.dev/reference/auth/local) | FrontMCP as its own OAuth authorization server | | [Custom login UI](https://frontmcp.dev/reference/auth/login-ui) | The login and consent pages, and replacing them | | [Progressive auth](https://frontmcp.dev/reference/auth/progressive) | Asking for more access later, when a tool needs it | | [Remote and proxied auth](https://frontmcp.dev/reference/auth/remote) | An external identity provider as the authorization server | | [Client ID metadata (CIMD)](https://frontmcp.dev/reference/auth/cimd) | Clients identified by a metadata document instead of registration | | [Authorities](https://frontmcp.dev/reference/auth/authorities) | Roles, permissions and rules on tools, resources, prompts, agents and skills | | [Auth in production](https://frontmcp.dev/reference/auth/production) | The checklist for running an authenticated server | ## Plugins and adapters Official packages that add behaviour to a server, or turn an API into tools: | Page | Covers | | --- | --- | | [Plugins overview](https://frontmcp.dev/reference/plugins) | The official plugins and adapters, their packages, and how to add them to a server | | [Cache](https://frontmcp.dev/reference/plugins/cache) | Reusing a tool's result for identical calls, per caller or shared | | [Remember](https://frontmcp.dev/reference/plugins/remember) | Memory tools can read and write across calls, per session, user or tool | | [Approval](https://frontmcp.dev/reference/plugins/approval) | Requiring the user's approval before a tool runs | | [Feature flags](https://frontmcp.dev/reference/plugins/feature-flags) | Turning tools, resources and prompts on and off with flags | | [CodeCall](https://frontmcp.dev/reference/plugins/codecall) | Letting the model search many tools and call them from a short script | | [Skilled OpenAPI](https://frontmcp.dev/reference/plugins/skilled-openapi) | Serving a REST API as a few skills instead of one tool per endpoint | | [OpenAPI adapter](https://frontmcp.dev/reference/adapters/openapi) | Every operation in an OpenAPI spec as a tool | ## Tool UI and React Widgets a tool shows in the host, and React apps that talk to a server: | Page | Covers | | --- | --- | | [Tool UI](https://frontmcp.dev/reference/ui) | A tool's `ui` option, the `ui://widget` resource, serving modes and the widget's Content Security Policy | | [Widget components](https://frontmcp.dev/reference/ui/components) | Widgets written as React files, `@frontmcp/ui` hooks and components, and `@frontmcp/uipack` | | [Hosts and platforms](https://frontmcp.dev/reference/ui/hosts) | How the client is recognized, the widget's bridge, MCP Apps hosts and the OpenAI Apps SDK | | [@frontmcp/react](https://frontmcp.dev/reference/react) | A server in the same process as a React app: the provider, its client, several servers, stores | | [React hooks](https://frontmcp.dev/reference/react/hooks) | Calling tools, reading resources and prompts, and registering tools from components | | [React components](https://frontmcp.dev/reference/react/components) | Generated forms, result viewers, `mcpComponent`, DOM resources and the router bridge | ## @frontmcp/testing End-to-end tests that start a server and call it like a client: | Page | Covers | | --- | --- | | [`test` and the `mcp` client](https://frontmcp.dev/reference/testing) | Writing tests, the client, and the result wrappers | | [Matchers](https://frontmcp.dev/reference/testing/matchers) | The MCP-specific `expect` matchers | | [Fixtures](https://frontmcp.dev/reference/testing/fixtures) | `test.use()`, and the `mcp`, `server` and `auth` fixtures and their lifetimes | | [Interceptors and HTTP mocking](https://frontmcp.dev/reference/testing/interceptors) | `mcp.mock`, `mcp.intercept`, `MockAPIServer` and `httpMock` | | [Testing authentication](https://frontmcp.dev/reference/testing/auth) | Tokens, clients that call as a user, and `MockOAuthServer` | ## CLI and Nx Creating, running and building servers, alone or in an Nx workspace: | Page | Covers | | --- | --- | | [frontmcp CLI](https://frontmcp.dev/reference/cli) | Every command and option: create, dev, build and its targets, test, inspector and more | | [Nx plugin](https://frontmcp.dev/reference/nx) | What `@frontmcp/nx` adds, installing it, and the workspace layout | | [Nx generators](https://frontmcp.dev/reference/nx/generators) | Every generator, its options, and what it writes | | [Nx executors](https://frontmcp.dev/reference/nx/executors) | Every executor, its options, and the command it runs | | [Monorepo patterns](https://frontmcp.dev/reference/nx/monorepo) | Apps, shared libraries and server shells, and composing apps into one server | ## Deployment Building a server and running it in production: | Page | Covers | | --- | --- | | [Deployment overview](https://frontmcp.dev/reference/deployment) | Every page in this section, grouped, and which page answers which task | | [Production build](https://frontmcp.dev/reference/deployment/production-build) | `frontmcp build`, what each target writes and bundles, and how the result runs | | [Node.js and Docker](https://frontmcp.dev/reference/deployment/node) | The node build on a machine, in Docker, with Redis in Compose, behind nginx, or on a Unix socket | | [Vercel](https://frontmcp.dev/reference/deployment/vercel) | The Vercel target, what the function serves, and Vercel KV | | [AWS Lambda](https://frontmcp.dev/reference/deployment/aws-lambda) | The Lambda handler, what ships beside it, and the events it accepts | | [Cloudflare Workers](https://frontmcp.dev/reference/deployment/cloudflare-workers) | The Worker entry, `wrangler.toml`, secrets and bindings | | [Redis](https://frontmcp.dev/reference/deployment/redis) | The `redis` option, what's kept under which keys, and what happens when Redis is down | | [SQLite](https://frontmcp.dev/reference/deployment/sqlite) | Sessions, tasks and pending questions in a SQLite file on one machine | | [High availability](https://frontmcp.dev/reference/deployment/high-availability) | Several instances behind a load balancer, and what they must share | | [Health checks and metrics](https://frontmcp.dev/reference/deployment/health-and-metrics) | `/healthz`, `/readyz` and `/metrics` on each runtime, and wiring them to probes | | [Security headers and transport](https://frontmcp.dev/reference/deployment/security) | Headers, bind address and host checks, body limits, and exposed endpoints | | [Connecting MCP clients](https://frontmcp.dev/reference/deployment/mcp-clients) | Clients over Streamable HTTP, stdio or a Unix socket, and MCP Bundles | ## Guides - [Rules of FrontMCP](https://frontmcp.dev/reference/rules): the short list of rules that keep servers correct and safe. - [Security](https://frontmcp.dev/reference/security): how to report a vulnerability, the defaults a new server starts with, and what FrontMCP does about each risk in the OWASP MCP Top 10. --- # Maintainers and corrections > FrontMCP's publisher, source, license, article authors and public documentation correction route. Source: https://frontmcp.dev/reference/maintainers [Covers FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) as of October 26, 2026 FrontMCP is published by AgentFront. Its [framework source](https://github.com/agentfront/frontmcp), release history and issue tracker are public. The software uses the Apache 2.0 license. This site documents the framework; hosting, model calls and external services have their own costs and terms. ## David Antoon David Antoon is the named author of the site's [implementation notes and guides](https://frontmcp.dev/blog), and is [frontegg-david](https://github.com/frontegg-david) on GitHub. Each article preserves its original publication date. A visible update date records a substantive revision; a stated example version identifies its scope. Runnable Playground checks and editorial review are described separately on [Versions](https://frontmcp.dev/versions#how-pages-are-checked). ## Report a documentation issue Use the “Report a docs issue” link below a page. It opens the framework's public GitHub issue form with only the page URL and documented release prefilled. Describe the unclear instruction, expected behavior and a minimal sanitized reproduction. Do not include credentials, private source or customer records. For a potential vulnerability, use the [private security reporting channel](https://frontmcp.dev/reference/security) instead of a public issue. Product plans are not release commitments; check the maintained reference and releases for available capabilities. --- # Site usage and privacy > What runs locally in the FrontMCP Playground, which usage events the site records, and what Global Privacy Control changes. Source: https://frontmcp.dev/reference/site-privacy [Covers FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) as of October 26, 2026 Playground code executes in your browser. Code you add can make network calls permitted by its runtime. Downloading runtime files, searching the docs and using external services also uses the network. The site records limited aggregate usage events to understand which examples and actions work. ## Usage events The page sends event names, page paths, example positions, selected fixed labels, startup/call durations and test counts to this site's `/api/events` endpoint. The receiver also obtains an approximate country from the hosting request metadata. New call-completion events distinguish successful results, tool errors, protocol errors and transport failures; an intentionally refused example can still be correct. Each page load also sends its loading and responsiveness timings (largest contentful paint, interaction to next paint, layout shift, first contentful paint and server response time) with the page's path, measured in your browser and sent to the same endpoint. No third-party analytics script runs on the site. The event schema does not collect code, prompts, tokens, tool arguments, tool results or raw search text. It has no visitor or session ID. Counts and event ratios do not identify unique people or establish a person-level conversion funnel. ## Global Privacy Control When your browser enables Global Privacy Control, the page's usage-event sender does not send its batches. That control does not imply zero requests: server-side assistant-referral classification and browser Content Security Policy reports are separate paths. Referral records use the recognized source and page path, not a copy of the whole referrer URL. CSP reports describe blocked resources; they are diagnostic reports rather than Playground source uploads. ## Sharing examples The Playground's Share/Open links carry example files in the URL fragment. Anyone you send that link to can read those files. Keep credentials and private code out of shared examples. Downloadable projects contain the published fixtures, not your local edits. ## Corrections See [maintainers and corrections](https://frontmcp.dev/reference/maintainers) for documentation feedback and [Security](https://frontmcp.dev/reference/security) for private vulnerability reporting. --- # @frontmcp/sdk reference > The decorators that declare what a FrontMCP server exposes, the context members your code calls inside a tool, resource or prompt, and the entry points that run a server over HTTP, in a Worker or in your own process. Source: https://frontmcp.dev/reference/sdk [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `@frontmcp/sdk` is the package a FrontMCP server is written with. Its pages fall into three groups. **Decorators** declare what the server exposes, from `@FrontMcp` and `@App` down to `@Tool`, `@Resource` and `@Prompt`. **Context members** are what `this` offers inside `execute()`, like `this.auth`, `this.get()` and `this.fail()`. **Entry points** run the same classes over HTTP, in a Worker or another web-standard runtime, or inside your own process. Limits, errors, the scope and the `z` you write schemas with complete the section. This page is for looking things up; [Your First Tool](https://frontmcp.dev/learn/your-first-tool) teaches the same pieces in order. ```ts import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; ``` --- ## Decorators Decorators declare what your server exposes. | Page | Declares | | --- | --- | | [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp) | An MCP server, the apps it hosts, and the settings that apply to all of them | | [`@App`](https://frontmcp.dev/reference/sdk/app) | Related tools, resources, prompts and providers, grouped into an app the server hosts | | [`@Tool`](https://frontmcp.dev/reference/sdk/tool) | A function an AI model can call, with validated input and optional structured output | | [`@Resource`](https://frontmcp.dev/reference/sdk/resource) | Data at a fixed URI that clients can list and read, like a policy document | | [`@ResourceTemplate`](https://frontmcp.dev/reference/sdk/resource-template) | A family of resources whose URIs follow a pattern, like `ticket://{id}` | | [`@Prompt`](https://frontmcp.dev/reference/sdk/prompt) | A reusable message template that users pick in their client and fill in with arguments | | [`@Provider`](https://frontmcp.dev/reference/sdk/provider) | A shared service, like a database client or a cache, that tools, resources and prompts get with `this.get()` | | [`@Job`](https://frontmcp.dev/reference/sdk/job) | A unit of work with validated input that clients run by name, while they wait or in the background, with retries | | [`@Workflow`](https://frontmcp.dev/reference/sdk/workflow) | Jobs chained into steps that run in order or in parallel, each step's input built from the results before it | | [`@Agent`](https://frontmcp.dev/reference/sdk/agent) | A tool that runs its own model loop, with its own instructions and tools, so a client's model can hand it a whole task | | [`@Skill`](https://frontmcp.dev/reference/sdk/skill) | A written procedure, with the tools it uses, that clients find and load for their model to follow | | [`@Plugin`](https://frontmcp.dev/reference/sdk/plugin) | Providers, tools, resources, prompts, skills and hooks, packaged so an app or a whole server turns them on with one line | | [`@Adapter`](https://frontmcp.dev/reference/sdk/adapter) | Tools, resources and prompts built from another source when the server starts, like a spec or a list of saved views, and replaced while it runs | | [`@Channel`](https://frontmcp.dev/reference/sdk/channel) | Events pushed into a connected Claude Code session as they happen | | [Hook decorators](https://frontmcp.dev/reference/sdk/hooks) | `Will`, `Did`, `Around` and `Stage`: code that runs before, after, around or inside a stage of every request | ## Context Inside `execute()`, `this` is the call's context. [Context classes](https://frontmcp.dev/reference/sdk/contexts) lists what `this` is in a tool, resource, prompt, agent, job, channel or skill, and every member each one has. The members used most have pages of their own: | Page | Does | | --- | --- | | [`this.auth`](https://frontmcp.dev/reference/sdk/auth) | Who is calling, with their scopes, roles and claims, and what each auth mode and entry point fills in | | [`this.context`](https://frontmcp.dev/reference/sdk/context) | The request a call belongs to: its id, trace, auth info and HTTP metadata, and a store that lasts for the request | | [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit) | Asks the user for input in the middle of a tool call, and continues with their answer as typed data | | [`this.progress()`](https://frontmcp.dev/reference/sdk/progress) | Tells the client how far a long call has got, when the client asked for progress | | [`this.notify()`](https://frontmcp.dev/reference/sdk/notify) | Sends log messages to the client while a tool runs, at the levels the client asked for | | [`this.get()`](https://frontmcp.dev/reference/sdk/get) | Gets a provider, like a ticket store or your configuration | | [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch) | Calls another HTTP service, with the request's tracing headers and a default timeout | | [`this.fail()`](https://frontmcp.dev/reference/sdk/fail) | Ends a tool call with an error the model can read, carrying the code you choose | | [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool) | Calls another tool of the same server, as the same caller | | [`this.respond()`](https://frontmcp.dev/reference/sdk/respond) | Ends a call early with a result you built yourself, or answers a call from a hook | ## Running a server Importing a class decorated with `@FrontMcp` starts FrontMCP's HTTP server. These run the same configuration in other ways: | Page | Does | | --- | --- | | [`FrontMcpInstance`](https://frontmcp.dev/reference/sdk/frontmcp-instance) | Builds and runs a server from its `@FrontMcp` configuration: over HTTP, stdio or a Unix socket, as a serverless handler, or in-process | | [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) | Serves MCP from any runtime that speaks web `Request` and `Response`, like Cloudflare Workers, Deno, Bun or a framework's route | | [`create()` and `createDirect()`](https://frontmcp.dev/reference/sdk/create) | A server inside your own process, whose tools, resources, prompts and jobs you call as async functions, as a user you name | | [`server.registerTool()`](https://frontmcp.dev/reference/sdk/register-tool) | Adds a tool to a running `create()` or `createDirect()` server from code outside it, and removes it again | | [`connect()` and client adapters](https://frontmcp.dev/reference/sdk/connect) | An MCP client connected in memory, with the server's tools and results in the shape Claude, OpenAI, LangChain or the Vercel AI SDK expects | ## Limits, errors and the scope | Page | Covers | | --- | --- | | [Guard options](https://frontmcp.dev/reference/sdk/guard) | A tool's `rateLimit`, `concurrency` and `timeout`, `@FrontMcp`'s `throttle`, whose calls each limit counts, and the error each one produces | | [Error classes](https://frontmcp.dev/reference/sdk/error-classes) | The SDK's error classes by area, the code a client receives, whether the message survives production, and whether you throw it or FrontMCP does | | [Error codes](https://frontmcp.dev/reference/errors) | What FrontMCP's JSON-RPC error codes and tool error results mean, and how to fix them | | [Scope and registries](https://frontmcp.dev/reference/sdk/scope) | `this.scope`: the registries you can list and search, what you can add at runtime, and which parts are FrontMCP's own machinery | ## `z`, `eagerZ` and `lazyZ` Every example on this site imports `z` from `@frontmcp/sdk`, not from `zod`. It's Zod 4's `z` with one change: `z.object()`, `z.strictObject()`, `z.looseObject()`, `z.union()`, `z.discriminatedUnion()`, `z.intersection()`, `z.record()` and `z.tuple()` return a stand-in, and the real Zod schema is built the first time the schema is used, by a parse or by reading it, like `.shape`. Methods you chain on a stand-in, like `.optional()` and `.describe()`, return stand-ins too. So loading and starting a server doesn't build the objects in its tools' `inputSchema`: they're built when a client first lists the tools or calls one. The other factories, like `z.string()` and `z.enum()`, are Zod's own. | Export | What it is | | --- | --- | | `z` | Zod 4's `z`, with the factories above deferred. Use it for every schema in a FrontMCP server. | | `eagerZ` | Zod's own `z`, the same object `import { z } from "zod"` gives. Its schemas are built when you call it. | | `lazyZ(build)` | A schema that calls `build()` the first time it's used, and only then. For a schema that's costly to make and may never be used, like one converted from a JSON Schema. | | `isLazy(schema)` | `true` for a stand-in, from `z`'s deferred factories or from `lazyZ()`. | | `forceMaterialize(schema)` | The real Zod schema, with every stand-in inside it built. | | `toJSONSchema(schema)` | Zod's JSON Schema converter, run on `forceMaterialize(schema)`, so it takes any `z` schema. | A stand-in behaves like the schema it stands for: it parses, `instanceof ZodObject` is `true`, and `z.infer` gives the same type. The SDK exports Zod's classes, like `ZodObject` and `ZodError`, and its types, so a FrontMCP server needs no import from `zod`. Two things to know: - **Zod's own `toJSONSchema()`** fails on some `z` schemas. `z.toJSONSchema()`, or the function imported from `zod`, throws `TypeError: Cannot set properties of undefined (setting 'ref')` when an object or a union in the schema is `.optional()` or has a `.default()`, like the optional `customer` below. Convert with `toJSONSchema` from `@frontmcp/sdk`, or build that schema with `eagerZ`. - **Schemas from `zod`** work in a tool's `inputSchema` like the SDK's, as long as they're Zod 4: `frontmcp create` adds `zod` `^4.0.0` to a new project. Schemas from Zod 3, `zod@3` or `zod/v3`, aren't recognized: the tool is listed with no properties, and every call fails with `INVALID_INPUT` and `Unknown error occurred when trying to parse input`. Initial call configuration: ```js { tool: "create_ticket", arguments: { title: "Cannot log in", customer: { email: "dana@acme.com" } } } ``` ```ts create-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; export const TicketInput = { title: z.string().min(5).describe("One-line summary of the problem"), customer: z.object({ email: z.string().email() }).optional().describe("Who reported it, if known"), }; @Tool({ name: "create_ticket", description: "Open a new support ticket", inputSchema: TicketInput }) export class CreateTicket extends ToolContext { async execute(input: { title: string; customer?: { email: string } }) { return { id: "T-4", status: "open", ...input }; } } ``` ```ts schemas.test.ts import { test, expect } from "@frontmcp/testing"; import { eagerZ, isLazy, lazyZ, toJSONSchema, z, ZodObject } from "@frontmcp/sdk"; import { TicketInput } from "./create-ticket.tool"; test("`z.object()` is a stand-in that behaves like the schema", () => { const customer = z.object({ email: z.string().email() }); expect(isLazy(customer)).toBe(true); expect(isLazy(z.string())).toBe(false); expect(customer instanceof ZodObject).toBe(true); expect(customer.safeParse({ email: "dana" }).success).toBe(false); }); test("`eagerZ` is Zod's own `z`", () => { expect(isLazy(eagerZ.object({ email: eagerZ.string() }))).toBe(false); }); test("`lazyZ()` builds the schema once, the first time it's used", () => { let builds = 0; const imported = lazyZ(() => { builds++; return eagerZ.object({ id: eagerZ.string() }); }); expect(builds).toBe(0); imported.parse({ id: "T-1" }); imported.parse({ id: "T-2" }); expect(builds).toBe(1); }); test("Zod's own `toJSONSchema()` fails on an optional object; the SDK's doesn't", () => { const schema = z.object(TicketInput); expect(() => z.toJSONSchema(schema)).toThrow("Cannot set properties of undefined (setting 'ref')"); const json = toJSONSchema(schema) as { required: string[] }; expect(json.required).toEqual(["title"]); const eager = eagerZ.object({ customer: eagerZ.object({ email: eagerZ.string() }).optional() }); expect(() => eagerZ.toJSONSchema(eager)).not.toThrow(); }); test("FrontMCP lists the tool's schema either way", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t: { name: string }) => t.name === "create_ticket"); expect(tool?.inputSchema.required).toEqual(["title"]); expect(tool?.inputSchema.properties.customer.properties.email.format).toBe("email"); }); ``` ## Which page for which task | You want to | Read | | --- | --- | | Give the model something to call | [`@Tool`](https://frontmcp.dev/reference/sdk/tool) | | Share a database client or an API client between tools | [`@Provider`](https://frontmcp.dev/reference/sdk/provider) and [`this.get()`](https://frontmcp.dev/reference/sdk/get) | | Refuse a call with a message the model can act on | [`this.fail()`](https://frontmcp.dev/reference/sdk/fail) and [Error classes](https://frontmcp.dev/reference/sdk/error-classes) | | Check who is calling before you act | [`this.auth`](https://frontmcp.dev/reference/sdk/auth) | | Confirm with the user before a tool acts | [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit) | | Run work that takes too long for one tool call | [`@Job`](https://frontmcp.dev/reference/sdk/job) | | Let a model on your server carry out a whole task | [`@Agent`](https://frontmcp.dev/reference/sdk/agent) | | Run code around every call, like logging or auditing | [Hook decorators](https://frontmcp.dev/reference/sdk/hooks), packaged as a [`@Plugin`](https://frontmcp.dev/reference/sdk/plugin) | | Generate tools from a spec, a catalog or a list kept somewhere else | [`@Adapter`](https://frontmcp.dev/reference/sdk/adapter), or the [OpenAPI adapter](https://frontmcp.dev/reference/adapters/openapi) for an OpenAPI spec | | Serve from Cloudflare Workers, Deno or Bun | [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) | | Call your tools from a script or a test, without HTTP | [`create()` and `createDirect()`](https://frontmcp.dev/reference/sdk/create) | | Give your tools to an agent loop built on Claude, OpenAI, LangChain or the Vercel AI SDK | [`connect()` and client adapters](https://frontmcp.dev/reference/sdk/connect) | | Cap how often a tool runs, or how long it may take | [Guard options](https://frontmcp.dev/reference/sdk/guard) | | Find out what an error code means | [Error codes](https://frontmcp.dev/reference/errors) | | Hand a schema to code that calls Zod's own functions | [`z`, `eagerZ` and `lazyZ`](https://frontmcp.dev/reference/sdk#z-eagerz-and-lazyz) | ## The three groups in one server A decorator for each part of the server, context members inside the tool, and the **Tests** tab running the same app through a second entry point, `createDirect()`, in-process and as a named user: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts help-desk.app.ts active import { App, Provider, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Provider({ name: "TicketStore" }) export class TicketStore { private tickets = [ { id: "T-1", title: "Cannot log in", status: "open" }, { id: "T-2", title: "Invoice total is wrong", status: "closed" }, ]; find(id: string) { return this.tickets.find((t) => t.id === id); } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).find(id); // a provider if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`, "NOT_FOUND")); // an error the model reads return { ticket, caller: this.auth.user.sub }; // who is calling } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], providers: [TicketStore] }) export class HelpDeskApp {} ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts help-desk.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; test("the tool gets the store with `this.get()`", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result).toBeSuccessful(); expect(result.json().ticket).toEqual({ id: "T-1", title: "Cannot log in", status: "open" }); }); test("`this.fail()` sends an error the model can read", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-9" }); expect(result).toBeError("NOT_FOUND"); expect(result).toHaveTextContent("There's no ticket T-9."); }); test("over HTTP with no credentials, `this.auth` is an anonymous caller", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result.json().caller).toMatch(/^anon:/); }); test("`createDirect()` runs the same app in-process, as the user you name", async () => { const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); try { const result = await server.callTool("get_ticket", { id: "T-2" }, { authContext: { user: { sub: "agent-7" } } }); expect(result.structuredContent).toMatchObject({ ticket: { id: "T-2", status: "closed" }, caller: "agent-7" }); } finally { await server.dispose(); } }); ``` ## Related pages - [Server configuration](https://frontmcp.dev/reference/server): apps, configuration files, logging, observability and flows, across decorators. - [Authentication and authorization](https://frontmcp.dev/reference/auth): the `auth` and `authorities` options, and what fills in `this.auth`. - [Plugins and adapters](https://frontmcp.dev/reference/plugins): the official plugins and the OpenAPI adapter. - [`@frontmcp/testing`](https://frontmcp.dev/reference/testing): end-to-end tests that call a server like a client. - Learn: [Describing Capabilities](https://frontmcp.dev/learn/describing-capabilities), [Structuring a Server](https://frontmcp.dev/learn/structuring-a-server) and [Running FrontMCP Anywhere](https://frontmcp.dev/learn/running-frontmcp-anywhere). --- # @FrontMcp > Declare an MCP server, the apps it hosts, and the settings that apply to all of them. Source: https://frontmcp.dev/reference/sdk/frontmcp [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `@FrontMcp` declares the server: its name and version, the [apps](https://frontmcp.dev/reference/sdk/app) it hosts, and the settings that apply to all of them, from how it asks users for input to which port it listens on. A project has one, in its entry file, and importing that file starts the server. ```ts @FrontMcp(options) export default class Server {} ``` --- ## Reference ### `@FrontMcp(options)` Apply `@FrontMcp` to an empty class in your server's entry file. ```ts main.ts import "reflect-metadata"; import { FrontMcp, LogLevel } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], http: { port: 3000 }, logging: { level: LogLevel.Info }, }) export default class Server {} ``` [See more examples below.](https://frontmcp.dev/reference/sdk/frontmcp#usage) #### Options Required: | Option | Type | Description | | --- | --- | --- | | `info` | `ServerInfoOptions` | Who the server is. Sent to clients in the `_meta` of its responses. See [`info`](https://frontmcp.dev/reference/sdk/frontmcp#info). | | `apps` | `AppType[]` | The apps to serve: `@App` classes, `app()` results, [`App.remote()` and `App.esm()`](https://frontmcp.dev/reference/sdk/app#apps-from-outside-your-code). | Shared by every app: | Option | Type | Description | | --- | --- | --- | | `providers` | `ProviderType[]` | [Providers](https://frontmcp.dev/reference/sdk/provider) every app can get, as one shared instance. | | `plugins` | `PluginType[]` | Plugins that apply to every app, such as caching or auditing. See [`@Plugin`](https://frontmcp.dev/reference/sdk/plugin). | | `skills` | `SkillType[]` | Skills available to every app. | | `tools`, `resources` | `ToolType[]`, `ResourceType[]` | Tools and resources served with every app's, once. See [Tools every app shares](https://frontmcp.dev/reference/sdk/frontmcp#tools-every-app-shares). (Changed in 1.9.1: before, they were accepted and never registered.) | | `adapters` | `AdapterType[]` | Adapters, like an OpenAPI adapter, whose tools are served with every app's. See [Registering an adapter](https://frontmcp.dev/reference/plugins#registering-an-adapter). (Changed in 1.9: before, `@FrontMcp` dropped the option.) | How the server behaves toward clients. These all work in the Playground: | Option | Type | Description | | --- | --- | --- | | `instructions` | `string` | Tells the model how to use the server as a whole. Sent with `server/discover`, and in `initialize` to older clients, through every entry point. (Changed in 1.9.3: before, `initialize` through `createFetchHandler()` and `connect()` left them out.) See [giving the model instructions](https://frontmcp.dev/reference/sdk/frontmcp#giving-the-model-instructions). | | `elicitation` | `{ enabled?, redis? }` | Lets tools ask the user for input with `this.elicit()`. Off by default. Turning it on also adds a `sendElicitationResult` tool for clients on protocol versions before 2026-07-28 that can't show forms; 2026-07-28 clients never see it. `redis` stores pending questions for servers that run as several processes; it defaults to the top-level `redis`. | | `pagination` | `{ tools?: { mode?, pageSize?, autoThreshold? } }` | How `tools/list` is split into pages. By default (`mode: "auto"`) it pages once there are more than `autoThreshold` tools, `pageSize` per page; both default to 40. `mode: true` always pages, `false` never does. The TypeScript type requires all three fields. | | `output` | `OutputPolicy` | Defaults for every tool's result: `schemaMode` (`"definition"`, the default, `"description"`, `"both"` or `"none"`), `schemaDescriptionFormat` (`"summary"` or `"jsonSchema"`) and `allowNonFinite` (default `false`: a `NaN` or `Infinity` in a result is an error). An app's or tool's `output` overrides it. | | `throttle` | `GuardConfig` | Server-wide rate limits, concurrency caps and timeouts. `enabled` is required: `enabled: false` also turns off every tool's own `rateLimit` and `concurrency`. A tool's own limits work without `throttle`. See [Guard options](https://frontmcp.dev/reference/sdk/guard) and [setting defaults for every tool](https://frontmcp.dev/reference/sdk/frontmcp#setting-defaults-for-every-tool). | | `fetch` | `{ forwardCallerTokenTo?, forwardCustomHeadersTo?, requestTimeout?, autoInjectTracingHeaders? }` | How [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch#server-options) behaves: which origins get the caller's token and `x-frontmcp-*` headers (none by default), the timeout (30 seconds), and whether to send tracing headers. | How the server is deployed. These configure a real process, so the Playground ignores them: | Option | Type | Description | | --- | --- | --- | | `serve` | `boolean` | Defaults to `true`: importing the decorated class starts the server. See [starting the server](https://frontmcp.dev/reference/sdk/frontmcp#starting-the-server). | | `http` | `HttpOptions` | Port, path, CORS and custom routes. See [`http`](https://frontmcp.dev/reference/sdk/frontmcp#http). | | `transport` | `TransportOptions` | Protocols, sessions and where sessions are stored. See [`transport`](https://frontmcp.dev/reference/sdk/frontmcp#transport). | | `session` | `{ sessionMode?, platformDetection? }` | Deprecated: the pre-1.0 option that `transport` replaced, and not read. A `sessionMode` other than `"stateful"` logs the same warning as [`transport.sessionMode`](https://frontmcp.dev/reference/sdk/frontmcp#transport), and a `platformDetection` logs ``session.platformDetection is ignored and will be removed in the next major, with the rest of `session`: move it to `transport.platformDetection`.`` (Changed in 1.9.4: before, it was dropped without a warning.) | | `auth` | `AuthOptions` | Who may connect. Defaults to public. See [`auth`](https://frontmcp.dev/reference/sdk/frontmcp#auth). | | `registry` | `McpRegistryOptionsInput` | Off by default. Serve a domain proof, server card and AI Catalog. See [`registry`](https://frontmcp.dev/reference/sdk/frontmcp#registry) and [MCP Registry](https://frontmcp.dev/reference/deployment/mcp-registry). | | `authorities` | `AuthoritiesConfig` | Named role and attribute rules, and where to find roles in a token, for the `authorities` option of tools, resources, templates, prompts, agents and skills. A server whose entries declare `authorities` doesn't start without it, and neither does one with a rule that checks nothing: see [Troubleshooting](https://frontmcp.dev/reference/sdk/frontmcp#authorities-configuration-required-or-invalid-authorities-rule). | | `splitByApp` | `boolean` | Serve each app on its own path, `/`, each with its own `auth`. Defaults to `false`. See [Apps, discovery and splitting](https://frontmcp.dev/reference/server/apps). | | `redis` | `RedisOptions` | Shared storage for sessions, tokens and pending questions: `{ host, port?, password?, db?, tls?, keyPrefix?, defaultTtlMs? }`, or `{ provider: "vercel-kv", url?, token? }`. | | `pubsub` | `RedisOptions` | Redis for resource-subscription events, needed when `redis` is Vercel KV. Defaults to `redis`. | | `sqlite` | `{ path?, encryption?, walMode?, ttlCleanupIntervalMs? }` | Local storage for sessions, pending questions and events on a single machine, instead of Redis. | | `logging` | `{ level?, enableConsole?, prefix?, transports? }` | `level` is a `LogLevel`: `Debug`, `Verbose`, `Info` (the default), `Warn`, `Error` or `Off`. `transports` adds your own log destinations. See [Logging](https://frontmcp.dev/reference/server/logging). | | `health` | `HealthOptions` | Health endpoints, on by default: `/healthz` (also `/health`) and `/readyz`. `probes` adds your own checks, like a database ping. See [Health checks](https://frontmcp.dev/reference/server/observability#health). | | `metrics` | `MetricsOptions` | A `/metrics` endpoint in Prometheus or JSON format. Off by default. `auth: "token"` protects it with a token from `FRONTMCP_METRICS_TOKEN`. See [Metrics](https://frontmcp.dev/reference/server/observability#metrics). | | `observability` | `boolean` or `ObservabilityOptions` | OpenTelemetry tracing and structured logs. Needs `@frontmcp/observability` installed. See [Observability and telemetry](https://frontmcp.dev/reference/server/observability). | Other features: | Option | Type | Description | | --- | --- | --- | | `skillsConfig` | `SkillsConfigOptions` | Serve skills over HTTP (`/llm.txt`, `/skills`), and choose how the skill catalog joins `instructions` with `injectInstructions`: `"append"` (the default), `"prepend"`, `"replace"` or `"off"`. See [`@Skill`](https://frontmcp.dev/reference/sdk/skill). Its `audit` options keep a signed record of what skill scripts run: see [Audit log](https://frontmcp.dev/reference/plugins/skilled-openapi#audit-log). | | `extApps` | `ExtAppsOptions` | What MCP Apps widgets may do: call tools, log, open links, update the model's context. | | `tasks` | `TasksOptions` | Where background tasks are kept, for how long, and where they run. Tasks are on by default; a tool runs as one when it sets `execution.taskSupport`. See [Background tasks](https://frontmcp.dev/reference/server/tasks). | | `jobs` | `{ enabled, allowDynamicRegistration?, store? }` | The jobs and workflows system. On when an app declares jobs or workflows, with runs kept in memory; set it to keep runs in Redis or to turn the system off. See [`@Job`](https://frontmcp.dev/reference/sdk/job#frontmcp-jobs-). | | `channels` | `{ enabled, defaultMeta? }` | Push events to Claude Code sessions through channels. Off by default. A client asks for them with `experimental: { "claude/channel": {} }` in its capabilities: in `initialize`, before 2026-07-28, or in a 2026-07-28 `subscriptions/listen` request, whose stream then carries the events (since 1.9.2; before, 2026-07-28 clients received none). See [`@Channel`](https://frontmcp.dev/reference/sdk/channel). | | `loader` | `{ url?, registryUrl?, token?, tokenEnvVar? }` | Where `App.esm()` fetches packages. Defaults to the npm registry and esm.sh. See [the loader](https://frontmcp.dev/reference/server/esm#the-loader). | | `ui` | `{ cdnOverrides?, escapeStringResults?, servingMode? }` | Defaults for the tools' [UI widgets](https://frontmcp.dev/reference/ui): where they load their libraries from, whether a template's plain string is [escaped](https://frontmcp.dev/reference/ui#escaping-string-results), and the [serving mode](https://frontmcp.dev/reference/ui#serving-modes) of every tool that doesn't set one. An app's `@App({ ui: { servingMode } })` overrides it for that app's tools, and a tool's own `ui.servingMode` overrides both: see [Defaults for the server and an app](https://frontmcp.dev/reference/ui#defaults-for-the-server-and-an-app). (`servingMode` is new in 1.9.4.) | #### `info` | Field | Type | Description | | --- | --- | --- | | `name` | `string` | **Required.** The server's name, like `"help-desk"`. | | `version` | `string` | **Required.** The server's version, like `"1.0.0"`. | | `title` | `string` | A readable name for clients to display. | | `websiteUrl` | `string` | A URL for the server's documentation or home page. | | `icons` | `Icon[]` | Icons for clients to show. Each has a `src`, and optionally `mimeType`, `sizes` and `theme`. | `info` has no `description` field. One is silently dropped: use `instructions` to describe the server to the model. #### `http` ```ts main.ts @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], http: { port: 8080, entryPath: "/mcp", cors: { origin: ["https://desk.example.com"], credentials: true }, routes: [{ method: "GET", path: "/exports", handler: (req, res) => res.json({ format: req.query.format ?? "csv" }) }], }, }) export default class Server {} ``` | Field | Default | Description | | --- | --- | --- | | `port` | `PORT`, or `3000` | The TCP port to listen on. | | `entryPath` | `""` | The path of the MCP endpoint. With the default, clients connect to the root: `http://localhost:3000`. | | `cors` | none | CORS for browser clients: `{ origin, credentials?, maxAge? }`. Without it, MCP responses have no CORS headers, so browser pages on other origins can't read them. The [server card](https://frontmcp.dev/reference/deployment/mcp-registry#documents-served) sends its own CORS headers with origin `*`. `origin: true` allows any origin. | | `security` | loopback | `bindAddress`: `"loopback"` (the default, `127.0.0.1`), `"all"`, or an address. The `FRONTMCP_BIND_ADDRESS` environment variable does the same. `dnsRebindingProtection` checks `Host` and `Origin` headers. `strict: true` turns on the hardening. | | `routes` | none | Extra HTTP handlers on the same port: `{ method, path, handler, auth? }`. With `auth: true`, a route requires the same credentials as the MCP endpoint. Paths used by FrontMCP, like `/health`, `/.well-known/*` and `/server-card`, are rejected at startup. `createFetchHandler()` serves these routes too. See [Serving HTTP routes next to MCP](https://frontmcp.dev/reference/sdk/frontmcp#serving-http-routes-next-to-mcp). | | `bodyLimit` | `"4mb"` | The largest JSON request body accepted, as a byte count or size string (`number \| string`). Applies to the fetch handler too, including limits above 4 MiB. A larger body gets HTTP `413`. `urlencodedLimit` does the same for form bodies on the Node host. See [body size limits](https://frontmcp.dev/reference/deployment/security#body-size-limits). | | `securityHeaders` | `nosniff` and `X-Frame-Options: DENY` | The security headers on every response: `{ hsts?, contentTypeOptions?, frameOptions?, custom?, csp? }`. `hsts`, `contentTypeOptions` and `frameOptions` take the header's value, or `false` to leave it out; `custom` adds headers as they are; `csp` is `{ enabled?, directives?, reportUri?, reportOnly? }`. Each option wins over its `FRONTMCP_*` variable ([Response headers](https://frontmcp.dev/reference/server/config-files#response-headers)). Since 1.8.7; before, FrontMCP sent neither default header and sent `X-Powered-By: Express`. | | `socketPath` | none | Listen on a Unix socket instead of a port. | | `hostFactory` | Express | Your own HTTP server instead of Express: a `FrontMcpServer`, or a function that returns one. It then applies `cors`, `bodyLimit`, `securityHeaders` and the `Host` check itself. See [Using your own HTTP server](https://frontmcp.dev/reference/sdk/frontmcp#using-your-own-http-server). | #### `registry` `registry` serves discovery documents before a client connects. It defaults to off. The [MCP Registry reference](https://frontmcp.dev/reference/deployment/mcp-registry#frontmcp-registry-) lists each option's type and default and [tests the routes](https://frontmcp.dev/reference/deployment/mcp-registry#serving-the-proof-and-discovery-documents). ```ts registry: { name: "com.example/help-desk", description: "Search support tickets", // publicKey defaults to MCP_REGISTRY_PUBLIC_KEY, read on each proof request. } ``` The proof is always at `/.well-known/mcp-registry-auth`; the card follows `http.entryPath` at `/server-card`; the catalog is at `/.well-known/ai-catalog.json`. With `splitByApp`, or only standalone apps, only the proof is served. Set `FRONTMCP_PUBLIC_URL` behind a proxy so the card names the public endpoint. #### `transport` | Field | Default | Description | | --- | --- | --- | | `protocol` | `"legacy"` | Which HTTP transports clients may use, for protocol versions before 2026-07-28: `"legacy"` (Streamable HTTP and the old SSE transport), `"modern"` (Streamable HTTP with sessions), `"stateless-api"` (no sessions), `"full"` (everything), or an object of `sse`, `streamable`, `json`, `stateless`, `legacy` and `strictSession` flags. | | `defaultProtocolVersion` | `"legacy"` on the Node server and stdio, `"2026-07-28"` behind `createFetchHandler()` | How to serve a bare JSON-RPC request that names no protocol version. On stdio, applies until `initialize` makes the connection a session. See [Protocol versions](https://frontmcp.dev/reference/server/protocol-versions#transportdefaultprotocolversion). | | `persistence` | on when `redis` or `sqlite` is set | Store sessions so they survive restarts: `{ redis?, sqlite?, defaultTtlMs?, sessionCheckTimeoutMs? }`. `sessionCheckTimeoutMs` (500 by default, new in 1.9.4) is how long a request waits for the store to confirm its session before it's served from memory: see [High availability](https://frontmcp.dev/reference/deployment/high-availability). Without it, FrontMCP uses the top-level `redis`, or else `sqlite`, if one is set, and memory otherwise. `false` keeps sessions in memory. | | `distributedMode` | `false` | `true` or `"auto"` for servers that run as many instances behind a load balancer or as serverless functions. | | `providerCaching` | `true` | When `false`, `CONTEXT` providers are rebuilt on every request, even within a session: those registered on `@FrontMcp` and, since 1.9.2, on apps. | | `eventStore` | off | Let SSE clients resume missed messages after reconnecting: `{ enabled, provider?, maxEvents?, ttlMs?, redis? }`. | | `sessionMode` | `"stateful"` | Deprecated, not read, and removed in the next major: with `"stateless"`, older clients still get a session id and must send it. Sessions follow `protocol`: `"stateless-api"` serves without them. A value other than `"stateful"` logs a warning at startup: ``transport.sessionMode ('stateless') is ignored and will be removed in the next major: sessions follow `transport.protocol`. To serve without sessions, set `transport.protocol: 'stateless-api'` and remove `sessionMode`.`` (Since 1.9; the message changed in 1.9.4.) | | `platformDetection` | built-in | Rules for recognizing the client's platform from its client info. | Clients that speak MCP 2026-07-28 don't use sessions, so most of these only matter for older clients. A session id is encrypted with `MCP_SESSION_SECRET`. Since 1.8.6, a request whose id was minted under another secret gets `404` `invalid session id` (before 1.8.6, `session not initialized`), and the client starts over with `initialize`, so changing the secret ends every open session once. This was checked on a Node server, since the Playground has no sessions. #### `auth` `auth` sets who may connect, and apps inherit it. An app's own `auth` doesn't change who may call its tools on the shared endpoint: see [Auth for one app](https://frontmcp.dev/reference/auth/modes#auth-for-one-app). [Auth modes](https://frontmcp.dev/reference/auth/modes) covers every mode and its options. | `mode` | Who may connect | | --- | --- | | `"public"` | Anyone. **The default.** Callers get anonymous identities. | | `"static"` | Callers that send one of the shared secrets in `tokens`, as `Authorization: Bearer …` by default. | | `"transparent"` | Callers with a valid token from your identity provider, `provider`. FrontMCP checks tokens but doesn't issue them. | | `"local"` | Callers who sign in through FrontMCP's own OAuth server. See [Local auth](https://frontmcp.dev/reference/auth/local). | | `"remote"` | Callers who sign in through an external OAuth provider, `provider`, with FrontMCP handling the flow. | In `local` and `remote` mode, only registered clients can sign in, since `requireRegisteredClients` defaults to `true`, and a client is granted only the scopes listed in `allowedScopes`. See [Local auth](https://frontmcp.dev/reference/auth/local). ```ts main.ts @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "static", tokens: [process.env.DESK_API_KEY!] }, }) export default class Server {} ``` The Playground's client can't send credentials, so this runs in a project, not in the Playground. [Authenticating Clients](https://frontmcp.dev/learn/authenticating-clients) walks through each mode. #### Starting the server With the default `serve: true`, importing the class starts an HTTP server. With the environment variable `FRONTMCP_STDIO=1` (or `true`), it connects over stdio instead and opens no port, for clients that launch the server as a subprocess. To decide when the server starts, set `serve: false` and start it yourself: ```ts main.ts import "reflect-metadata"; import { FrontMcp, FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; const options = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }; @FrontMcp({ ...options, serve: false }) export default class Server {} await connectToDatabase(); await FrontMcpInstance.bootstrap(options); ``` [`FrontMcpInstance`](https://frontmcp.dev/reference/sdk/frontmcp-instance) also has `runStdio(options)`, `createHandler(options)` for serverless platforms, and [`createFetchHandler(options)`](https://frontmcp.dev/reference/sdk/create-fetch-handler), which is what the Playground uses. To call a server in your own process, see [`create()` and `createDirect()`](https://frontmcp.dev/reference/sdk/create) and [`connect()`](https://frontmcp.dev/reference/sdk/connect). #### Caveats - Declare **one** `@FrontMcp` per server. The class body is ignored. - Options FrontMCP doesn't know are **dropped without an error**, including `info.description` and a top-level `prompts`. Check the spelling of anything that seems to have no effect. - The MCP endpoint is at the **root path** unless you set `http.entryPath`. - The Playground runs your options through `FrontMcpInstance.createFetchHandler()`, in your browser, with a minimal `AsyncContext` so that calls overlap as they do on Node (FrontMCP's browser build serves [one request at a time](https://frontmcp.dev/reference/sdk/create-fetch-handler#in-a-browser) without one). It opens no port, and listener options such as `http.port` and `http.socketPath` have no effect there. Custom `http.routes` are served. `auth` does apply, but the Playground's client never sends credentials: a mode that requires them (such as `static`) refuses every request, and one that allows anonymous callers treats the Playground as anonymous. See [Authenticating Clients](https://frontmcp.dev/learn/authenticating-clients). --- ## Usage ### Declaring a server `info` and `apps` are all a server needs. Clients receive `info` with every response: open the **Wire** tab and look at `_meta` in any response. Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; @FrontMcp({ info: { name: "help-desk", title: "Help Desk", version: "1.4.0", websiteUrl: "https://desk.example.com" }, apps: [HelpDeskApp], }) export default class Server {} ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", title: "Cannot log in" }, { id: "T-2", title: "Invoice total is wrong" }, ]; @Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] }) export class HelpDeskApp {} ``` ```ts info.test.ts import { test, expect } from "@frontmcp/testing"; test("clients see the server's info", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" }); expect(response.result).toMatchObject({ _meta: { "io.modelcontextprotocol/serverInfo": { name: "help-desk", title: "Help Desk", version: "1.4.0", websiteUrl: "https://desk.example.com", }, }, }); }); ``` ### Tools every app shares `tools` and `resources` on `@FrontMcp` belong to no app: every app serves them, and they're listed once, with their own names. Use them for what isn't any app's, like a health check: Initial call configuration: ```js { tool: "ping", arguments: {} } ``` ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { BillingApp, HelpDeskApp } from "./apps"; import { Ping, ServerStatus } from "./shared"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp], tools: [Ping], resources: [ServerStatus], }) export default class Server {} ``` ```ts shared.ts import { Resource, ResourceContext, Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "ping", description: "Check that the server is up", inputSchema: {} }) export class Ping extends ToolContext { async execute() { return { ok: true }; } } @Resource({ name: "status", uri: "desk://status", mimeType: "application/json", description: "Whether every service is up" }) export class ServerStatus extends ResourceContext { async execute() { return { services: { tickets: "up", billing: "up" } }; } } ``` ```ts apps.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [{ id: "T-1", title: "Cannot log in" }].filter((t) => t.title.toLowerCase().includes(query)) }; } } @Tool({ name: "get_invoice", description: "Get an invoice", inputSchema: { id: z.string() } }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, amount: "49 EUR" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] }) export class HelpDeskApp {} @App({ id: "billing", name: "Billing", tools: [GetInvoice] }) export class BillingApp {} ``` ```ts shared.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcp, FrontMcpInstance, Tool, ToolContext } from "@frontmcp/sdk"; import { SearchTickets } from "./apps"; test("the server's tool and resource are listed once, next to the apps'", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name).sort()).toEqual(["get_invoice", "ping", "search_tickets"]); expect((await mcp.resources.list()).map((r) => r.uri)).toEqual(["desk://status"]); expect((await mcp.tools.call("ping", {})).json()).toEqual({ ok: true }); }); test("a name an app's tool has too gets a prefix on both", async () => { @Tool({ name: "search_tickets", description: "Search every app's tickets", inputSchema: {} }) class SearchEverything extends ToolContext { async execute() { return { from: "server" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] }) class HelpDesk {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], tools: [SearchEverything] }); try { expect((await server.listTools()).tools.map((t) => t.name).sort()).toEqual(["help-desk:search_tickets", "server:search_tickets"]); expect((await server.callTool("server:search_tickets", {})).structuredContent).toEqual({ from: "server" }); } finally { await server.dispose(); } }); ``` `@FrontMcp` has no `prompts`: put prompts in an app. Changed in 1.9.1: before, FrontMCP accepted `tools` and `resources` here but never registered them, so clients couldn't see or call them. ### Giving the model instructions Tool descriptions explain each tool. `instructions` explains the server: which tool to start with, how the tools fit together, what not to do. Clients read it from `server/discover` and usually add it to the model's context, so keep it short. ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], instructions: "Support ticket tools. Find tickets with search_tickets before acting on them. " + "Never close a ticket the customer hasn't confirmed is solved.", }) export default class Server {} ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [], query }; } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, CloseTicket] }) export class HelpDeskApp {} ``` ```ts instructions.test.ts import { test, expect } from "@frontmcp/testing"; test("server/discover carries the instructions", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" }); expect(response.result).toMatchObject({ instructions: expect.stringContaining("Find tickets with search_tickets") }); }); ``` ### Asking the user for input `this.elicit()` pauses a tool to ask the user a question, and it only works when the server turns elicitation on. The Call tab shows the form a client would show; answer it to finish the call. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true }, }) export default class Server {} ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a ticket once the user confirms", inputSchema: { id: z.string() } }) class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() })); if (answer.status !== "accept" || !answer.content?.confirm) return { id, status: "open" }; return { id, status: "closed" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] }) export class HelpDeskApp {} ``` ```ts elicit.test.ts import { test, expect } from "@frontmcp/testing"; test("the tool closes the ticket once the user confirms", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "accept", content: { confirm: true } })); const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.json()).toEqual({ id: "T-1", status: "closed" }); }); ``` Without `elicitation: { enabled: true }`, the same call fails. See [the troubleshooting entry](https://frontmcp.dev/reference/sdk/frontmcp#elicitation-is-disabled-in-server-configuration). ### Paging long tool lists Once a server has more than 40 tools, `tools/list` returns them 40 at a time, with a `nextCursor` for the next page. Change both numbers with `pagination`. Here the threshold is four tools, and there are five, so they come two per page: ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], pagination: { tools: { mode: "auto", autoThreshold: 4, pageSize: 2 } }, }) export default class Server {} ``` ```ts help-desk.app.ts import { App, tool } from "@frontmcp/sdk"; const names = ["search_tickets", "get_ticket", "create_ticket", "assign_ticket", "close_ticket"]; const tools = names.map((name) => tool({ name, description: name.replace("_", " "), inputSchema: {} })(async () => ({ ok: true })), ); @App({ id: "help-desk", name: "Help Desk", tools }) export class HelpDeskApp {} ``` ```ts pages.test.ts import { test, expect } from "@frontmcp/testing"; type Page = { tools: { name: string }[]; nextCursor?: string }; test("each page has two tools and a cursor to the next", async ({ mcp }) => { const page = async (cursor?: string) => (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "tools/list", params: cursor ? { cursor } : {} })).result as Page; const first = await page(); expect(first.tools).toHaveLength(2); const second = await page(first.nextCursor); expect(second.tools).toHaveLength(2); const third = await page(second.nextCursor); expect(third.tools).toHaveLength(1); expect(third.nextCursor).toBeUndefined(); }); ``` A client reads every tool by following `nextCursor` until there isn't one. `mcp.tools.list()` does that for you since 1.8.6; before, it returned only the first page. A `DirectClient` from [`connect()`](https://frontmcp.dev/reference/sdk/connect) or `server.connect()` does it too: its list methods follow every page, `listResources()`, `listResourceTemplates()` and `listPrompts()` since 1.8.7. So does [`listTools()` on the server itself](https://frontmcp.dev/reference/sdk/create#listing-more-than-one-page-of-tools), since 1.9; before, it returned only the first page. This test sends the requests itself, to show each page. ### Setting defaults for every tool Some options set a default for every tool on the server. An app's own setting overrides the server's, and a tool's overrides both. ### Server-wide defaults ### Example: Output `output` controls how tool results are checked and described. With `allowNonFinite: true`, a `NaN` in a result becomes `null` instead of failing the call. Change it to `false` to see the error. Initial call configuration: ```js { tool: "average_reply_hours", arguments: {} } ``` ```ts main.ts active import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "average_reply_hours", description: "Average hours to first reply this week", inputSchema: {} }) class AverageReplyHours extends ToolContext { async execute() { const replies: number[] = []; return { hours: replies.reduce((a, b) => a + b, 0) / replies.length }; // 0 / 0 is NaN } } @App({ id: "reports", name: "Reports", tools: [AverageReplyHours] }) class ReportsApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [ReportsApp], output: { allowNonFinite: true }, }) export default class Server {} ``` ### Example: Rate limit `throttle` sets limits for every tool. Here each tool may run twice a minute; the third call fails with a message that says when to retry. Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts main.ts active import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [], query }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], throttle: { enabled: true, defaultRateLimit: { maxRequests: 2, windowMs: 60_000 } }, }) export default class Server {} ``` ```ts limits.test.ts import { test, expect } from "@frontmcp/testing"; test("the third call in a minute is refused", async ({ mcp }) => { await mcp.tools.call("search_tickets", { query: "a" }); await mcp.tools.call("search_tickets", { query: "b" }); const third = await mcp.tools.call("search_tickets", { query: "c" }); expect(third).toBeError(); expect(third.raw._meta?.code).toBe("RATE_LIMIT_EXCEEDED"); expect(third.text()).toContain("Rate limit exceeded"); }); ``` `throttle` also takes `global` (one limit shared by all requests), `defaultConcurrency` and `globalConcurrency` (`{ maxConcurrent }`), `defaultTimeout` (`{ executeMs }`), `ipFilter`, and `storage` to share counters between processes through Redis. A tool's own `rateLimit`, `concurrency` and `timeout` override the defaults. `ui.servingMode` sets a default the same way, for the [serving mode](https://frontmcp.dev/reference/ui#serving-modes) of every tool with a widget that doesn't set its own: `@FrontMcp({ ui: { servingMode: "static" } })`, overridden by `@App({ ui: { servingMode } })` for an app's tools, and by a tool's own `ui.servingMode`. New in 1.9.4. [Defaults for the server and an app](https://frontmcp.dev/reference/ui#defaults-for-the-server-and-an-app) shows it. ### Serving HTTP routes next to MCP Not everything a help desk serves is MCP. A spreadsheet wants a CSV export, and the billing system calls a webhook when an invoice is paid. `http.routes` adds plain HTTP endpoints on the MCP endpoint's port, so they don't need a second server: ```ts main.ts import "reflect-metadata"; import { FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; import { markPaid, ticketsCsv } from "./store"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "static", tokens: [process.env.DESK_API_KEY!] }, http: { routes: [ { method: "GET", path: "/exports/:status", auth: true, // the same credentials as the MCP endpoint handler: (req, res) => { res.setHeader("Content-Type", "text/csv"); res.send(ticketsCsv(req.params?.status ?? "open")); }, }, { method: "POST", path: "/webhooks/billing", handler: (req, res) => { markPaid(req.body.invoice); res.status(202).json({ received: req.body.invoice }); }, }, ], }, }) export default class Server {} ``` A handler is Express-style, `(req, res, next)`. `req.params` holds the path's `:status`, `req.query` the query string, `req.headers` the headers, and `req.body` a JSON or form body, already parsed. `res` has `status()`, `json()`, `send()` and `setHeader()`. This server answers: | Request | Answer | | --- | --- | | `GET /exports/open` with no `Authorization` header | `401` `{"error":"Unauthorized"}`, with `WWW-Authenticate: Bearer realm="mcp"` | | `GET /exports/open` with a key that isn't in `tokens` | `401`, with `WWW-Authenticate: Bearer realm="mcp", error="invalid_token", error_description="The access token is invalid"` | | `GET /exports/open` with `Authorization: Bearer` and the key | `200`, the CSV, as `text/csv; charset=utf-8` | | `POST /webhooks/billing` with no credentials | `202` `{"received":"INV-7"}`. A route without `auth: true` is open to anyone, whatever the server's `auth`, so check that the request comes from billing in the handler. | `auth: true` runs the check the MCP endpoint runs, before the handler: - Without valid credentials, the route answers `401` with the challenge the MCP endpoint sends. With an identity provider (`transparent` mode), it points at the resource metadata: `Bearer resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource"`. - A valid token that lacks one of `auth.requiredScopes` gets `403` `{"error":"Forbidden"}`, with `error="insufficient_scope"` and the scopes it needs in the challenge. - Otherwise the handler runs, and `req.authSession` says who called: `user` holds the token's claims (`sub`, `scope`, and for a JWT the rest of them), and `token` the token itself. A static key's `user.sub` is `static:` and a hash of the key. - A server that lets anonymous callers in lets them into these routes too. In `public` mode, the default, and with `allowAnonymous`, a request without a token runs the handler as a new anonymous caller each time, whose `req.authSession.user.sub` starts with `anon:`. If a route needs a real caller, refuse those in the handler. `throttle.ipFilter` applies to every route, before `auth`: a refused address gets `403` `{"error":"forbidden","message":"Client IP rejected by ipFilter"}`. **The content type.** FrontMCP's Express server sets `Content-Type: application/json; charset=utf-8` on every response before a handler runs. That suits `res.json()`, but a CSV string sent with `res.send()` goes out labelled as JSON. Set the type first, as the export does. **Reserved paths.** A route can't take a path FrontMCP serves itself: - the MCP endpoint and the `/sse` and `/message` paths next to it: `/`, `/sse` and `/message` by default, or `/mcp`, `/mcp/sse` and `/mcp/message` with `entryPath: "/mcp"`; - `/server-card`, whether or not `registry` is set. Changed in 1.10.0: before, a custom route could use this path; - `/health` and `/metrics`; - anything under `/oauth/` or `/.well-known/`. Such a route stops the server from starting: see [the troubleshooting entry](https://frontmcp.dev/reference/sdk/frontmcp#custom-httproute--collides-with-the-reserved-frontmcp-path). Health probe paths (`/healthz` and `/readyz`, or the paths set by `health.healthzPath` and `health.readyzPath`) are reserved while health is enabled. Changed in 1.9.5: before, a custom route could replace a health probe. **Where routes are served.** FrontMCP's Node server serves them, whether you start it by importing the class or with `bootstrap()`, and so does [`createHandler()`](https://frontmcp.dev/reference/sdk/frontmcp-instance#frontmcpinstancecreatehandlerconfig). [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) serves them too, including channel webhooks, behind the same IP filtering and session verification. Changed in 1.9.5: before, fetch-handler routes answered `404`. **Large files.** A tool's result, like a resource's contents, travels inside one JSON-RPC response. For an export of many megabytes, serve the file from a route and have the tool return a `resource_link` to it: the result carries only the link, and the file goes out on a request of its own when someone follows it. With `auth: true` on the route, that request needs the same credentials as the MCP endpoint. Initial call configuration: ```js { tool: "export_tickets", arguments: { status: "open" } } ``` ```ts main.ts active import { App, FrontMcp, Tool, ToolContext, z, type ServerRequest, type ServerResponse } from "@frontmcp/sdk"; import { ticketsCsv } from "./store"; const PUBLIC_URL = "https://desk.example.com"; // where clients reach this server @Tool({ name: "export_tickets", description: "Export the tickets with a status as a CSV file, and return a link to it", inputSchema: { status: z.enum(["open", "closed"]) }, outputSchema: "resource_link", }) class ExportTickets extends ToolContext { async execute({ status }: { status: "open" | "closed" }) { return { type: "resource_link" as const, uri: `${PUBLIC_URL}/exports/${status}`, name: `${status}-tickets.csv`, mimeType: "text/csv" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], http: { routes: [ { method: "GET" as const, path: "/exports/:status", handler: (req: ServerRequest, res: ServerResponse) => { res.setHeader("Content-Type", "text/csv"); res.send(ticketsCsv(req.params?.status ?? "open")); }, }, ], }, }; @FrontMcp(config) export default class Server {} ``` ```ts store.ts 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: "Export times out", status: "open" }, ]; // A real export would stream thousands of rows from the database export function ticketsCsv(status: string) { const rows = tickets.filter((t) => t.status === status).map((t) => `${t.id},${t.title}`); return ["id,title", ...rows].join("\n") + "\n"; } ``` ```ts export.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; test("the result is a link to the file, not the file", async ({ mcp }) => { const result = await mcp.tools.call("export_tickets", { status: "open" }); expect(result).toBeSuccessful(); expect(result.raw.content).toEqual([ { type: "resource_link", uri: "https://desk.example.com/exports/open", name: "open-tickets.csv", mimeType: "text/csv" }, ]); }); test("createFetchHandler() serves the export route", async () => { const handler = await FrontMcpInstance.createFetchHandler(config); const response = await handler(new Request("https://desk.example.com/exports/open")); expect(response.status).toBe(200); expect(await response.text()).toContain("id,title"); }); ``` On a Node server, following the link returns the two open tickets as `text/csv`. ### Using your own HTTP server `HostServerAdapter` is exported as a value and `Scope` as a type from `@frontmcp/sdk`. Custom hosts implement `FrontMcpServer`. Changed in 1.9.5: `enhancedHandler()` is removed from that contract; hosts no longer need to implement it. FrontMCP's Node server is built on Express. `http.hostFactory` replaces Express with a server of your own: a `FrontMcpServer` subclass, or a function that returns one. FrontMCP still decides what to serve, the MCP endpoint, discovery documents, OAuth, health checks and your routes, and registers each with your server. Your server decides how requests reach them. To add MCP to a Node server you already run, you don't need one: [`createHandler()`](https://frontmcp.dev/reference/sdk/frontmcp-instance#frontmcpinstancecreatehandlerconfig) returns FrontMCP's Express app as a `(req, res)` handler, to call with the requests MCP should answer. FrontMCP calls these methods, in this order: | Method | Called | | --- | --- | | `registerMiddleware(path, handler)` | Once for each of FrontMCP's endpoints, while the server starts: the MCP endpoint with `"/"`, discovery documents with `""`, and OAuth with paths like `"/oauth/token"` and `"/oauth/provider/:providerId/callback"`. Run `handler` for every request at or under `path`, in the order they were registered. A handler that doesn't serve the request calls `next()`. | | `registerRoute(method, path, handler)` | For each of `http.routes`, then for FrontMCP's health checks, `/healthz`, `/health` and `/readyz`. Run `handler` for that method and path. `:name` parts of the path go in `req.params`. | | `prepare()` | Once everything is registered. | | `start(portOrSocketPath, bindAddress)` | When the server starts: with `http.socketPath` if it's set, else `http.port`, and the bind address `http.security` resolves to, `"127.0.0.1"` by default. | | `getHandler()` | By `createHandler()`, instead of `start()`. `createHandler()` returns what it returns. | | `stop()` | On `SIGTERM` or `SIGINT`. The base class's does nothing. | The handlers expect Express's request and response, so a host gives them what they use. On the request: `path`, `query`, `params`, `headers`, and `body`, parsed from JSON or a form. On the response: `status()`, which returns the response, `json()`, `send()` and `redirect()`, besides Node's own `setHeader()`, `writeHead()`, `write()` and `end()`, which streamed answers use. This host does it with `node:http` alone: ```ts node-host.ts import http from "node:http"; import { FrontMcpServer, type HttpMethod, type ServerRequest, type ServerRequestHandler, type ServerResponse } from "@frontmcp/sdk"; type Layer = { method?: HttpMethod; path: string; prefix: boolean; handler: ServerRequestHandler }; export class NodeHost extends FrontMcpServer { private layers: Layer[] = []; private server?: http.Server; registerMiddleware(path: string, handler: ServerRequestHandler) { this.layers.push({ path, prefix: true, handler }); } registerRoute(method: HttpMethod, path: string, handler: ServerRequestHandler) { this.layers.push({ method, path, prefix: false, handler }); } prepare() {} getHandler() { return (req: http.IncomingMessage, res: http.ServerResponse) => this.handle(req, res).catch(() => { if (!res.headersSent) res.writeHead(500).end(); }); } async start(portOrSocketPath?: number | string, bindAddress?: string) { this.server = http.createServer(this.getHandler()); await new Promise((resolve) => { if (typeof portOrSocketPath === "string") this.server!.listen(portOrSocketPath, resolve); else this.server!.listen(portOrSocketPath, bindAddress, resolve); }); } override async stop() { await new Promise((resolve) => this.server?.close(resolve)); } private async handle(req: http.IncomingMessage, res: http.ServerResponse) { const url = new URL(req.url ?? "/", "http://localhost"); let body: unknown; try { body = await readBody(req); } catch { return void res.writeHead(400).end(); // not JSON } // What FrontMCP's handlers read and call, as on Express const request = Object.assign(req, { path: url.pathname, query: Object.fromEntries(url.searchParams), body }); const response = Object.assign(res, { status: (code: number) => { res.statusCode = code; return res; }, json: (payload: unknown) => { if (!res.hasHeader("content-type")) res.setHeader("content-type", "application/json; charset=utf-8"); res.end(JSON.stringify(payload)); }, send: (payload: unknown) => res.end(typeof payload === "string" || payload instanceof Uint8Array ? payload : JSON.stringify(payload)), redirect: (status: number | string, location?: string) => { res.writeHead(typeof status === "number" ? status : 302, { location: typeof status === "number" ? location : status }).end(); }, }); // Offer the request to each handler in the order FrontMCP registered them, until one answers const layers = this.layers.filter((layer) => !layer.method || layer.method === req.method); const next = async (i: number): Promise => { if (i === layers.length) return void res.writeHead(404).end(); const params = matchPath(layers[i], url.pathname); if (!params) return next(i + 1); Object.assign(request, { params }); await layers[i].handler(request as unknown as ServerRequest, response as unknown as ServerResponse, () => next(i + 1)); }; await next(0); } } // "/exports/:status" matches "/exports/open" with { status: "open" }; a middleware's path also matches what's below it function matchPath(layer: Layer, pathname: string): Record | undefined { const want = layer.path.split("/").filter(Boolean); const got = pathname.split("/").filter(Boolean); if (layer.prefix ? got.length < want.length : got.length !== want.length) return undefined; const params: Record = {}; for (const [i, part] of want.entries()) { if (part.startsWith(":")) params[part.slice(1)] = decodeURIComponent(got[i]); else if (part !== got[i]) return undefined; } return params; } async function readBody(req: http.IncomingMessage) { const type = req.headers["content-type"] ?? ""; const form = type.includes("application/x-www-form-urlencoded"); if (!form && !type.includes("application/json")) return undefined; const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const text = Buffer.concat(chunks).toString("utf8"); if (!text) return undefined; return form ? Object.fromEntries(new URLSearchParams(text)) : JSON.parse(text); } ``` ```ts main.ts import "reflect-metadata"; import { FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; import { NodeHost } from "./node-host"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], http: { port: 3000, hostFactory: () => new NodeHost() }, }) export default class Server {} ``` On a Node server, this host served `server/discover` and `tools/call` to a 2026-07-28 client, an `initialize` session and a streamed `tools/call` to a 2025-06-18 client, `/healthz`, `/readyz`, the resource metadata document, and an `http.routes` route with `auth: true`. The Playground runs no HTTP server, so it never calls a host. Some of what the Express server does, a host of your own has to do itself: `cors`, `bodyLimit`, `securityHeaders` with its `nosniff` and `X-Frame-Options` defaults, the `Host` check of `security.dnsRebindingProtection`, and the JSON `Content-Type` default. With this host, a request from an origin listed in `cors` gets no CORS headers. The function form of `hostFactory` is called once, with the `http` options except `hostFactory`, defaults filled in (`port`, `entryPath`, `bodyLimit`), so your server can read them. An instance, `hostFactory: new NodeHost()`, works the same way, without them. `FrontMcpServer`, `HttpMethod`, `ServerRequestHandler`, `ServerRequest` and `ServerResponse` come from `@frontmcp/sdk`. FrontMCP's Express host class isn't exported, so a host extends `FrontMcpServer` directly. --- ## Troubleshooting ### `@FrontMcp invalid metadata for "apps"` The full message goes on: `apps items must be annotated with @App() | @FrontMcpApp() or be a valid remote app configuration.` An entry in `apps` is a class without `@App`. Often it's a tool or a provider listed directly on the server: ```ts // 🚩 A tool is not an app @FrontMcp({ info, apps: [SearchTickets] }) // ✅ Put the tool in an app, and the app on the server @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] }) class HelpDeskApp {} @FrontMcp({ info, apps: [HelpDeskApp] }) ``` ### `Invalid option: expected one of 0|1|2|3|4|100` The error's `path` is `logging.level`. The level is a `LogLevel` enum value, not a string: ```ts // 🚩 A string logging: { level: "info" } // ✅ The enum import { LogLevel } from "@frontmcp/sdk"; logging: { level: LogLevel.Info } ``` ### `Elicitation is disabled in server configuration` A tool called `this.elicit()`, but the server doesn't have elicitation turned on, so the call failed with this message: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts main.ts active import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a ticket once the user confirms", inputSchema: { id: z.string() } }) class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() })); return { id, status: answer.content?.confirm ? "closed" : "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] }) class HelpDeskApp {} // 🚩 No `elicitation: { enabled: true }` @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` Add `elicitation: { enabled: true }` to `@FrontMcp`. If the server runs as several processes, also give it a `redis`, so an answer can reach whichever process asked. ### `Authorities configuration required`, or `Invalid authorities rule` The server didn't start, for one of three reasons: - An entry declares `authorities`, a tool, resource, template, prompt, agent, a tool inside an agent, or a skill, and `@FrontMcp` has no `authorities` option to check it with. - A rule checks nothing: `{}`, an empty list, an unknown field like `role:`, or a profile name inside `anyOf`. The message names the entry. Such a rule used to let every caller in; since FrontMCP 1.8.3 the server refuses it instead. - A rule names a profile the `authorities` option doesn't define, like a typo: `Invalid authorities rule: Tool "close_ticket": authorities names an unknown profile "lead"`. Changed in 1.8.4: before, such a server started and then refused every caller. ```ts startup.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { ArchiveApp, HelpDeskApp } from "./apps"; import { authorities } from "./main"; const info = { name: "help-desk", version: "1.0.0" }; test("an entry with authorities, and no authorities option", async () => { await expect(FrontMcpInstance.createDirect({ info, apps: [HelpDeskApp] })).rejects.toThrow('Authorities configuration required: Tool "close_ticket"'); }); test("a rule that checks nothing", async () => { await expect(FrontMcpInstance.createDirect({ info, apps: [HelpDeskApp, ArchiveApp], authorities })).rejects.toThrow( 'Invalid authorities rule: Tool "reopen_ticket": authorities checks nothing', ); }); test("a profile name the option doesn't define", async () => { const typo = { profiles: { leads: { roles: { any: ["lead"] } } } }; await expect(FrontMcpInstance.createDirect({ info, apps: [HelpDeskApp], authorities: typo })).rejects.toThrow( 'Invalid authorities rule: Tool "close_ticket": authorities names an unknown profile "lead"', ); }); test("with the option, and a rule that checks a role, the server starts", async ({ mcp }) => { expect(await mcp.tools.call("close_ticket", { id: "T-1" })).toBeError("AUTHORITY_DENIED"); }); ``` ```ts apps.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a ticket", inputSchema: { id: z.string() }, authorities: "lead" }) class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, closed: true }; } } // 🚩 checks nothing @Tool({ name: "reopen_ticket", description: "Reopen a ticket", inputSchema: { id: z.string() }, authorities: {} }) class ReopenTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, reopened: true }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] }) export class HelpDeskApp {} @App({ id: "archive", name: "Archive", tools: [ReopenTicket] }) export class ArchiveApp {} ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./apps"; export const authorities = { profiles: { lead: { roles: { any: ["lead"] } } } }; // ✅ The option is set, and the app's rule checks a role @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], authorities }) export default class Server {} ``` Add the `authorities` option, and fix the rule. To leave an entry open to everyone, remove its `authorities`. ### `Custom http.route … collides with the reserved FrontMCP path` The server didn't start, because a route in `http.routes` takes a path FrontMCP serves itself. The full message names both paths and the rule: `Custom http.route "GET /health" collides with the reserved FrontMCP path "/health". Reserved prefixes are the MCP entry path (and its /sse, /message and /server-card siblings), /oauth/*, /.well-known/*, /health, /metrics, and the health probe paths (/healthz, /readyz unless health.healthzPath / health.readyzPath move them). Choose a different path.` Changed in 1.10.0: before, a custom route could use `/server-card`. It is now reserved, even without `registry` configured. ```ts startup.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance, type ServerRequestHandler } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; const info = { name: "help-desk", version: "1.0.0" }; const ok: ServerRequestHandler = (_req, res) => res.json({ ok: true }); const serve = (path: string, entryPath = "") => FrontMcpInstance.createFetchHandler({ info, apps: [HelpDeskApp], http: { entryPath, routes: [{ method: "GET", path, handler: ok }] } }); test("a route on /health", async () => { await expect(serve("/health")).rejects.toThrow('Custom http.route "GET /health" collides with the reserved FrontMCP path "/health".'); }); test("a route under /.well-known", async () => { await expect(serve("/.well-known/desk.json")).rejects.toThrow('collides with the reserved FrontMCP path "/.well-known"'); }); test("the server card's path is reserved without registry configured", async () => { await expect(serve("/mcp/server-card", "/mcp")).rejects.toThrow('collides with the reserved FrontMCP path "/mcp/server-card"'); }); test("with entryPath /mcp, /mcp/sse is taken and / is free", async () => { await expect(serve("/mcp/sse", "/mcp")).rejects.toThrow('collides with the reserved FrontMCP path "/mcp/sse"'); await expect(serve("/", "/mcp")).resolves.toBeDefined(); }); test("/healthz is reserved while health is enabled", async () => { await expect(serve("/healthz")).rejects.toThrow('collides with the reserved FrontMCP path "/healthz"'); }); ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], // ✅ A path of its own, not /health http: { routes: [{ method: "GET", path: "/status/queue", handler: (_req, res) => res.json({ waiting: 4 }) }] }, }) export default class Server {} ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [], query }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] }) export class HelpDeskApp {} ``` Move the route to a path of its own. To add a check to FrontMCP's health endpoints, use `health.probes` instead: see [Health checks](https://frontmcp.dev/reference/server/observability#health). ### My client gets `404` or `Cannot POST /mcp` The client is using a different path from the server. The MCP endpoint is at the root, `http://localhost:3000`, unless `http.entryPath` says otherwise. Either point the client at the root, or set `http: { entryPath: "/mcp" }`. If apps are `standalone` or the server uses `splitByApp`, each app has its own path under the entry path, like `/mcp/billing`. ### A route answers `404` `{"error":"Not Found","entryPaths":["/"]}` No MCP flow or custom route matched the request. Check the method and path in `http.routes`, and whether the handler calls `next()` without answering. Changed in 1.9.5: `createFetchHandler()` serves custom routes and channel webhooks; before, it returned this `404` for them: see [Serving HTTP routes next to MCP](https://frontmcp.dev/reference/sdk/frontmcp#serving-http-routes-next-to-mcp). ### A web page can't read the server's responses Browsers block cross-origin reads unless the server sends CORS headers, and since FrontMCP 1.7 MCP responses have none by default. The [server card](https://frontmcp.dev/reference/deployment/mcp-registry#documents-served) sends its own CORS headers with origin `*`. The request still reaches the server; the browser hides a response without CORS headers. List the origins that need access: ```ts http: { cors: { origin: ["https://desk.example.com"] } } ``` FrontMCP then also exposes the `Mcp-Session-Id` header, which clients using sessions need. CORS only affects browsers. It isn't access control; use [`auth`](https://frontmcp.dev/reference/sdk/frontmcp#auth) for that. ### Clients on other machines can't connect The server listens on `127.0.0.1` by default, so only the same machine can reach it. Set `http: { security: { bindAddress: "all" } }`, or the `FRONTMCP_BIND_ADDRESS=all` environment variable (handy in a Dockerfile), and put [`auth`](https://frontmcp.dev/reference/sdk/frontmcp#auth) in front of anything you expose. --- # @App > Group related tools, resources, prompts and providers into an app that a FrontMCP server hosts. Source: https://frontmcp.dev/reference/sdk/app [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `@App` groups related capabilities, the tools, resources and prompts for one area like tickets or billing, together with the providers they use. A [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp) server hosts one or more apps on the same endpoint. Each app keeps its providers to itself, and can have its own auth and defaults. ```ts @App(options) class MyApp {} ``` --- ## Reference ### `@App(options)` Apply `@App` to an empty class, and list the class in `@FrontMcp({ apps })`. ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket, SearchTickets } from "./tools"; import { TicketResource } from "./ticket.resource"; import { TriageTicket } from "./triage.prompt"; import { TicketStore } from "./ticket-store"; @App({ id: "help-desk", name: "Help Desk", description: "Search, triage and close support tickets", tools: [SearchTickets, CloseTicket], resources: [TicketResource], prompts: [TriageTicket], providers: [TicketStore], }) export class HelpDeskApp {} ``` [See more examples below.](https://frontmcp.dev/reference/sdk/app#usage) #### Options Required: | Option | Type | Description | | --- | --- | --- | | `name` | `string` | A readable name for logs and tooling, like `"Help Desk"`. | Optional: | Option | Type | Description | | --- | --- | --- | | `id` | `string` | A stable identifier. It prefixes names that clash with another app's, and is the URL path of a [standalone](https://frontmcp.dev/reference/sdk/app#standalone-apps) app. Defaults to `name` with spaces replaced by `-`, so set it: short, lowercase, like `help-desk`. | | `description` | `string` | What the app is for. For your own documentation and tooling; FrontMCP doesn't send it to clients. | | `tools` | `ToolType[]` | [`@Tool`](https://frontmcp.dev/reference/sdk/tool) classes or `tool()` functions. | | `resources` | `ResourceType[]` | [`@Resource`](https://frontmcp.dev/reference/sdk/resource) and [`@ResourceTemplate`](https://frontmcp.dev/reference/sdk/resource-template) classes. | | `prompts` | `PromptType[]` | [`@Prompt`](https://frontmcp.dev/reference/sdk/prompt) classes or `prompt()` functions. | | `providers` | `ProviderType[]` | [Providers](https://frontmcp.dev/reference/sdk/provider) for this app's tools, resources and prompts. Other apps can't see them. | | `plugins` | `PluginType[]` | Plugins that hook into this app's requests, such as caching or auditing, and can add tools and providers of their own. See [`@Plugin`](https://frontmcp.dev/reference/sdk/plugin). | | `adapters` | `AdapterType[]` | Adapters that generate tools from another source, such as an [OpenAPI spec](https://frontmcp.dev/reference/adapters/openapi). | | `authProviders` | `AuthProviderType[]` | Credentials this app's tools can ask for with `authProviders` on `@Tool`, such as a GitHub token. | | `auth` | `AuthOptionsInput` | This app's auth, overriding the server's `auth`, for an app with its own endpoint (`standalone` or `splitByApp`). On the shared endpoint only the server's `auth` checks callers, so where the app's would go unchecked the server refuses to start: a server with no `auth`, or in `public`, `static` or `transparent` mode (a `static` app that accepts all of a `static` server's tokens is allowed). In `local` and `remote` mode it starts, and app grants are checked per call only with `incrementalAuth`. The same modes as [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp#auth). See [Auth for one app](https://frontmcp.dev/reference/auth/modes#auth-for-one-app). (Changed in 1.9.2: before, the shared endpoint ignored it, and the app's tools were as open as the server's.) | | `output` | `OutputPolicy` | Defaults for this app's tool results: `schemaMode`, `schemaDescriptionFormat`, `allowNonFinite`. Overrides the server's `output`; a tool's own `output` overrides both. See [overriding server defaults](https://frontmcp.dev/reference/sdk/app#overriding-server-defaults-for-one-app). | | `ui` | `{ servingMode? }` | The [serving mode](https://frontmcp.dev/reference/ui#serving-modes) of this app's tools that don't set one. Overrides the server's `ui.servingMode`; a tool's own `ui.servingMode` overrides both. A value that isn't a serving mode throws a `ZodError` when the class is decorated. See [Defaults for the server and an app](https://frontmcp.dev/reference/ui#defaults-for-the-server-and-an-app). New in 1.9.4. | | `standalone` | `boolean \| "includeInParent"` | Serve the app on its own path. Defaults to `false`. See [standalone apps](https://frontmcp.dev/reference/sdk/app#standalone-apps). | | `agents` | `AgentType[]` | Agents: tools backed by their own model and tools. Each is exposed as a tool. | | `skills` | `SkillType[]` | Skills: step-by-step guides for using several tools together, exposed as resources. | | `jobs` | `JobType[]` | Jobs: named units of work with typed input and output, for the server's jobs system. | | `workflows` | `WorkflowType[]` | Workflows that run jobs in steps. | | `channels` | `ChannelType[]` | Channels that push events to Claude Code sessions. See [`@Channel`](https://frontmcp.dev/reference/sdk/channel). | #### Standalone apps By default every app is served on the server's one MCP endpoint. `standalone` gives an app its own endpoint, under its `id`: | `standalone` | On the main endpoint | On `/` | | --- | --- | --- | | `false` (default) | Yes | No | | `true` | No | Yes | | `"includeInParent"` | Yes | Yes | ```ts main.ts @App({ id: "billing", name: "Billing", tools: [GetInvoice], standalone: true }) class BillingApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp], http: { port: 3000, entryPath: "/mcp" }, }) export default class Server {} // http://localhost:3000/mcp → Help Desk tools only // http://localhost:3000/mcp/billing → Billing tools only ``` The path is relative to `http.entryPath`: with the default entry path, `/`, the billing app is at `/billing`. The Playground has one endpoint and no paths, so standalone apps can't be shown in it. To give **every** app its own path, use `splitByApp: true` on [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp). #### Apps from outside your code `App.remote()` connects to another MCP server and serves its tools, resources and prompts as an app. `App.esm()` loads an app from an npm package and runs it in your process. [Remote servers](https://frontmcp.dev/reference/server/remote) and [Loading apps from npm](https://frontmcp.dev/reference/server/esm) document every option, with examples that run against stand-in servers in the Playground. ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [ HelpDeskApp, App.remote("https://status.example.com/mcp", { namespace: "status", transportOptions: { timeout: 10_000 }, remoteAuth: { mode: "static", credentials: { type: "bearer", value: process.env.STATUS_TOKEN! } }, }), App.esm("@acme/crm-mcp@^2.0.0", { namespace: "crm" }), ], }) export default class Server {} ``` | Option | Applies to | Description | | --- | --- | --- | | `namespace` | both | Prefix for the app's tool, resource and prompt names. For `App.esm()` it defaults to the package name. | | `name`, `description` | both | Override the name derived from the URL or package, and describe the app. The app's id is the `name` you set, else the `namespace`, else the derived name; two apps with one id stop the server from starting, with `DuplicateAppIdError`. (Changed in 1.9.3: before, the `namespace` didn't count, and the server started.) | | `standalone` | both | As for `@App`. | | `filter` | both | Which of the app's tools, resources and prompts to serve, like `{ exclude: { tools: ["delete_*"] } }`. (Changed in 1.9.2: before, it was ignored.) | | `transportOptions` | `remote` | `timeout` (default 30000 ms, for tool calls only), `fallbackToSSE` (`true`), `headers` for every request, and `retryAttempts` and `retryDelayMs`: a tool call that fails on the network, times out, or gets HTTP `408`, `425`, `429` or a `5xx` other than `501` and `505` is tried again, 3 times in all by default, or `retryAttempts` + 1. See [retries](https://frontmcp.dev/reference/server/remote#retrying-calls-that-fail-on-the-way). (Changed in 1.9.2: before, `retryAttempts` and `retryDelayMs` were ignored. Changed in 1.9.3: before, only network failures were retried.) | | `remoteAuth` | `remote` | Credentials for the remote server: `{ mode: "static", credentials: { type: "bearer", value } }` sends them on every request. [Remote servers](https://frontmcp.dev/reference/server/remote#authenticating-to-the-remote-server) has the other modes: `forward`, `mapped` (since 1.9.3) and `oauth`. (Changed in 1.9.2: before, it sent nothing.) | | `refreshInterval`, `cacheTTL` | `remote` | How often to re-read the remote server's capabilities (default never), and how long to cache them (default 60000 ms). | | `autoUpdate`, `cacheTTL`, `loader`, `importMap` | `esm` | Poll for new versions that match the range; how long to cache a loaded package; a [loader](https://frontmcp.dev/reference/server/esm#the-loader) that replaces the server's for this app; and an import map, which rewrites the package's imports of the packages it names. (Changed in 1.9.2: before, `importMap` was ignored.) | Without its own `loader`, `App.esm()` fetches packages with the server's [`loader`](https://frontmcp.dev/reference/server/esm#the-loader) settings. #### `app(options)` The function form takes the same options and returns an app class, for code that builds apps from data rather than declaring them. See [building an app without a decorator](https://frontmcp.dev/reference/sdk/app#building-an-app-without-a-decorator). #### Caveats - The class body is ignored. Keep it empty. - Every entry in `tools`, `resources`, `prompts` and `providers` must be decorated (or made with the function form). The server fails to start otherwise. - An app's providers are **private to it**. To share one between apps, register it on `@FrontMcp({ providers })`. - When two apps expose a tool with the same name, FrontMCP renames **both** to `:`. See [when two apps use the same name](https://frontmcp.dev/reference/sdk/app#when-two-apps-use-the-same-name). --- ## Usage ### Grouping capabilities into an app Put everything one area needs in one app: its tools, its resources, its prompts, and the providers they share. Open the **Capabilities** tab to see what the app exposes. Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts help-desk.app.ts active import { App } from "@frontmcp/sdk"; import { SearchTickets } from "./search-tickets.tool"; import { TicketResource } from "./ticket.resource"; import { TriageTicket } from "./triage.prompt"; import { TicketStore } from "./ticket-store"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets], resources: [TicketResource], prompts: [TriageTicket], providers: [TicketStore], }) export class HelpDeskApp {} ``` ```ts search-tickets.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Tool({ name: "search_tickets", description: "Search support tickets by words in their title", inputSchema: { query: z.string() }, }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: this.get(TicketStore).search(query) }; } } ``` ```ts ticket.resource.ts import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @ResourceTemplate({ name: "ticket", uriTemplate: "ticket://{id}", mimeType: "application/json", description: "One ticket by id" }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(this.get(TicketStore).find(id)) }] }; } } ``` ```ts triage.prompt.ts import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "triage_ticket", description: "Suggest a priority and a next step for a ticket", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class TriageTicket extends PromptContext { async execute({ id }: Record): Promise { return { messages: [{ role: "user", content: { type: "text", text: `Read ticket://${id}, then suggest a priority and a next step.` } }] }; } } ``` ```ts ticket-store.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "TicketStore" }) export class TicketStore { private 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" }, ]; search(query: string) { return this.tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())); } find(id: string) { return this.tickets.find((t) => t.id === id); } } ``` ### Hosting several apps on one server List each app in `@FrontMcp({ apps })`. Clients see one server, with every app's tools in one `tools/list`. Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-7" } } ``` ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; import { BillingApp } from "./billing.app"; @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp], }) export default class Server {} ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] }) export class HelpDeskApp {} ``` ```ts billing.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_invoice", description: "Get an invoice by id", inputSchema: { id: z.string() } }) class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, total: 120, currency: "EUR", paid: false }; } } @App({ id: "billing", name: "Billing", tools: [GetInvoice] }) export class BillingApp {} ``` ```ts apps.test.ts import { test, expect } from "@frontmcp/testing"; test("one tools/list has both apps' tools", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t) => t.name); expect(names).toEqual(expect.arrayContaining(["close_ticket", "get_invoice"])); expect(names).toHaveLength(2); }); ``` ### When two apps use the same name Tool names must be unique on a server. When two apps each have a `search` tool, FrontMCP keeps both and renames them to `:`. The model sees the prefixed names. A call to the plain name still works, and reaches the first app in `apps` without saying so. Initial call configuration: ```js { tool: "billing:search", arguments: { query: "INV" } } ``` ```ts main.ts active import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search", description: "Search support tickets", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [`T-1 (matched "${query}")`] }; } } @Tool({ name: "search", description: "Search invoices", inputSchema: { query: z.string() } }) class SearchInvoices extends ToolContext { async execute({ query }: { query: string }) { return { invoices: [`INV-7 (matched "${query}")`] }; } } @App({ id: "desk", name: "Help Desk", tools: [SearchTickets] }) class DeskApp {} @App({ id: "billing", name: "Billing", tools: [SearchInvoices] }) class BillingApp {} @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [DeskApp, BillingApp] }) export default class Server {} ``` ```ts names.test.ts import { test, expect } from "@frontmcp/testing"; test("both tools are renamed with their app's id", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t) => t.name); expect(names).toEqual(["billing:search", "desk:search"]); }); test("the plain name reaches the first app", async ({ mcp }) => { expect((await mcp.tools.call("search", { query: "log" })).json()).toHaveProperty("tickets"); }); ``` The renaming only happens on a clash, so adding an app can rename tools that clients already use. Give tools names that are unique across the server, like `search_tickets` and `search_invoices`, and the question never comes up. ### Keeping providers private to an app A provider registered on an app is visible to that app only. One registered on `@FrontMcp` is shared by every app, as a single instance. Here both apps count into the same `Metrics`, but only the help desk can see `TicketStore`. Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-7" } } ``` ```ts main.ts active import { App, FrontMcp, Provider, Tool, ToolContext, z } from "@frontmcp/sdk"; @Provider({ name: "Metrics" }) class Metrics { calls = 0; } @Provider({ name: "TicketStore" }) class TicketStore { count = 3; } @Tool({ name: "count_tickets", description: "Count open tickets", inputSchema: {} }) class CountTickets extends ToolContext { async execute() { const calls = ++this.get(Metrics).calls; return { open: this.get(TicketStore).count, calls }; } } @Tool({ name: "get_invoice", description: "Get an invoice by id", inputSchema: { id: z.string() } }) class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { const calls = ++this.get(Metrics).calls; return { id, calls, canSeeTickets: this.tryGet(TicketStore) !== undefined }; } } @App({ id: "desk", name: "Help Desk", tools: [CountTickets], providers: [TicketStore] }) class DeskApp {} @App({ id: "billing", name: "Billing", tools: [GetInvoice] }) class BillingApp {} @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [DeskApp, BillingApp], providers: [Metrics] }) export default class Server {} ``` ```ts providers.test.ts import { test, expect } from "@frontmcp/testing"; test("apps share server providers, not each other's", async ({ mcp }) => { await mcp.tools.call("count_tickets", {}); const invoice = await mcp.tools.call("get_invoice", { id: "INV-7" }); expect(invoice.json()).toEqual({ id: "INV-7", calls: 2, canSeeTickets: false }); }); ``` ### Overriding server defaults for one app Some settings cascade from the server to its apps to each tool, and the most specific one wins. `output` is one: here the server hides output schemas, and the help desk app puts them in its tools' descriptions instead. ```ts main.ts active import { App, FrontMcp, 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() }), }) class TicketStats extends ToolContext { async execute() { return { open: 12, closed: 40 }; } } @Tool({ name: "invoice_stats", description: "Count paid and unpaid invoices", inputSchema: {}, outputSchema: z.object({ paid: z.number(), unpaid: z.number() }), }) class InvoiceStats extends ToolContext { async execute() { return { paid: 30, unpaid: 4 }; } } @App({ id: "desk", name: "Help Desk", tools: [TicketStats], output: { schemaMode: "description" } }) class DeskApp {} @App({ id: "billing", name: "Billing", tools: [InvoiceStats] }) class BillingApp {} @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [DeskApp, BillingApp], output: { schemaMode: "none" } }) export default class Server {} ``` ```ts output.test.ts import { test, expect } from "@frontmcp/testing"; test("the app's setting beats the server's", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "ticket_stats"); expect(tool?.outputSchema).toBeUndefined(); expect(tool?.description).toContain("**Returns:**"); }); test("other apps keep the server's setting", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "invoice_stats"); expect(tool?.outputSchema).toBeUndefined(); expect(tool?.description).toBe("Count paid and unpaid invoices"); }); ``` `schemaMode` can be `"definition"` (the default: send it as `outputSchema`), `"description"`, `"both"` or `"none"`. `ui.servingMode` cascades the same way (since 1.9.4): see [Defaults for the server and an app](https://frontmcp.dev/reference/ui#defaults-for-the-server-and-an-app). ### Building an app without a decorator `app()` builds an app from a plain object. It's useful when the list of tools comes from data, such as a config file or a loop. Initial call configuration: ```js { tool: "count_open", arguments: {} } ``` ```ts help-desk.ts import { app, tool } from "@frontmcp/sdk"; const counts = { open: 12, closed: 40, pending: 3 }; const tools = Object.entries(counts).map(([status, count]) => tool({ name: `count_${status}`, description: `How many tickets are ${status}`, inputSchema: {} })(async () => ({ count })), ); export const helpDesk = app({ id: "help-desk", name: "Help Desk", tools }); ``` --- ## Troubleshooting ### `tools items must be annotated with @Tool() | @FrontMcpTool(), be a package specifier string, or come from Tool.esm() | Tool.remote().` The server fails to start with `@App invalid metadata for "tools"`, and the index of the bad entry. That entry is a class without `@Tool`. (Changed in 1.9.1: the message used to end at `or be a package specifier string.`, before `Tool.esm()` and `Tool.remote()` entries were accepted.) Check that the decorator is there and that you imported the class, not something else with the same name. The same kind of message appears for `resources`, `prompts` and `providers`. ### A tool was renamed to `desk:search` Another app on the same server has a tool with the same name, so FrontMCP prefixed both with their app's `id`. Rename one of the tools. See [when two apps use the same name](https://frontmcp.dev/reference/sdk/app#when-two-apps-use-the-same-name). ### `Provider "TicketStore" is not available: not found in local or parent registries` The tool's app can't see the provider. Often it's registered on a **different** app: Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-7" } } ``` ```ts main.ts active import { App, FrontMcp, Provider, Tool, ToolContext, z } from "@frontmcp/sdk"; @Provider({ name: "TicketStore" }) class TicketStore { forInvoice(id: string) { return ["T-2"]; } } @Tool({ name: "get_invoice", description: "Get an invoice and its tickets", inputSchema: { id: z.string() } }) class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, tickets: this.get(TicketStore).forInvoice(id) }; } } // 🚩 TicketStore belongs to the help desk app, so billing can't see it @App({ id: "desk", name: "Help Desk", providers: [TicketStore] }) class DeskApp {} @App({ id: "billing", name: "Billing", tools: [GetInvoice] }) class BillingApp {} @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [DeskApp, BillingApp] }) export default class Server {} ``` Move the provider to `@FrontMcp({ providers })` to share it, or register it in the app that uses it. ### A standalone app's tools are missing With `standalone: true`, an app is only served at `/`, not on the main endpoint. Point the client at the app's own URL, or use `standalone: "includeInParent"` to serve it in both places. --- # @Tool > Declare a function an AI model can call, with validated input and optional structured output. Source: https://frontmcp.dev/reference/sdk/tool [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `@Tool` declares an MCP [tool](https://frontmcp.dev/learn/your-first-tool): a function an AI model can decide to call. FrontMCP checks every call against the tool's input schema before your code runs. ```ts @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. ```ts 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.](https://frontmcp.dev/reference/sdk/tool#usage) #### 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](https://frontmcp.dev/learn/shaping-tool-results#declaring-the-shape-with-outputschema)). | | `annotations` | `ToolAnnotations` | Hints for clients: `title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. See [describing side effects](https://frontmcp.dev/reference/sdk/tool#describing-side-effects-with-annotations). | | `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](https://frontmcp.dev/reference/sdk/guard). | | `concurrency` | `ConcurrencyConfig` | Cap on simultaneous executions: `maxConcurrent`, optional queue. See [Guard options](https://frontmcp.dev/reference/sdk/guard). | | `timeout` | `TimeoutConfig` | Execution timeout: `executeMs`. See [Guard options](https://frontmcp.dev/reference/sdk/guard). | | `authProviders` | `ToolAuthProviderRef[]` | Credentials the tool needs, such as `{ name: "github", scopes: ["repo"] }`. Required by default. See [Progressive auth](https://frontmcp.dev/reference/auth/progressive#credentials-authproviders-and-thiscredentials). | | `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](https://frontmcp.dev/reference/sdk/agent#giving-an-agent-tools) only. Since 1.8.4, `surface` holds for agents and [jobs](https://frontmcp.dev/reference/sdk/call-tool#where-it-works) too. See [Environment awareness](https://frontmcp.dev/reference/server/environment). | | `execution` | `{ taskSupport }` | Whether the tool can run as a background task: `"required"`, `"optional"` or `"forbidden"`. See [Background tasks](https://frontmcp.dev/reference/server/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](https://frontmcp.dev/reference/ui#fields-the-schema-doesnt-declare)). | Plugins add options of their own, which only take effect where the plugin is installed: `cache` ([Cache](https://frontmcp.dev/reference/plugins/cache#cache-in-tool)), `approval` ([Approval](https://frontmcp.dev/reference/plugins/approval#the-approval-tool-option)), `featureFlag` ([Feature flags](https://frontmcp.dev/reference/plugins/feature-flags#the-featureflag-option)) and `codecall` ([CodeCall](https://frontmcp.dev/reference/plugins/codecall#the-codecall-field-on-tool)). A server whose tool sets `approval` or `featureFlag`, with no plugin that enforces it, doesn't start: see [`Unenforced metadata`](https://frontmcp.dev/reference/sdk/plugin#unenforced-metadata-tool--declares-). #### `execute(input)` FrontMCP calls `execute()` once the arguments pass validation. Inside it, `this` is a `ToolContext`: [Context classes](https://frontmcp.dev/reference/sdk/contexts) 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](https://frontmcp.dev/reference/sdk/tool#typescript-says-unable-to-resolve-signature-of-class-decorator)). - 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`](https://frontmcp.dev/reference/sdk/tool#returning-an-error-the-model-can-read) for messages the model should read. - The class can declare [hooks](https://frontmcp.dev/reference/sdk/hooks#where-hooks-can-be-declared) 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. Initial call configuration: ```js { tool: "create_ticket", arguments: { title: "Can't reset my password", priority: "high" } } ``` ```ts create-ticket.tool.ts 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 }; } } ``` ### 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: Plain value A number, string or boolean is wrapped as `{ "value": ... }`. Initial call configuration: ```js { tool: "count_open", arguments: {} } ``` ```ts count-open.tool.ts 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; } } ``` ### Example: Object An object arrives as `structuredContent` as it is. Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts get-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one ticket by id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } ``` ### Example: Plain text With `outputSchema: "string"`, the text block is the string itself, and `structuredContent` is `{ "content": ... }`. Initial call configuration: ```js { tool: "ticket_status", arguments: { id: "T-1" } } ``` ```ts ticket-status.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "ticket_status", description: "Get the status of a ticket, as a sentence", inputSchema: { id: z.string() }, outputSchema: "string", }) export class TicketStatus extends ToolContext { async execute({ id }: { id: string }) { return `Ticket ${id} is open.`; } } ``` ```ts ticket-status.test.ts import { test, expect } from "@frontmcp/testing"; test("the text is the sentence itself", async ({ mcp }) => { const result = await mcp.tools.call("ticket_status", { id: "T-1" }); expect(result.text()).toBe("Ticket T-1 is open."); expect(result.raw.structuredContent).toEqual({ content: "Ticket T-1 is open." }); }); ``` ### Example: Declared output With `outputSchema`, clients see the result's shape in `tools/list` before they call, and a result that doesn't match fails with `INVALID_OUTPUT`. Initial call configuration: ```js { tool: "ticket_stats", arguments: {} } ``` ```ts ticket-stats.tool.ts 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() }), annotations: { readOnlyHint: true }, }) export class TicketStats extends ToolContext { async execute() { return { open: 12, closed: 40 }; } } ``` ### 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. Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-404" } } ``` ```ts get-ticket.tool.ts 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" }; } } ``` ### 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. | ```ts delete-ticket.tool.ts 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 }; } } ``` ### Using a provider Get shared services with `this.get()`. Register the provider in the same app's `providers` array. Initial call configuration: ```js { tool: "whoami", arguments: {} } ``` ```ts whoami.tool.ts active 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(); } } ``` ```ts directory.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; @Provider({ name: "Directory", scope: ProviderScope.GLOBAL }) export class Directory { onDuty() { return { team: "EMEA support", lead: "Nour" }; } } ``` ### 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. Initial call configuration: ```js { tool: "shout", arguments: { text: "hello" } } ``` ```ts shout.tool.ts import { tool, z } from "@frontmcp/sdk"; export const shout = tool({ name: "shout", description: "Uppercase some text", inputSchema: { text: z.string() }, })(async ({ text }) => text.toUpperCase()); ``` --- ## 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: ```ts // 🚩 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. Initial call configuration: ```js { tool: "ticket_stats", arguments: {} } ``` ```ts ticket-stats.tool.ts 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 = { open: 12, pending: 3 }; // no "closed" today return { open: counts.open, closed: counts.closed }; } } ``` ```ts ticket-stats.test.ts import { test, expect } from "@frontmcp/testing"; test("a missing field fails the call", async ({ mcp }) => { const result = await mcp.tools.call("ticket_stats", {}); expect(result.raw._meta?.code).toBe("INVALID_OUTPUT"); expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at closed)"); }); ``` 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. --- # @Resource > Declare data at a fixed URI that clients can list and read, like a policy document or a settings file. Source: https://frontmcp.dev/reference/sdk/resource [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `@Resource` declares an MCP [resource](https://frontmcp.dev/learn/exposing-data-with-resources): data at a fixed URI, like `policy://sla`, that a client can list and read. The client application decides when to read it, usually to attach it to a conversation. For a family of URIs that share a pattern, like `ticket://{id}`, use [`@ResourceTemplate`](https://frontmcp.dev/reference/sdk/resource-template). ```ts @Resource(options) class MyResource extends ResourceContext { async execute(uri) { /* ... */ } } ``` --- ## Reference ### `@Resource(options)` Apply `@Resource` to a class that extends `ResourceContext`, and list the class in an app's `resources` array. ```ts sla.resource.ts import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "sla", uri: "policy://sla", description: "How fast the help desk replies, by ticket priority", mimeType: "application/json", }) class SlaPolicy extends ResourceContext { async execute() { return { high: "4 hours", normal: "1 business day", low: "3 business days" }; } } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/resource#usage) #### Options Required: | Option | Type | Description | | --- | --- | --- | | `name` | `string` | Identifies the resource. Unique within the server. | | `uri` | `string` | The address clients read. It must start with a scheme and `://`, like `policy://sla` or `file:///docs/faq.md`. | Optional: | Option | Type | Description | | --- | --- | --- | | `title` | `string` | A display name for people. Clients show it instead of `name`. | | `description` | `string` | What the data is and when it's useful. Clients and models read it in `resources/list`. | | `mimeType` | `string` | The content type, like `application/json`. Listed in `resources/list`, and used for the content blocks FrontMCP builds from what `execute()` returns. Without it, FrontMCP picks one per block. Blocks you return in `{ contents }` are sent as they are. | | `icons` | `Icon[]` | Images clients can show: `src`, and optionally `mimeType`, `sizes` and `theme` (`"light"` or `"dark"`). | | `annotations` | `ResourceAnnotations` | Hints for clients: `audience` (`["user"]`, `["assistant"]` or both), `priority` (from 0 to 1) and `lastModified` (an ISO 8601 date). | | `_meta` | `Record` | Extra metadata, sent as is in `resources/list`. Use reverse-DNS keys, like `com.example/owner`. | | `availableWhen` | `EntryAvailability` | Only offer the resource on certain platforms, runtimes or deployments. Elsewhere it isn't listed, and reading it fails with [`-32003`](https://frontmcp.dev/reference/sdk/resource#reads-fail-with--32003-resource--is-not-available-in-the-current-environment). See [Environment awareness](https://frontmcp.dev/reference/server/environment). | #### `execute(uri, params)` FrontMCP calls `execute()` every time a client reads the resource. - `uri`: the URI that was read. For a `@Resource`, that's always its `uri` option. - `params`: template parameters. Always `{}` for a `@Resource`. - **Returns** the content. FrontMCP turns it into the `contents` array of the `resources/read` result: | `execute()` returns | The client receives | | --- | --- | | An object or number, like `{ high: "4 hours" }` | One text block with the value as JSON. MIME type `application/json`. | | A string | One text block. The MIME type comes from the URI's extension (`.md` is `text/markdown`) or the text itself, and is `text/plain` otherwise. | | A `Uint8Array` or `Buffer` | One `blob` block, base64-encoded. MIME type `application/octet-stream`, unless you set `mimeType`. | | An object with a `text` or `blob` string | That block, with the resource's URI. | | An array | One block per item, converted as above. Items without their own `uri` get `#0`, `#1`… appended to the resource's URI. | | `{ contents: [...] }` | Exactly those blocks. Use this for full control. | Inside `execute()`, `this` is the `ResourceContext`: `this.uri` and `this.params` hold the same values as the arguments, `this.get()` returns [providers](https://frontmcp.dev/reference/sdk/provider), `this.fetch()` makes outbound HTTP requests, `this.context` and `this.auth` describe the request and the caller, and `this.notifyUpdated()` tells subscribed clients that the content changed. `this.notify()`, `this.progress()` and `this.elicit()` belong to tools, not resources. #### `resource(options)(handler)` The function form takes the same options. The handler receives `(uri, params)` and returns a `ReadResourceResult`, the `{ contents: [...] }` shape. #### Caveats - The class **must extend `ResourceContext`**. Using `@Resource` on any other class is a compile error. - `uri` **must have a scheme followed by `://`**. Otherwise the server doesn't start, and reports "URI must have a valid scheme". - Reads match the URI **exactly**. `policy://sla/` and `policy://SLA` are different URIs, and reading them fails with "Resource not found". - A resource takes no arguments. For data that depends on an id, use [`@ResourceTemplate`](https://frontmcp.dev/reference/sdk/resource-template). If a resource and a template both match a URI, the resource wins. - If `execute()` throws `ResourceNotFoundError(uri)`, the client gets the same error as for a URI that matches nothing: `-32602`, `Resource not found: …`. A `PublicMcpError` also gives `-32602`, with your error's message as the whole message. - Any other error is an internal one: JSON-RPC error `-32603`, with `Resource "…" read failed:` and your error's message in development. With `NODE_ENV=production`, FrontMCP hides the message and sends `Internal FrontMCP error. Please contact support with error ID: …` instead. See [troubleshooting](https://frontmcp.dev/reference/sdk/resource#reads-fail-with--32603-resource--read-failed). - `this.fail(error)` fails the read the same way as `throw error` for a `PublicMcpError`. For any other error it's still `-32603`, but the message is your error's message without the `read failed:` prefix, and `data.code` is `SERVER_ERROR` instead of `RESOURCE_READ_ERROR`. Production hides it the same way. --- ## Usage ### Exposing data at a fixed URI Give the resource a URI with your own scheme and a description that says what's in it. Return a plain object and FrontMCP sends it as JSON. Initial call configuration: ```js { resource: "policy://sla" } ``` ```ts sla.resource.ts import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "sla", title: "Reply-time policy", uri: "policy://sla", description: "How fast the help desk replies, by ticket priority. Read it before promising a customer a reply time.", }) export class SlaPolicy extends ResourceContext { async execute() { return { high: "4 hours", normal: "1 business day", low: "3 business days" }; } } ``` Open the **Capabilities** tab to see the resource as `resources/list` describes it. Clients read it with `resources/read` and the URI. ### Choosing what the client receives What `execute()` returns decides the content blocks and their MIME types. ### Content types ### Example: Text A string becomes one text block. The URI ends in `.md`, so the MIME type is `text/markdown`. Initial call configuration: ```js { resource: "guide://replies.md" } ``` ```ts replies.resource.ts import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "reply-guide", uri: "guide://replies.md", description: "How to word replies to customers" }) export class ReplyGuide extends ResourceContext { async execute() { return "# Replying to customers\n\n- Greet them by name.\n- Say what you did, then what happens next.\n- Link the ticket id, like T-1."; } } ``` ### Example: Full control Return `{ contents }` to set every field yourself, here a CSV export. Initial call configuration: ```js { resource: "export://tickets/open.csv" } ``` ```ts export.resource.ts import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "open-tickets-csv", uri: "export://tickets/open.csv", description: "Open tickets as CSV" }) export class OpenTicketsCsv extends ResourceContext { async execute(uri: string) { const csv = "id,title,priority\nT-1,Cannot log in,high\nT-3,Login link expired,normal"; return { contents: [{ uri, mimeType: "text/csv", text: csv }] }; } } ``` ### Example: Binary Bytes arrive base64-encoded in `blob`. Set `mimeType`, or clients get `application/octet-stream`. Initial call configuration: ```js { resource: "brand://logo.png" } ``` ```ts logo.resource.ts import { Resource, ResourceContext } from "@frontmcp/sdk"; // The first bytes of a PNG file, to keep the example short. const png = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); @Resource({ name: "logo", uri: "brand://logo.png", mimeType: "image/png", description: "The help desk logo" }) export class Logo extends ResourceContext { async execute() { return png; } } ``` ### Example: Several blocks Return an array for several blocks. Blocks without a `uri` get `#0`, `#1`… after the resource's URI. Initial call configuration: ```js { resource: "policy://handbook" } ``` ```ts handbook.resource.ts import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "handbook", uri: "policy://handbook", description: "The support handbook, one block per section" }) export class Handbook extends ResourceContext { async execute(uri: string) { return [ { text: "Escalate high-priority tickets after 2 hours.", mimeType: "text/plain" }, { uri: `${uri}#hours`, mimeType: "application/json", text: JSON.stringify({ open: "08:00", close: "18:00" }) }, ]; } } ``` ### Reading from a provider Get shared services with `this.get()`, and register the provider in the same app's `providers` array. Initial call configuration: ```js { resource: "desk://on-duty" } ``` ```ts on-duty.resource.ts active import { Resource, ResourceContext } from "@frontmcp/sdk"; import { Rota } from "./rota"; @Resource({ name: "on-duty", uri: "desk://on-duty", description: "Which agents are on duty right now" }) export class OnDuty extends ResourceContext { async execute() { return { agents: this.get(Rota).onDuty() }; } } ``` ```ts rota.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; @Provider({ name: "Rota", scope: ProviderScope.GLOBAL }) export class Rota { onDuty() { return ["Nour", "Sam"]; } } ``` ### Describing a resource for clients Everything except `availableWhen` goes out in `resources/list`. Clients use `title` and `icons` for display, and `annotations` to decide who the content is for and how prominent it should be. The test below reads the list the way a client would. ```ts sla.resource.ts import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "sla", title: "Reply-time policy", uri: "policy://sla", description: "How fast the help desk replies, by ticket priority", mimeType: "application/json", annotations: { audience: ["assistant"], priority: 0.8, lastModified: "2026-09-01T09:00:00Z" }, _meta: { "com.example/owner": "support-ops" }, }) export class SlaPolicy extends ResourceContext { async execute() { return { high: "4 hours", normal: "1 business day", low: "3 business days" }; } } ``` ```ts sla.test.ts import { test, expect } from "@frontmcp/testing"; test("resources/list describes the policy", async ({ mcp }) => { const resources = await mcp.resources.list(); expect(resources).toContainResource("policy://sla"); expect(resources[0]).toMatchObject({ name: "sla", title: "Reply-time policy", mimeType: "application/json", annotations: { audience: ["assistant"], priority: 0.8 }, _meta: { "com.example/owner": "support-ops" }, }); }); test("reading it returns JSON", async ({ mcp }) => { const content = await mcp.resources.read("policy://sla"); expect(content).toHaveMimeType("application/json"); expect(content.json()).toEqual({ high: "4 hours", normal: "1 business day", low: "3 business days" }); }); ``` ### Writing a resource as a function For a resource that doesn't need context, the function form is shorter. Its handler returns the full `{ contents }` shape, which is sent as it is, so set each block's `mimeType` there. Initial call configuration: ```js { resource: "policy://hours" } ``` ```ts hours.resource.ts import { resource } from "@frontmcp/sdk"; export const openingHours = resource({ name: "opening-hours", uri: "policy://hours", mimeType: "application/json", description: "When the help desk answers tickets", })((uri) => ({ contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ weekdays: "08:00-18:00", weekends: "closed" }) }], })); ``` --- ## Troubleshooting ### The server doesn't start: "URI must have a valid scheme" The `uri` option needs a scheme followed by `://`. `policy:sla` and `/policy/sla` are rejected; `policy://sla` works. ### Reads fail with `-32602` "Resource not found" No resource or template matches the URI exactly, or `execute()` threw `ResourceNotFoundError`. Check for a trailing slash, different letter case, or a typo, and compare with `resources/list`. Under MCP 2026-07-28 this error has code `-32602`; earlier protocol versions use `-32002`. A resource whose `availableWhen.surface` leaves out `"mcp"` gets the same answer: MCP clients can't read it. Initial call configuration: ```js { resource: "policy://SLA" } ``` ```ts sla.resource.ts import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "sla", uri: "policy://sla" }) export class SlaPolicy extends ResourceContext { async execute() { return { high: "4 hours" }; } } ``` ### Reads fail with `-32603` "Resource … read failed" `execute()` threw an error that isn't a `PublicMcpError`, so FrontMCP treats it as a failure of the server. In development, the message after `read failed:` is your error's message: Initial call configuration: ```js { resource: "policy://sla" } ``` ```ts sla.resource.ts import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "sla", uri: "policy://sla" }) export class SlaPolicy extends ResourceContext { async execute(): Promise { // 🚩 A server failure, and production hides the message throw new Error("The SLA policy hasn't been published yet"); } } ``` With `NODE_ENV=production`, the client only gets `Internal FrontMCP error. Please contact support with error ID: …`. If the client should read the message, throw a `PublicMcpError`. The read then fails with `-32602` and your message, in production too: Initial call configuration: ```js { resource: "policy://sla" } ``` ```ts sla.resource.ts active import { PublicMcpError, Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "sla", uri: "policy://sla" }) export class SlaPolicy extends ResourceContext { async execute(): Promise { // ✅ The client sees exactly this message throw new PublicMcpError("The SLA policy hasn't been published yet"); } } ``` ```ts sla.test.ts import { test, expect } from "@frontmcp/testing"; test("the message reaches the client as it was thrown", async ({ mcp }) => { const result = await mcp.resources.read("policy://sla"); expect(result).toBeError(-32602); expect(result.error?.message).toBe("The SLA policy hasn't been published yet"); }); ``` A message ending in `read failed: Resource output not found` means `execute()` returned nothing. Return the content, or end with [`this.respond()`](https://frontmcp.dev/reference/sdk/respond), which works in resources since 1.9.3. ### Reads fail with `-32003` "Resource "…" is not available in the current environment" The resource's `availableWhen` doesn't match where the server runs. It's left out of `resources/list`, and a client that reads it by URI gets this error, with what the resource requires and what the server is: Initial call configuration: ```js { resource: "policy://deno-deploy" } ``` ```ts deploy.resource.ts active import { Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ name: "deploy-notes", uri: "policy://deno-deploy", mimeType: "text/plain", availableWhen: { runtime: ["deno"] } }) export class DeployNotes extends ResourceContext { async execute() { return "Deploy with deployctl."; } } ``` ```ts deploy.test.ts import { test, expect } from "@frontmcp/testing"; test("it isn't listed, and reading it fails", async ({ mcp }) => { expect(await mcp.resources.list()).toEqual([]); const result = await mcp.resources.read("policy://deno-deploy"); expect(result).toBeError(-32003); expect(result.error?.message).toMatch(/^Resource "policy:\/\/deno-deploy" is not available in the current environment \(requires: \{"runtime":\["deno"\]\}\)/); }); ``` The message lists the server's OS, runtime and deployment, in production too. If the resource should be readable here, fix its `availableWhen`. ### My resource isn't in `resources/list` Check, in order: 1. The class is in its app's `resources` array, and the app is in `@FrontMcp({ apps })`. 2. It's a `@Resource`. Templates are listed separately, in `resources/templates/list`. 3. Its `availableWhen` matches the environment the server runs in. --- # @ResourceTemplate > Declare a family of resources whose URIs follow a pattern, like ticket://{id}, and read the parameters from the URI. Source: https://frontmcp.dev/reference/sdk/resource-template [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `@ResourceTemplate` declares a family of MCP [resources](https://frontmcp.dev/learn/exposing-data-with-resources) that share a URI pattern, like `ticket://{id}`. Clients fill in the pattern to read one member, such as `ticket://T-1`, and FrontMCP passes the parts it matched to your code. For a single resource at a fixed URI, use [`@Resource`](https://frontmcp.dev/reference/sdk/resource). ```ts @ResourceTemplate(options) class MyTemplate extends ResourceContext { async execute(uri, params) { /* ... */ } } ``` --- ## Reference ### `@ResourceTemplate(options)` Apply `@ResourceTemplate` to a class that extends `ResourceContext`, and list the class in an app's `resources` array, next to static resources. ```ts ticket.resource.ts import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk"; @ResourceTemplate({ name: "ticket", uriTemplate: "ticket://{id}", description: "One support ticket by id, like ticket://T-1", mimeType: "application/json", }) class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, params: { id: string }) { const ticket = await findTicket(params.id); if (!ticket) throw new ResourceNotFoundError(uri); return ticket; } } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/resource-template#usage) #### Options Required: | Option | Type | Description | | --- | --- | --- | | `name` | `string` | Identifies the template. Unique within the server. | | `uriTemplate` | `string` | The URI pattern, with parameters in braces, like `ticket://{id}`. It must start with a scheme and `://`. See [URI template syntax](https://frontmcp.dev/reference/sdk/resource-template#uri-template-syntax). | Optional: | Option | Type | Description | | --- | --- | --- | | `title` | `string` | A display name for people. Clients show it instead of `name`. | | `description` | `string` | What each resource is, and what the parameters mean. Clients and models read it in `resources/templates/list`. | | `mimeType` | `string` | The content type of every matching resource. Listed in `resources/templates/list`, and used for content blocks FrontMCP builds from what `execute()` returns. | | `icons` | `Icon[]` | Images clients can show: `src`, and optionally `mimeType`, `sizes` and `theme`. | | `annotations` | `ResourceAnnotations` | `audience`, `priority` and `lastModified`, as for [`@Resource`](https://frontmcp.dev/reference/sdk/resource#options). FrontMCP 1.8 accepts them but leaves them out of `resources/templates/list`. | | `_meta` | `Record` | Extra metadata. Like `annotations`, FrontMCP 1.8 doesn't send it in `resources/templates/list`. | | `availableWhen` | `EntryAvailability` | Only offer the template on certain platforms, runtimes or deployments. Elsewhere it isn't in `resources/templates/list`, and reading a URI it matches fails with [`-32003`](https://frontmcp.dev/reference/sdk/resource-template#reads-fail-with--32003-resourcetemplate--is-not-available-in-the-current-environment). See [Environment awareness](https://frontmcp.dev/reference/server/environment). | #### URI template syntax | Pattern | Matches | Example | | --- | --- | --- | | `{name}` | One path segment: anything up to the next `/` | `ticket://{id}` matches `ticket://T-1` with `id` = `"T-1"` | | `{+name}` | One or more segments, slashes included | `kb://{+path}` matches `kb://billing/refunds.md` with `path` = `"billing/refunds.md"` | | Other text | Itself, exactly | `customer://{customer}/tickets` needs the literal `/tickets` | Parameter values are percent-decoded, so reading `note://a%2Fb` from `note://{id}` gives `id` = `"a/b"`. #### `execute(uri, params)` FrontMCP calls `execute()` for each read whose URI matches the template. - `uri`: the URI that was read, like `ticket://T-1`. - `params`: the matched parameters, like `{ id: "T-1" }`. Values are always strings. Type them with `ResourceContext<{ id: string }>`. - **Returns** the content, converted as for [`@Resource`](https://frontmcp.dev/reference/sdk/resource#executeuri-params). Content FrontMCP builds for you is labelled with the URI that was read, like `ticket://T-1`, and a string's MIME type can come from that URI's extension. Blocks you return in `{ contents }` are sent as they are. `this.uri` and `this.params` hold the same values as the arguments. The rest of `this` is the same `ResourceContext` as for `@Resource`. #### Completing parameters Add a method named after a parameter with `Completer` appended, like `idCompleter` for `{id}`. When a client sends `completion/complete` for the template, FrontMCP calls it with what the user has typed so far. ```ts async idCompleter(partial: string) { return { values: ["T-1", "T-12"], total: 2, hasMore: false }; } ``` It returns `values`, and optionally `total` and `hasMore`. Completers can use `this.get()`. To choose a completer at run time instead, override `getArgumentCompleter(name)` and return a function, or `null`. #### `resourceTemplate(options)(handler)` The function form takes the same options. The handler receives `(uri, params)` and returns the `{ contents: [...] }` shape. Function-form templates can't have completers. #### Caveats - The class **must extend `ResourceContext`**. Using `@ResourceTemplate` on any other class is a compile error. - **Parameters are strings, and they aren't validated.** `ticket://anything` matches `ticket://{id}`. Check the value in `execute()`. - A `{name}` parameter **doesn't match `/`**. Use `{+name}` for paths. - If a [`@Resource`](https://frontmcp.dev/reference/sdk/resource) has exactly the URI that was read, it wins over any template. - Templates are listed in `resources/templates/list`, not `resources/list`. - To answer "not found" for parameters that don't point at anything, throw `ResourceNotFoundError(uri)`. The client gets `-32602`, the same error as for a URI no template matches. Other errors fail the read as they do for [`@Resource`](https://frontmcp.dev/reference/sdk/resource#caveats). --- ## Usage ### Reading one item by id Put the id in the URI, and return the item. FrontMCP sends it as JSON, labelled with the URI that was read. Initial call configuration: ```js { resource: "ticket://T-2" } ``` ```ts ticket.resource.ts active import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @ResourceTemplate({ name: "ticket", uriTemplate: "ticket://{id}", description: "One support ticket by id, like ticket://T-1", mimeType: "application/json", }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { return tickets.find((t) => t.id === id); } } ``` ```ts tickets.ts export const tickets = [ { id: "T-1", customer: "acme", title: "Cannot log in", status: "open" }, { id: "T-2", customer: "acme", title: "Invoice total is wrong", status: "closed" }, { id: "T-3", customer: "globex", title: "Login link expired", status: "open" }, ]; ``` In the **Call** tab, pick `ticket://{id}` from the **Request** menu and read any id. The content's `uri` is the one you read. ### Handling an id that doesn't exist Throw `ResourceNotFoundError` with the URI. The client gets a `-32602` error that names it, the same as for a URI that matches no template at all. Initial call configuration: ```js { resource: "ticket://T-9" } ``` ```ts ticket.resource.ts active import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @ResourceTemplate({ name: "ticket", uriTemplate: "ticket://{id}", mimeType: "application/json" }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = tickets.find((t) => t.id === id); if (!ticket) throw new ResourceNotFoundError(uri); return ticket; } } ``` ```ts tickets.ts export const tickets = [ { id: "T-1", title: "Cannot log in", status: "open" }, { id: "T-2", title: "Invoice total is wrong", status: "closed" }, ]; ``` ```ts not-found.test.ts import { test, expect } from "@frontmcp/testing"; test("an unknown id is not found", async ({ mcp }) => { const result = await mcp.resources.read("ticket://T-9"); expect(result).toBeError(-32602); expect(result.error?.message).toBe("Resource not found: ticket://T-9"); }); test("a known id still reads", async ({ mcp }) => { expect((await mcp.resources.read("ticket://T-1")).json()).toMatchObject({ id: "T-1", status: "open" }); }); ``` To send a message of your own instead, like `There's no ticket T-9. Ticket ids look like T-1.`, throw a `PublicMcpError`: the read fails with `-32602` and exactly that message. A plain `Error` is a server failure, `-32603`, and production hides its message. See [`@Resource`](https://frontmcp.dev/reference/sdk/resource#reads-fail-with--32603-resource--read-failed). ### Using several parameters Each `{name}` becomes a key in `params`. Literal text between them has to match exactly. Initial call configuration: ```js { resource: "customer://acme/tickets/open" } ``` ```ts customer-tickets.resource.ts active import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; import { tickets } from "./tickets"; type Params = { customer: string; status: string }; @ResourceTemplate({ name: "customer-tickets", uriTemplate: "customer://{customer}/tickets/{status}", description: "A customer's tickets with one status, open or closed", mimeType: "application/json", }) export class CustomerTickets extends ResourceContext { async execute(uri: string, { customer, status }: Params) { const found = tickets.filter((t) => t.customer === customer && t.status === status); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ customer, status, tickets: found }) }] }; } } ``` ```ts tickets.ts export const tickets = [ { id: "T-1", customer: "acme", title: "Cannot log in", status: "open" }, { id: "T-2", customer: "acme", title: "Invoice total is wrong", status: "closed" }, { id: "T-3", customer: "acme", title: "Login link expired", status: "open" }, ]; ``` ### Matching paths with slashes `{name}` stops at the first `/`. Use `{+name}` when a parameter is a path. ### Path parameters ### Example: {+path} matches the whole path Initial call configuration: ```js { resource: "kb://billing/refunds.md" } ``` ```ts kb.resource.ts import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; @ResourceTemplate({ name: "kb-article", uriTemplate: "kb://{+path}", description: "A knowledge-base article by path" }) export class KbArticle extends ResourceContext<{ path: string }> { async execute(uri: string, { path }: { path: string }) { return { contents: [{ uri, mimeType: "text/markdown", text: `# ${path}\n\nRefunds take 5 business days.` }] }; } } ``` ### Example: {path} doesn't match a slash The same read fails with "Resource not found", because `{path}` only matches one segment. Initial call configuration: ```js { resource: "kb://billing/refunds.md" } ``` ```ts kb.resource.ts import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; @ResourceTemplate({ name: "kb-article", uriTemplate: "kb://{path}", description: "A knowledge-base article by path" }) export class KbArticle extends ResourceContext<{ path: string }> { async execute(uri: string, { path }: { path: string }) { return { contents: [{ uri, mimeType: "text/markdown", text: `# ${path}` }] }; } } ``` ### Example: An encoded slash matches {path} A client can percent-encode the slash instead. FrontMCP decodes it, so `path` is `"billing/refunds.md"` again. Initial call configuration: ```js { resource: "kb://billing%2Frefunds.md" } ``` ```ts kb.resource.ts import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; @ResourceTemplate({ name: "kb-article", uriTemplate: "kb://{path}", description: "A knowledge-base article by path" }) export class KbArticle extends ResourceContext<{ path: string }> { async execute(uri: string, { path }: { path: string }) { return { contents: [{ uri, mimeType: "text/markdown", text: `# ${path}` }] }; } } ``` ### Suggesting parameter values Clients can offer suggestions as the user types an id. Add an `idCompleter()` method, and FrontMCP answers `completion/complete` requests for the `{id}` parameter with it. The test sends the request a client would. ```ts ticket.resource.ts active import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @ResourceTemplate({ name: "ticket", uriTemplate: "ticket://{id}", mimeType: "application/json" }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(tickets.find((t) => t.id === id)) }] }; } async idCompleter(partial: string) { const values = tickets.map((t) => t.id).filter((id) => id.startsWith(partial)); return { values, total: values.length }; } } ``` ```ts tickets.ts export const tickets = [ { id: "T-1", title: "Cannot log in" }, { id: "T-2", title: "Invoice total is wrong" }, { id: "T-12", title: "Export times out" }, ]; ``` ```ts complete.test.ts import { test, expect } from "@frontmcp/testing"; test("typing T-1 suggests T-1 and T-12", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "completion/complete", params: { ref: { type: "ref/resource", uri: "ticket://{id}" }, argument: { name: "id", value: "T-1" } }, }); expect(response).toHaveResult(); expect(response.result.completion).toEqual({ values: ["T-1", "T-12"], total: 2 }); }); test("the template is listed", async ({ mcp }) => { expect(await mcp.resources.listTemplates()).toContainResourceTemplate("ticket://{id}"); }); ``` ### Writing a template as a function For a template that doesn't need context, the function form is shorter. Its handler gets the same `(uri, params)` and returns the full `{ contents }` shape. Initial call configuration: ```js { resource: "agent://nour/queue" } ``` ```ts queue.resource.ts import { resourceTemplate } from "@frontmcp/sdk"; export const agentQueue = resourceTemplate({ name: "agent-queue", uriTemplate: "agent://{agent}/queue", description: "The tickets assigned to one support agent", mimeType: "application/json", })((uri, { agent }) => ({ contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ agent, tickets: ["T-1", "T-3"] }) }], })); ``` --- ## Troubleshooting ### The server doesn't start: "URI template must have a valid scheme" `uriTemplate` needs a scheme followed by `://`, like `ticket://{id}`. `{id}` or `ticket:{id}` are rejected. ### Reads fail with `-32602` "Resource not found" No template matched the URI. Check, in order: 1. The literal parts match exactly, including case and slashes. 2. No `{name}` value contains a `/`. Use `{+name}`, or have the client percent-encode the value with `encodeURIComponent()`. 3. The template is in its app's `resources` array. 4. `execute()` threw `ResourceNotFoundError`, which gives the same error. Check that the item exists. 5. The template's `availableWhen.surface`, if it has one, includes `"mcp"`. MCP clients can't read a template that leaves it out. ### Reads fail with `-32003` "ResourceTemplate "…" is not available in the current environment" The template's `availableWhen` doesn't match where the server runs. It's left out of `resources/templates/list`, and reading a URI it matches fails with this error, which names the URI, what the template requires and what the server is: Initial call configuration: ```js { resource: "deploy://T-1/notes" } ``` ```ts deploy-notes.template.ts active import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; @ResourceTemplate({ name: "deploy-notes", uriTemplate: "deploy://{id}/notes", mimeType: "text/plain", availableWhen: { runtime: ["deno"] } }) export class DeployNotes extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { return `Deploy notes for ${id}`; } } ``` ```ts deploy.test.ts import { test, expect } from "@frontmcp/testing"; test("it isn't listed, and reading a matching URI fails", async ({ mcp }) => { expect(await mcp.resources.listTemplates()).toEqual([]); const result = await mcp.resources.read("deploy://T-1/notes"); expect(result).toBeError(-32003); expect(result.error?.message).toMatch(/^ResourceTemplate "deploy:\/\/T-1\/notes" is not available in the current environment \(requires: \{"runtime":\["deno"\]\}\)/); }); ``` ### My template isn't in `resources/list` That's expected. Clients find templates with `resources/templates/list`, and `mcp.resources.listTemplates()` in tests. ### Completions come back empty Check that the method is named after the parameter, `idCompleter` for `{id}`, and that the template is a class. If the completer throws, FrontMCP logs a warning and answers with no values. --- # @Prompt > Declare a reusable message template that users pick in their client and fill in with arguments. Source: https://frontmcp.dev/reference/sdk/prompt [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `@Prompt` declares an MCP [prompt](https://frontmcp.dev/learn/reusable-prompts): a message template that a user picks in their client, often from a slash menu, and fills in with arguments. FrontMCP runs your `execute()` with those arguments and returns the messages it builds, which the client then puts in front of the model. ```ts @Prompt(options) class MyPrompt extends PromptContext { async execute(args): Promise { /* ... */ } } ``` --- ## Reference ### `@Prompt(options)` Apply `@Prompt` to a class that extends `PromptContext`, and list the class in an app's `prompts` array. ```ts triage-ticket.prompt.ts import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "triage_ticket", title: "Triage a ticket", description: "Suggest a priority and a next step for a support ticket", arguments: [ { name: "id", description: "Ticket id, like T-1", required: true }, { name: "tone", description: "friendly or formal" }, ], }) class TriageTicket extends PromptContext { async execute(args: Record): Promise { return { messages: [ { role: "user", content: { type: "text", text: `Read ticket ${args.id}, then suggest a priority and a next step.` } }, ], }; } } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/prompt#usage) #### Options Required: | Option | Type | Description | | --- | --- | --- | | `name` | `string` | What clients list and ask for with `prompts/get`. Unique within the server. | Optional: | Option | Type | Description | | --- | --- | --- | | `arguments` | `PromptArgument[]` | The values the user fills in. Leave it out for a prompt without arguments. | | `title` | `string` | A readable name for menus, like `"Triage a ticket"`. Clients fall back to `name` without it. | | `description` | `string` | What the prompt is for. Also becomes the result's `description` when `execute()` doesn't return one. | | `icons` | `Icon[]` | Icons clients can show next to the prompt. Each has a `src`, and optionally `mimeType`, `sizes` and `theme` (`"light"` or `"dark"`). | | `availableWhen` | `EntryAvailability` | Only offer the prompt in matching environments. Takes arrays of `os`, `platform`, `runtime`, `deployment`, `provider`, `target`, `surface` and `env` values. Elsewhere the prompt isn't listed, and `prompts/get` fails with [`-32003`](https://frontmcp.dev/reference/sdk/prompt#prompt--is-not-available-in-the-current-environment). See [Environment awareness](https://frontmcp.dev/reference/server/environment). | Each entry in `arguments`: | Field | Type | Description | | --- | --- | --- | | `name` | `string` | The key the value arrives under in `args`. | | `description` | `string` | Shown to the user filling it in. | | `required` | `boolean` | Defaults to `false`. When `true`, FrontMCP rejects a `prompts/get` that leaves it out, and `execute()` doesn't run. | #### `execute(args)` FrontMCP calls `execute()` when a client sends `prompts/get`, once every required argument is present. - `args`: the arguments the client sent, as strings. They're also available as `this.args`. - **Returns** the prompt's messages. FrontMCP turns what `execute()` returns into the `prompts/get` result: | `execute()` returns | The client receives | | --- | --- | | `{ description?, messages }` | Those messages. Each has a `role`, `"user"` or `"assistant"`, and one `content` block of type `text`, `image`, `audio`, `resource` (an embedded resource) or `resource_link`. | | A string | One `user` message with that text. | | An array of `{ role, content }` messages | Those messages, as if you had returned `{ messages }`. | | Any other object | One `user` text message with the object as JSON. | The result's `description` is the one you return, or else the prompt's `description` option. FrontMCP checks the result when `execute()` returns: a message whose `role` isn't `"user"` or `"assistant"` [fails the request](https://frontmcp.dev/reference/sdk/prompt#tool-output-validation-failed-from-a-prompt). Inside `execute()`, `this` is the `PromptContext`, with the members [every context class](https://frontmcp.dev/reference/sdk/contexts#members-every-class-shares) has: `this.get()` and `this.tryGet()` for [providers](https://frontmcp.dev/reference/sdk/provider), [`this.auth`](https://frontmcp.dev/reference/sdk/auth) for who is asking, [`this.context`](https://frontmcp.dev/reference/sdk/context#reading-the-request-in-a-prompt) for the request, [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch) and [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool). It adds `this.args` for the arguments, and `this.metadata` for the options above. (Changed in 1.9.2: before, `PromptContext` had none of `this.auth`, `this.context`, `this.fetch()` and `this.callTool()`, and the caller was in `this.authInfo`, now deprecated.) #### Caveats - The class **must extend `PromptContext`**. Using `@Prompt` on any other class is a compile error. - Argument values are **always strings**, because that's all MCP allows. Convert them yourself. - Arguments that aren't declared are **passed through** to `execute()`, not dropped as they are for tools. - When `execute()` throws a `PublicMcpError` (or a subclass, like `InvalidInputError`), the client gets a JSON-RPC error, code `-32602`, whose message is your error's message. [See rejecting bad arguments.](https://frontmcp.dev/reference/sdk/prompt#rejecting-bad-arguments) - Any other error is an internal one: code `-32603`, and `Prompt execution failed: ` followed by its message in development. With `NODE_ENV=production`, FrontMCP hides the message and sends `Internal FrontMCP error. Please contact support with error ID: …` instead. - `this.fail(error)` fails the request the same way as `throw error` for a `PublicMcpError`. For any other error it's still `-32603`, but the message is your error's message without the `Prompt execution failed: ` prefix, and `data.code` is `SERVER_ERROR` instead of `PROMPT_EXECUTION_FAILED`. Production hides it the same way. - [`this.respond(value)`](https://frontmcp.dev/reference/sdk/respond) ends `execute()` as `return value` would. (Changed in 1.9.3: before, the request failed with [`Prompt output not found`](https://frontmcp.dev/reference/sdk/prompt#prompt-execution-failed-prompt-output-not-found).) - Declaring `execute()` as returning `Promise` is optional. With it, TypeScript checks each message's `role` and `content` as you write them. --- ## Usage ### Declaring arguments List every value the user should fill in. Clients read `arguments` from `prompts/list` to build the form, so describe each one. Open the **Capabilities** tab to see the prompt the way a client does, and the **Tests** tab to see what `required` does. Initial call configuration: ```js { prompt: "triage_ticket", arguments: { id: "T-2", tone: "friendly" } } ``` ```ts triage-ticket.prompt.ts active import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "triage_ticket", title: "Triage a ticket", description: "Suggest a priority and a next step for a support ticket", arguments: [ { name: "id", description: "Ticket id, like T-1", required: true }, { name: "tone", description: "friendly or formal (default: formal)" }, ], }) export class TriageTicket extends PromptContext { async execute(args: Record): Promise { const tone = args.tone ?? "formal"; return { messages: [ { role: "user", content: { type: "text", text: `Read ticket ${args.id}. Suggest a priority (low, normal or high) and a next step, in a ${tone} tone.`, }, }, ], }; } } ``` ```ts triage.test.ts import { test, expect } from "@frontmcp/testing"; test("clients see which arguments are required", async ({ mcp }) => { const prompt = (await mcp.prompts.list()).find((p) => p.name === "triage_ticket"); expect(prompt?.arguments).toEqual([ { name: "id", description: "Ticket id, like T-1", required: true }, { name: "tone", description: "friendly or formal (default: formal)" }, ]); }); test("leaving out a required argument is an error", async ({ mcp }) => { const result = await mcp.prompts.get("triage_ticket", {}); expect(result).toBeError(-32602); expect(result.error?.message).toBe("Missing required argument: id"); }); test("optional arguments can be left out", async ({ mcp }) => { const result = await mcp.prompts.get("triage_ticket", { id: "T-1" }); expect(result).toHaveMessages(1); expect(result.messages[0].content.text).toContain("in a formal tone"); }); test("undeclared arguments aren't rejected", async ({ mcp }) => { const result = await mcp.prompts.get("triage_ticket", { id: "T-1", colour: "blue" }); expect(result).toBeSuccessful(); }); ``` ### Writing several messages A prompt can start a conversation with more than one turn. An `assistant` message puts words in the model's mouth, which is a reliable way to set the format of its answer. Initial call configuration: ```js { prompt: "draft_reply", arguments: { customer: "Amira", problem: "The invoice total is wrong" } } ``` ```ts draft-reply.prompt.ts import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "draft_reply", title: "Draft a reply", description: "Draft a short reply to a customer", arguments: [ { name: "customer", description: "The customer's first name", required: true }, { name: "problem", description: "What they reported", required: true }, ], }) export class DraftReply extends PromptContext { async execute({ customer, problem }: Record): Promise { return { description: `Reply to ${customer}`, messages: [ { role: "user", content: { type: "text", text: `You're a support agent. Keep replies under 80 words and end with one clear next step.` } }, { role: "assistant", content: { type: "text", text: "Understood. What did the customer report?" } }, { role: "user", content: { type: "text", text: `${customer} wrote: "${problem}". Draft the reply.` } }, ], }; } } ``` The result's `description` is the one you return. Without one, FrontMCP uses the prompt's `description` option. ### Returning a string or an array A prompt that's a single user message can return just its text. A prompt with several messages can return the array of messages, without the `{ messages }` around it. Open the **Tests** tab to see what each one sends. Initial call configuration: ```js { prompt: "escalation_note", arguments: { id: "T-3" } } ``` ```ts shortcuts.prompt.ts active import { Prompt, PromptContext } from "@frontmcp/sdk"; @Prompt({ name: "escalation_note", description: "Write an escalation note for the on-call engineer", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class EscalationNote extends PromptContext { async execute({ id }: Record) { return `Write a two-line escalation note for ticket ${id}: impact first, then what was tried.`; } } @Prompt({ name: "reply_in_house_style", description: "Draft a reply in the help desk's style" }) export class ReplyInHouseStyle extends PromptContext { async execute() { return [ { role: "user", content: { type: "text", text: "Draft replies in two sentences: what we did, then what happens next." } }, { role: "assistant", content: { type: "text", text: "Understood. Paste the ticket." } }, ]; } } ``` ```ts shortcuts.test.ts import { test, expect } from "@frontmcp/testing"; test("a string is one user message", async ({ mcp }) => { const result = await mcp.prompts.get("escalation_note", { id: "T-3" }); expect(result.messages).toEqual([ { role: "user", content: { type: "text", text: "Write a two-line escalation note for ticket T-3: impact first, then what was tried." } }, ]); }); test("the prompt's description comes along", async ({ mcp }) => { const result = await mcp.prompts.get("escalation_note", { id: "T-3" }); expect(result.description).toBe("Write an escalation note for the on-call engineer"); }); test("an array is the list of messages", async ({ mcp }) => { const result = await mcp.prompts.get("reply_in_house_style", {}); expect(result.messages.map((m) => m.role)).toEqual(["user", "assistant"]); expect(result.messages[0].content.text).toContain("two sentences"); }); ``` ### Including data in a message A message can carry data as well as text. Embed it, or link to a [resource](https://frontmcp.dev/reference/sdk/resource) the client can read. ### Data in a message ### Example: Embedded resource Get the data from a [provider](https://frontmcp.dev/reference/sdk/provider) with `this.get()`, and embed it as a `resource` block. The client sends the model the data itself, with its URI and MIME type. Initial call configuration: ```js { prompt: "summarize_ticket", arguments: { id: "T-1" } } ``` ```ts summarize-ticket.prompt.ts active import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Prompt({ name: "summarize_ticket", description: "Summarize a ticket and its history for a handover", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class SummarizeTicket extends PromptContext { async execute({ id }: Record): Promise { const ticket = this.get(TicketStore).find(id); return { messages: [ { role: "user", content: { type: "resource", resource: { uri: `ticket://${id}`, mimeType: "application/json", text: JSON.stringify(ticket) }, }, }, { role: "user", content: { type: "text", text: "Summarize this ticket in three bullet points for the next agent." } }, ], }; } } ``` ```ts ticket-store.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "TicketStore" }) export class TicketStore { private tickets = [ { id: "T-1", title: "Cannot log in", status: "open", history: ["Reported by Amira", "Password reset sent"] }, { id: "T-2", title: "Invoice total is wrong", status: "closed", history: ["Refund issued"] }, ]; find(id: string) { return this.tickets.find((t) => t.id === id); } } ``` ### Example: Resource link A `resource_link` block sends only the URI. The client reads the resource itself with `resources/read` if it needs it, which keeps large data out of the prompt. Here the link points at a resource template in the same server; use the **Request** menu to read `ticket://T-1`. Initial call configuration: ```js { prompt: "summarize_ticket", arguments: { id: "T-1" } } ``` ```ts summarize-ticket.prompt.ts active import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "summarize_ticket", description: "Summarize a ticket and its history for a handover", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class SummarizeTicket extends PromptContext { async execute({ id }: Record): Promise { return { messages: [ { role: "user", content: { type: "resource_link", uri: `ticket://${id}`, name: id, mimeType: "application/json" } }, { role: "user", content: { type: "text", text: "Read the linked ticket, then summarize it in three bullet points." } }, ], }; } } ``` ```ts ticket.resource.ts import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; @ResourceTemplate({ name: "ticket", uriTemplate: "ticket://{id}", mimeType: "application/json", description: "One ticket by id" }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = { id, title: "Cannot log in", status: "open" }; return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] }; } } ``` ### Rejecting bad arguments Arguments are free text, so a prompt can't declare a format the way a tool's schema can. Check the values in `execute()` and throw a `PublicMcpError` with a message the user can act on. The client gets a JSON-RPC error, code `-32602`, with your message. Initial call configuration: ```js { prompt: "summarize_ticket", arguments: { id: "T-9" } } ``` ```ts summarize-ticket.prompt.ts active import { Prompt, PromptContext, PublicMcpError, type GetPromptResult } from "@frontmcp/sdk"; const tickets = [{ id: "T-1", title: "Cannot log in", status: "open" }]; @Prompt({ name: "summarize_ticket", description: "Summarize a ticket for a handover", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class SummarizeTicket extends PromptContext { async execute({ id }: Record): Promise { const ticket = tickets.find((t) => t.id === id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}. Ticket ids look like T-1.`); return { messages: [{ role: "user", content: { type: "text", text: `Summarize: ${ticket.title} (${ticket.status})` } }] }; } } ``` ```ts summarize.test.ts import { test, expect } from "@frontmcp/testing"; test("the user gets the message as it was thrown", async ({ mcp }) => { const result = await mcp.prompts.get("summarize_ticket", { id: "T-9" }); expect(result).toBeError(-32602); expect(result.error?.message).toBe("There's no ticket T-9. Ticket ids look like T-1."); }); ``` > **Note** > > Only a `PublicMcpError` keeps its message everywhere. Any other error thrown from a prompt is internal: in development the client sees `Prompt execution failed: ` and its message, and with `NODE_ENV=production` an error ID instead. So errors from databases and other services don't reach the user, and the user doesn't learn what went wrong either. Catch the ones you expect, and throw a `PublicMcpError` of your own. ### Writing a prompt as a function For a prompt that doesn't need providers, the function form is shorter. It takes the same options, and the handler receives the arguments. Initial call configuration: ```js { prompt: "escalate", arguments: { id: "T-3" } } ``` ```ts escalate.prompt.ts import { prompt } from "@frontmcp/sdk"; export const escalate = prompt({ name: "escalate", description: "Write an escalation note for the on-call engineer", arguments: [{ name: "id", description: "Ticket id", required: true }], })((args) => ({ messages: [ { role: "user", content: { type: "text", text: `Write a two-line escalation note for ticket ${args?.id}: impact first, then what was tried.` } }, ], })); ``` --- ## Troubleshooting ### `Missing required argument: id` The client asked for the prompt without a value for an argument marked `required: true`, so `execute()` didn't run. The response is a JSON-RPC error with code `-32602`. If users often skip the argument, say more about it in its `description`, or make it optional and use a default in `execute()`. ### `Prompt not found: triage_ticket` No prompt with that name is registered on the server, and the response is a JSON-RPC error with code `-32602`. Check, in order: 1. The class is in its app's `prompts` array (not `tools`), and the app is in `@FrontMcp({ apps })`. `@FrontMcp` has no `prompts` option; one there is silently ignored. 2. The name matches exactly, including case. 3. The prompt's `availableWhen.surface`, if it has one, includes `"mcp"`. MCP clients get this error for a prompt that leaves it out. ### The plain name gets another app's prompt Two apps declare a prompt with the same name. `prompts/list` shows them as `:`, like `desk:triage_ticket` and `billing:triage_ticket`, and `prompts/get` finds each by that name. The plain name, `triage_ticket`, gets the first app's prompt. Use the names `prompts/list` gives: Initial call configuration: ```js { prompt: "billing:triage_ticket" } ``` ```ts main.ts import { App, FrontMcp, Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "triage_ticket", arguments: [] }) class DeskTriage extends PromptContext { async execute(): Promise { return { messages: [{ role: "user", content: { type: "text", text: "Triage the newest support ticket." } }] }; } } @Prompt({ name: "triage_ticket", arguments: [] }) class BillingTriage extends PromptContext { async execute(): Promise { return { messages: [{ role: "user", content: { type: "text", text: "Triage the newest billing dispute." } }] }; } } @App({ id: "desk", name: "Help Desk", prompts: [DeskTriage] }) class DeskApp {} @App({ id: "billing", name: "Billing", prompts: [BillingTriage] }) class BillingApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [DeskApp, BillingApp] }) export default class Server {} ``` ```ts names.test.ts import { test, expect } from "@frontmcp/testing"; test("each app's prompt by its listed name; the plain name gets the first app's", async ({ mcp }) => { expect((await mcp.prompts.list()).map((prompt) => prompt.name)).toEqual(["billing:triage_ticket", "desk:triage_ticket"]); const expected = { "desk:triage_ticket": "Triage the newest support ticket.", "billing:triage_ticket": "Triage the newest billing dispute.", triage_ticket: "Triage the newest support ticket.", }; for (const [name, text] of Object.entries(expected)) { expect((await mcp.prompts.get(name, {})).messages[0].content.text).toBe(text); } }); ``` Give prompts names that are unique across the whole server, like `triage_billing_dispute`, so clients don't need the prefix. (Changed in 1.9.2: before, `prompts/get` answered `Prompt not found: billing:triage_ticket`, and the second app's prompt couldn't be reached.) ### `Prompt "…" is not available in the current environment` The prompt's `availableWhen` doesn't match where the server runs. It's left out of `prompts/list`, and `prompts/get` fails with a JSON-RPC error with code `-32003`, whose message says what the prompt requires and what the server is: Initial call configuration: ```js { prompt: "deploy_checklist" } ``` ```ts deploy-checklist.prompt.ts active import { Prompt, PromptContext } from "@frontmcp/sdk"; @Prompt({ name: "deploy_checklist", description: "Walk through a Deno Deploy release", availableWhen: { runtime: ["deno"] } }) export class DeployChecklist extends PromptContext { async execute() { return "List what to check before running deployctl."; } } ``` ```ts deploy.test.ts import { test, expect } from "@frontmcp/testing"; test("it isn't listed, and getting it fails", async ({ mcp }) => { expect(await mcp.prompts.list()).toEqual([]); const result = await mcp.prompts.get("deploy_checklist", {}); expect(result).toBeError(-32003); expect(result.error?.message).toMatch(/^Prompt "deploy_checklist" is not available in the current environment \(requires: \{"runtime":\["deno"\]\}\)/); }); ``` The message lists the server's OS, runtime and deployment, in production too. If the prompt should be offered here, fix its `availableWhen`. ### `Prompt execution failed: Prompt output not found` `execute()` finished without returning anything, or returned `undefined`. Here a lookup misses for every ticket but `T-1`: Initial call configuration: ```js { prompt: "summarize_ticket", arguments: { id: "T-2" } } ``` ```ts summarize-ticket.prompt.ts import { Prompt, PromptContext } from "@frontmcp/sdk"; const summaries: Record = { "T-1": "Summarize ticket T-1, our oldest." }; @Prompt({ name: "summarize_ticket", arguments: [{ name: "id", required: true }] }) export class SummarizeTicket extends PromptContext { async execute({ id }: Record) { return summaries[id]; // 🚩 undefined for T-2 } } ``` Return a result on every path: ```ts // ✅ Every id gets a message return summaries[id] ?? `Summarize ticket ${id}.`; ``` Before 1.9.3, a prompt that called `this.respond()` failed with this error too. It now ends the request with the value it's given. ### `Tool output validation failed`, from a prompt A message has a `role` other than `"user"` or `"assistant"`, usually `"system"`. MCP prompts have no system role, so FrontMCP rejects the result. The client gets error `-32603`, with `INVALID_OUTPUT` as the code in its `data`, and a message that says "Tool" even though this is a prompt. What failed validation is only in the server log: Initial call configuration: ```js { prompt: "support_persona" } ``` ```ts persona.prompt.ts import { Prompt, PromptContext } from "@frontmcp/sdk"; @Prompt({ name: "support_persona" }) export class SupportPersona extends PromptContext { async execute() { return { messages: [{ role: "system", content: { type: "text", text: "You are a patient support agent." } }] }; } } ``` Put instructions in the first `user` message instead. Declaring `execute()` as returning `Promise` catches this while you write it: TypeScript only accepts `"user"` and `"assistant"` there. ### TypeScript says `Property 'execute' in type '…' is not assignable to the same property in base type 'PromptContext'` `execute()` can finish without returning anything, usually because a code path has no `return`. It must return a string, an array or an object, or throw, on every path: ```ts // 🚩 Promise on the path where the ticket exists async execute({ id }: Record) { if (!tickets.has(id)) throw new PublicMcpError(`There's no ticket ${id}.`); } // ✅ Every path returns or throws async execute({ id }: Record) { if (!tickets.has(id)) throw new PublicMcpError(`There's no ticket ${id}.`); return `Summarize ticket ${id}.`; } ``` --- # @Provider > Declare a shared service, like a database client or a cache, that tools, resources and prompts get with this.get(). Source: https://frontmcp.dev/reference/sdk/provider [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `@Provider` declares a service that tools, resources and prompts share: a database client, an API wrapper, a cache, your configuration. FrontMCP creates it, keeps it for as long as its scope says, and hands it to any code that asks with `this.get()`. This is dependency injection: your tools say what they need, not how to build it. ```ts @Provider(options) class MyService { /* ... */ } ``` --- ## Reference ### `@Provider(options)` Apply `@Provider` to a class, list it in the `providers` of an app or of the server, and get it inside `execute()` with `this.get(MyService)`. ```ts ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets = new Map([["T-1", { id: "T-1", title: "Cannot log in", status: "open" }]]); find(id: string) { return this.tickets.get(id); } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket } from "./get-ticket.tool"; import { TicketStore } from "./ticket-store"; @App({ id: "help-desk", name: "Help Desk", providers: [TicketStore], tools: [GetTicket] }) export class HelpDeskApp {} ``` [See more examples below.](https://frontmcp.dev/reference/sdk/provider#usage) #### Options | Option | Type | Description | | --- | --- | --- | | `name` | `string` | **Required.** Names the provider in logs and error messages. | | `scope` | `ProviderScope` | How long one instance lives. Defaults to `ProviderScope.GLOBAL`. See [scopes](https://frontmcp.dev/reference/sdk/provider#scopes). | | `description` | `string` | What the provider is for. | | `id` | `string` | A stable identifier, if `name` isn't one. | #### Scopes | Scope | Value | One instance per | | --- | --- | --- | | `ProviderScope.GLOBAL` | `"global"` | Server. Created when the server starts, and kept until it stops. **The default.** | | `ProviderScope.CONTEXT` | `"context"` | Session, for clients that open one (MCP before 2026-07-28). Under 2026-07-28, which has no sessions, one per request, for callers with a token too. Created when the request starts. | | `ProviderScope.SESSION`, `ProviderScope.REQUEST` | `"session"`, `"request"` | Deprecated. Both mean `CONTEXT`. | The Playground speaks 2026-07-28, so a `CONTEXT` provider in an example is new on every call. #### Registering providers A provider is available where it's registered, and nowhere else: | Where | Visible to | | --- | --- | | `@App({ providers })` | That app's tools, resources and prompts, and its other providers. | | `@FrontMcp({ providers })` | Every app on the server. | | `@Plugin({ providers })` | The plugin only. Also list a provider in the plugin's `exports` to make it visible to the apps that use the plugin. Each app that installs the plugin gets its own instance. | Each entry in `providers` is one of: | Form | Example | Notes | | --- | --- | --- | | A class | `TicketStore` | Must be decorated with `@Provider`. The class is also the token you pass to `this.get()`. | | A factory | `{ provide: DeskConfig, name: "DeskConfig", inject: () => [], useFactory: () => ({ slaHours: 4 }) }` | `name` is required. `inject` is a **function** that returns the tokens to pass to `useFactory`. `useFactory` can be `async`. Add `scope` to change the default `GLOBAL`. | | An instance | `{ provide: TicketStore, useValue: new TicketStore() }` | `useValue` must be an instance of a `@Provider` class. | `provide` is the token: a class, an abstract class, a symbol or a string. An abstract class makes a good token for configuration, because `this.get()` then knows its type. #### `this.get(token)` and `this.tryGet(token)` Available in `ToolContext`, `ResourceContext` and `PromptContext`. - `this.get(token)` returns the instance. If no provider for `token` is registered where the caller can see it, it throws, and a tool call fails with `Provider "…" is not available`. - `this.tryGet(token)` returns `undefined` instead of throwing. For a symbol or string token, pass the type yourself: `this.get(SLA_HOURS)`. #### Constructor injection In a project compiled by `tsc` with `emitDecoratorMetadata`, a provider's constructor parameters are injected by type: ```ts ticket-repo.ts import { Provider } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Provider({ name: "TicketRepo" }) export class TicketRepo { constructor(private store: TicketStore) {} titleOf(id: string) { return this.store.find(id)?.title; } } ``` The Playground can't run this: it compiles with esbuild, which doesn't emit decorator metadata, so `store` would be `undefined`. The examples below use factories with `inject` instead, which work everywhere. #### Caveats - A provider must be **registered** in `providers`. Decorating the class isn't enough. - In FrontMCP 1.8, `providers` rejects `{ provide, useValue }` with a plain value and `{ provide, useClass }`, even though the TypeScript types allow them. [Use a factory.](https://frontmcp.dev/reference/sdk/provider#providing-a-value-or-configuration) - A `GLOBAL` provider **can't depend on** a `CONTEXT` provider. The server fails to start. - `GLOBAL` means one instance per server **process**. Several processes or serverless instances each have their own, so keep shared data in a database or Redis. - A provider on an `@App` can declare hooks, and they run for that app's tools. Hooks on a `@FrontMcp` provider or a `CONTEXT` provider never run. See [where hooks can be declared](https://frontmcp.dev/reference/sdk/hooks#where-hooks-can-be-declared). --- ## Usage ### Sharing state between tools A `GLOBAL` provider is created once, so every tool that gets it sees the same data. Call `assign_ticket`, then `my_queue`, and the ticket is there. Initial call configuration: ```js { tool: "assign_ticket", arguments: { id: "T-1", agent: "nour" } } ``` ```ts tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { Assignments } from "./assignments"; @Tool({ name: "assign_ticket", description: "Assign a ticket to a support agent", inputSchema: { id: z.string(), agent: z.string() }, }) export class AssignTicket extends ToolContext { async execute({ id, agent }: { id: string; agent: string }) { this.get(Assignments).assign(id, agent); return { id, agent }; } } @Tool({ name: "my_queue", description: "List the tickets assigned to an agent", inputSchema: { agent: z.string() }, }) export class MyQueue extends ToolContext { async execute({ agent }: { agent: string }) { return { tickets: this.get(Assignments).queue(agent) }; } } ``` ```ts assignments.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; @Provider({ name: "Assignments", scope: ProviderScope.GLOBAL }) export class Assignments { private byTicket = new Map(); assign(id: string, agent: string) { this.byTicket.set(id, agent); } queue(agent: string) { return [...this.byTicket].filter(([, a]) => a === agent).map(([id]) => id); } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { AssignTicket, MyQueue } from "./tools"; import { Assignments } from "./assignments"; @App({ id: "help-desk", name: "Help Desk", tools: [AssignTicket, MyQueue], providers: [Assignments] }) export class HelpDeskApp {} ``` ```ts queue.test.ts import { test, expect } from "@frontmcp/testing"; test("the next call sees the assignment", async ({ mcp }) => { await mcp.tools.call("assign_ticket", { id: "T-1", agent: "nour" }); await mcp.tools.call("assign_ticket", { id: "T-2", agent: "nour" }); const result = await mcp.tools.call("my_queue", { agent: "nour" }); expect(result.json()).toEqual({ tickets: ["T-1", "T-2"] }); }); ``` ### Keeping state for one request A `CONTEXT` provider is new for each request (each session, for clients that use sessions). Within a request, every `this.get()` returns the same instance, so it can collect what happens during one call without leaking into the next. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { AuditTrail } from "./audit-trail"; @Tool({ name: "close_ticket", description: "Close a ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { this.get(AuditTrail).record(`checked that ${id} exists`); this.get(AuditTrail).record(`closed ${id}`); return { id, status: "closed", trail: this.get(AuditTrail).steps }; } } ``` ```ts audit-trail.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; @Provider({ name: "AuditTrail", scope: ProviderScope.CONTEXT }) export class AuditTrail { steps: string[] = []; record(step: string) { this.steps.push(step); } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; import { AuditTrail } from "./audit-trail"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket], providers: [AuditTrail] }) export class HelpDeskApp {} ``` ```ts audit.test.ts import { test, expect } from "@frontmcp/testing"; test("each call starts with an empty trail", async ({ mcp }) => { await mcp.tools.call("close_ticket", { id: "T-1" }); const second = await mcp.tools.call("close_ticket", { id: "T-2" }); expect(second.json<{ trail: string[] }>().trail).toEqual(["checked that T-2 exists", "closed T-2"]); }); ``` With `ProviderScope.GLOBAL`, the second call would return all four steps. ### Providing a value or configuration To register something that isn't a `@Provider` class, like a settings object or a client from a library, use a factory. The object needs a `name`, and `inject` returns the tokens whose instances `useFactory` receives, in order. ### Factories ### Example: A value An abstract class works as a typed token: `this.get(DeskConfig)` knows it has a `slaHours`. Initial call configuration: ```js { tool: "reply_due", arguments: { priority: "high" } } ``` ```ts help-desk.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; export abstract class DeskConfig { abstract slaHours: { high: number; normal: number }; } @Tool({ name: "reply_due", description: "How many hours we have to reply to a ticket of this priority", inputSchema: { priority: z.enum(["high", "normal"]) }, }) export class ReplyDue extends ToolContext { async execute({ priority }: { priority: "high" | "normal" }) { return { hours: this.get(DeskConfig).slaHours[priority] }; } } @App({ id: "help-desk", name: "Help Desk", tools: [ReplyDue], providers: [ { provide: DeskConfig, name: "DeskConfig", inject: () => [], useFactory: () => ({ slaHours: { high: 4, normal: 24 } }) }, ], }) export class HelpDeskApp {} ``` ### Example: Built from another provider `inject` lists the providers the factory needs. With `as const`, TypeScript infers the factory's parameter types from them. Initial call configuration: ```js { tool: "reply_due", arguments: { priority: "normal" } } ``` ```ts help-desk.app.ts active import { App, Provider, Tool, ToolContext, z } from "@frontmcp/sdk"; @Provider({ name: "Settings" }) export class Settings { get(key: string) { return { SLA_HIGH_HOURS: "4", SLA_NORMAL_HOURS: "24" }[key]; } } export abstract class DeskConfig { abstract slaHours: { high: number; normal: number }; } @Tool({ name: "reply_due", description: "How many hours we have to reply to a ticket of this priority", inputSchema: { priority: z.enum(["high", "normal"]) }, }) export class ReplyDue extends ToolContext { async execute({ priority }: { priority: "high" | "normal" }) { return { hours: this.get(DeskConfig).slaHours[priority] }; } } @App({ id: "help-desk", name: "Help Desk", tools: [ReplyDue], providers: [ Settings, { provide: DeskConfig, name: "DeskConfig", inject: () => [Settings] as const, useFactory: (settings) => ({ slaHours: { high: Number(settings.get("SLA_HIGH_HOURS")), normal: Number(settings.get("SLA_NORMAL_HOURS")) }, }), }, ], }) export class HelpDeskApp {} ``` ### Example: Async An `async` factory is awaited before the server takes requests, so tools never see a half-built provider. Initial call configuration: ```js { tool: "list_agents", arguments: {} } ``` ```ts help-desk.app.ts active import { App, Tool, ToolContext } from "@frontmcp/sdk"; export abstract class Roster { abstract agents: string[]; } async function loadRoster() { await new Promise((resolve) => setTimeout(resolve, 50)); // stands in for a database query return { agents: ["nour", "sam", "li"] }; } @Tool({ name: "list_agents", description: "List the support agents on the roster", inputSchema: {} }) export class ListAgents extends ToolContext { async execute() { return { agents: this.get(Roster).agents }; } } @App({ id: "help-desk", name: "Help Desk", tools: [ListAgents], providers: [{ provide: Roster, name: "Roster", inject: () => [], useFactory: loadRoster }], }) export class HelpDeskApp {} ``` ### Reading the request in a provider Inject `FRONTMCP_CONTEXT` into a `CONTEXT` factory to build something for the current request: its id, its trace, who is calling. Here each ticket is stamped with the request that created it. Initial call configuration: ```js { tool: "create_ticket", arguments: { title: "Printer on fire" } } ``` ```ts help-desk.app.ts active import { App, FRONTMCP_CONTEXT, ProviderScope, Tool, ToolContext, z } from "@frontmcp/sdk"; export abstract class RequestInfo { abstract requestId: string; abstract traceId: string; } @Tool({ name: "create_ticket", description: "Open a new support ticket", inputSchema: { title: z.string() } }) export class CreateTicket extends ToolContext { async execute({ title }: { title: string }) { const { requestId } = this.get(RequestInfo); return { id: "T-4", title, createdBy: requestId }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CreateTicket], providers: [ { provide: RequestInfo, name: "RequestInfo", scope: ProviderScope.CONTEXT, inject: () => [FRONTMCP_CONTEXT] as const, useFactory: (ctx) => ({ requestId: ctx.requestId, traceId: ctx.traceContext.traceId }), }, ], }) export class HelpDeskApp {} ``` ```ts request.test.ts import { test, expect } from "@frontmcp/testing"; test("each call has its own request id", async ({ mcp }) => { const a = (await mcp.tools.call("create_ticket", { title: "First" })).json<{ createdBy: string }>(); const b = (await mcp.tools.call("create_ticket", { title: "Second" })).json<{ createdBy: string }>(); expect(a.createdBy).toEqual(expect.any(String)); expect(a.createdBy).not.toBe(b.createdBy); }); ``` `FRONTMCP_CONTEXT` only makes sense per request, so inject it into `CONTEXT` providers only. A `CONTEXT` provider can also hold something built for the caller, like a client for your own API that acts as them. Under 2026-07-28, every request gets its own instance, whoever is calling, with a token or without, and even when two requests send the same `mcp-session-id` header. So one caller's client is never handed to another. This server uses `transparent` auth, and the test calls it as two users, one after the other: Initial call configuration: ```js { tool: "my_tickets", arguments: {} } ``` ```ts help-desk.app.ts active import { App, FRONTMCP_CONTEXT, ProviderScope, Tool, ToolContext } from "@frontmcp/sdk"; /** A client for the ticket API that acts as the caller. */ export abstract class TicketApi { abstract actingAs: string | undefined; } @Tool({ name: "my_tickets", description: "List the tickets assigned to the caller", inputSchema: {} }) export class MyTickets extends ToolContext { async execute() { return { actingAs: this.get(TicketApi).actingAs }; } } @App({ id: "help-desk", name: "Help Desk", tools: [MyTickets], providers: [ { provide: TicketApi, name: "TicketApi", scope: ProviderScope.CONTEXT, inject: () => [FRONTMCP_CONTEXT] as const, // In transparent mode, clientId is the token's sub. useFactory: (ctx) => ({ actingAs: ctx.authInfo.clientId }), }, ], }) export class HelpDeskApp {} ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "transparent" as const, provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", allowAnonymous: true, providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } }, }, }; @FrontMcp(config) export default class Server {} ``` **Hidden fixture/test file (runs with the example):** ```ts tokens.ts hidden // Access tokens for two users, signed ahead of time with the key in main.ts. export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw"; export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw"; ``` ```ts callers.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; import { NOUR, SAM } from "./tokens"; test("each caller gets a client of their own", async () => { // One server; each call is a 2026-07-28 request over HTTP with the caller's token. const handler = await FrontMcpInstance.createFetchHandler(config); const callAs = async (token: string) => { const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "my_tickets", "mcp-session-id": "the-same-id", authorization: `Bearer ${token}`, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "my_tickets", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return (await response.json()).result.structuredContent; }; expect(await callAs(NOUR)).toEqual({ actingAs: "nour" }); expect(await callAs(SAM)).toEqual({ actingAs: "sam" }); }); ``` Clients on protocol versions before 2026-07-28 keep a `CONTEXT` provider for their whole session, and that includes this one: FrontMCP 1.8 builds it on the session's first request, so later requests in the session see that request's `requestId`. When a value has to be right for every request, read it from `this.context` in the tool instead. ### Using a provider only if it's there `this.tryGet()` returns `undefined` for a provider that isn't registered, so a tool can work with or without it. Here notifications are optional. Add `Notifier` to the app's `providers`, and `notified` becomes `true`. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { Notifier } from "./notifier"; @Tool({ name: "close_ticket", description: "Close a ticket, and tell the customer if we can", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const notifier = this.tryGet(Notifier); notifier?.send(`Ticket ${id} was closed.`); return { id, status: "closed", notified: notifier !== undefined }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket], providers: [] }) export class HelpDeskApp {} ``` ```ts notifier.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "Notifier" }) export class Notifier { send(message: string) { console.log(message); } } ``` --- ## Troubleshooting ### `Provider "TicketStore" is not available: not found in local or parent registries` A tool asked for a provider that isn't registered anywhere it can see, so the call failed: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts help-desk.app.ts active import { App, Provider, Tool, ToolContext, z } from "@frontmcp/sdk"; @Provider({ name: "TicketStore" }) export class TicketStore { find(id: string) { return { id, title: "Cannot log in" }; } } @Tool({ name: "get_ticket", description: "Get one ticket by id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return this.get(TicketStore).find(id); } } // 🚩 TicketStore is decorated, but not in `providers` @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDeskApp {} ``` Add it to the `providers` of the tool's app, or of `@FrontMcp` to share it with every app. A provider registered in **another** app isn't visible: apps don't see each other's providers. ### `providers items must be annotated with @Provider() | @FrontMcpProvider().` The server fails to start with `@App invalid metadata for "providers"` and the index of the bad entry. FrontMCP 1.8 accepts three forms, and rejects everything else: ```ts providers: [ TicketStore, // ✅ a @Provider class { provide: DeskConfig, name: "DeskConfig", inject: () => [], useFactory: () => config }, // ✅ a named factory { provide: TicketStore, useValue: new TicketStore() }, // ✅ an instance of a @Provider class PlainClass, // 🚩 no @Provider decorator { provide: DeskConfig, useValue: config }, // 🚩 a plain value: use a factory { provide: DeskConfig, useClass: EnvConfig }, // 🚩 useClass: use a factory that returns new EnvConfig() { provide: DeskConfig, useFactory: () => config }, // 🚩 a factory without `name` ]; ``` ### `Invalid dependency: DEFAULT-scoped provider Audit cannot depend on scoped provider RequestLog.` A `GLOBAL` provider (here `Audit`) injects a `CONTEXT` provider (`RequestLog`). The global one would outlive the request it was built for, so the server refuses to start. Make the dependent provider `CONTEXT` too, or pass the request-scoped value in as a method argument. ### `Provider TicketRepo depends on TicketStore, which is not registered (local or parent).` A constructor parameter or an `inject` token names a provider that isn't registered. Add it to `providers`. If the message says `depends on Function` or `depends on Object`, the parameter's type was imported with `import type`, which leaves no class at run time for `emitDecoratorMetadata` to record. Use a normal `import`. ### A constructor parameter is `undefined` The provider was built without its dependencies. Check that: 1. `tsconfig.json` sets `experimentalDecorators` and `emitDecoratorMetadata` to `true`, and your build uses `tsc` (or a compiler that emits decorator metadata). 2. `import "reflect-metadata"` is the first line of your entry file. In the Playground this always happens, because esbuild doesn't emit decorator metadata. Use a factory with `inject` there. ### `Failed to construct provider "DeskConfig": Cannot read properties of undefined` The factory was called without the providers it needs. Most often, `inject` is an array. It must be a function that returns one; FrontMCP ignores an array and calls the factory with no arguments: ```ts // 🚩 An array: `settings` is undefined { provide: DeskConfig, name: "DeskConfig", inject: [Settings], useFactory: (settings) => ({ slaHours: settings.slaHours }) } // ✅ A function that returns the array { provide: DeskConfig, name: "DeskConfig", inject: () => [Settings] as const, useFactory: (settings) => ({ slaHours: settings.slaHours }) } ``` TypeScript reports the array form as an error, unless it's been cast away. A `GLOBAL` factory runs when the server starts, so the server fails to start. A `CONTEXT` factory runs at the start of every request, so every call to that app's tools fails, even calls to tools that don't use it. ### State I stored in a provider is gone on the next call Check the provider's scope. A `CONTEXT` provider is rebuilt for every request under MCP 2026-07-28, and for every session on older clients. Use `GLOBAL` for state that should last. If it already is `GLOBAL`, your server probably runs as several processes or serverless instances, each with its own copy; keep the state in a database or Redis. --- # @Job > Declare a unit of work with validated input that clients run by name, while they wait or in the background, with retries. Source: https://frontmcp.dev/reference/sdk/job [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `@Job` declares a [job](https://frontmcp.dev/learn/your-first-job): a piece of work with validated input that a client starts by name, with the built-in `execute_job` tool. A job can run while the client waits, or [in the background](https://frontmcp.dev/learn/running-jobs-in-the-background) while the client does something else, and FrontMCP can retry it when it fails. Jobs are also the steps of a [`@Workflow`](https://frontmcp.dev/reference/sdk/workflow). ```ts @Job(options) class MyJob extends JobContext { async execute(input) { /* ... */ } } ``` --- ## Reference ### `@Job(options)` Apply `@Job` to a class that extends `JobContext`, and list the class in an app's `jobs` array. FrontMCP then adds the tools that run jobs to the server; you don't write a tool for each job. ```ts count-open.job.ts import { Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "count-open-tickets", description: "Count a customer's open tickets", inputSchema: { customer: z.string() }, outputSchema: { customer: z.string(), open: z.number() }, retry: { maxAttempts: 3, backoffMs: 500 }, }) export class CountOpenTickets extends JobContext { async execute({ customer }: { customer: string }) { this.log(`Counting open tickets for ${customer}`); const open = await countOpenTickets(customer); return { customer, open }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CountOpenTickets } from "./count-open.job"; @App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets] }) export class HelpDeskApp {} ``` [See more examples below.](https://frontmcp.dev/reference/sdk/job#usage) #### Options Required: | Option | Type | Description | | --- | --- | --- | | `name` | `string` | What clients pass to `execute_job`, and what a workflow step's `jobName` refers to. Unique within the server. If you also set `id`, the id replaces it. | | `inputSchema` | object of Zod types | One Zod type per input field, like `{ customer: z.string() }`. `execute_job` checks the input against it before `execute()` runs. | | `outputSchema` | object of Zod types, or any output schema a [tool](https://frontmcp.dev/reference/sdk/tool#options) accepts | The shape of the result. TypeScript checks `execute()`'s return type against it, and FrontMCP checks the result when the job runs: fields it doesn't declare are dropped, and a result that doesn't match fails the run with `INVALID_OUTPUT`, without a retry. See [Troubleshooting](https://frontmcp.dev/reference/sdk/job#tool-output-validation-failed-output-does-not-match-outputschema-at-). (Changed in 1.9.2: before, the result wasn't checked at run time.) | Optional: | Option | Type | Default | Description | | --- | --- | --- | --- | | `description` | `string` | | What the job does. `list_jobs` shows it. | | `id` | `string` | `name` | A stable identifier. When set, it **replaces** `name`: clients, `list_jobs` and workflow steps all use the id. | | `retry` | `JobRetryConfig` | none: one attempt | Run `execute()` again when it throws. See [`retry`](https://frontmcp.dev/reference/sdk/job#retry). | | `timeout` | `number` | `300000` (5 minutes) | How long the job may run, in milliseconds, **as a workflow step**. `execute_job` doesn't enforce it. | | `tags` | `string[]` | `[]` | Labels to filter `list_jobs` by. | | `labels` | `Record` | `{}` | Key-value labels to filter `list_jobs` by. | | `hideFromDiscovery` | `boolean` | `false` | Leave the job out of `list_jobs`. It still runs by name. | | `permissions` | `JobPermission[]` | none: anyone may run it | Who may run and list the job. See [`permissions`](https://frontmcp.dev/reference/sdk/job#permissions). | #### `retry` | Field | Type | Default | Description | | --- | --- | --- | --- | | `maxAttempts` | `number` | `3` | Attempts in all, counting the first. | | `backoffMs` | `number` | `1000` | How long to wait before the second attempt, in milliseconds. | | `backoffMultiplier` | `number` | `2` | Each wait is this many times longer than the one before. | | `maxBackoffMs` | `number` | `60000` | The longest any one wait can be. | The wait after attempt *n* is `backoffMs × backoffMultiplier^(n−1)`, capped at `maxBackoffMs`. The defaults apply as soon as you set `retry`: `retry: {}` means three attempts, with waits of 1 and 2 seconds. A job without `retry` runs once when a client calls `execute_job`, but three times, with the same waits, when it's a [workflow step](https://frontmcp.dev/reference/sdk/workflow#caveats). #### `permissions` Each entry is a rule for one action: | Field | Type | Description | | --- | --- | --- | | `action` | `"execute" \| "list" \| "delete" \| "create" \| "read" \| "update"` | What the rule guards. FrontMCP checks `execute` (when `execute_job` starts a run, again before each attempt, and for a workflow step that runs the job), `list` (`list_jobs`) and `delete` (`remove_job`). The other three are accepted and never checked. | | `roles` | `string[]` | The caller needs at least one of these, in their `roles` claim. | | `scopes` | `string[]` | The caller needs at least one of these, in their token's `scope` claim. | | `custom` | `(authInfo) => boolean \| Promise` | Your own check. It receives the caller's `authInfo`, with the token's claims in `authInfo.user`. For `execute`, it runs when the run starts and again before each attempt: four times for a job that runs three times. (Changed in 1.9.4: before, once per run.) | A rule passes when each of its fields passes, and the caller needs every rule for the action. `list_jobs` leaves out jobs the caller can't list **or** can't run. A caller who isn't allowed to run a job gets the same error as for a job that doesn't exist, so they can't find out which jobs exist by guessing names. When the check before a later attempt refuses the caller, as a `custom` check can, the run fails at once, without a retry. #### `execute(input)` FrontMCP calls `execute()` once the input passes validation, and again for each retry. - `input`: the validated input. Fields with a `.default()` are filled in, and fields that aren't in `inputSchema` are dropped. The same object is `this.input`. - **Returns** the job's result: `result` in what `execute_job` returns, and `outputs` when the job is a workflow step, where later steps read it. Return an object. To fail, throw, or call `this.fail()`. A failed attempt is retried if the job has `retry`, unless the result failed `outputSchema`. #### `JobContext` Inside `execute()`, `this` is the `JobContext`: | Member | Description | | --- | --- | | `this.log(message)` | Adds a line to the run's `logs`, prefixed with the time: `[2026-09-27T09:00:00.000Z] message`. Only the lines of the attempt that succeeded are kept, and a failed run's `logs` is empty, but every line also goes to the server's log. | | `this.input` | The validated input. | | `this.get(token)`, `this.tryGet(token)` | [Providers](https://frontmcp.dev/reference/sdk/provider), from the job's app and from the server. See [Using a provider](https://frontmcp.dev/reference/sdk/job#using-a-provider). (Changed in 1.9.2: before, a job only saw the server's.) | | `this.fail(error)` | Ends the attempt with `error`. The message and code of a `PublicMcpError` reach the client as they are. | | `this.auth` | The caller who started the run, as in [tools](https://frontmcp.dev/reference/sdk/auth): `user.sub`, `isAnonymous`, `roles`, `scopes`. | | `this.callTool(name, args)` | Calls a tool of the server as the caller who started the run, on the `"job"` surface: a tool whose `availableWhen.surface` leaves out `"job"` answers `Tool "…" not found`. See [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool#where-it-works). | | `this.context`, `this.fetch()` | The request's context, as in a tool. When `execute_job` runs the job while the client waits, it's the caller's; in the background, it's a copy with the same session, caller and trace, and a request id of its own. `this.fetch()` sends the tracing headers, `traceparent` and `x-request-id`. See [Calling another service from a job](https://frontmcp.dev/reference/sdk/job#calling-another-service-from-a-job). [Context classes](https://frontmcp.dev/reference/sdk/contexts) compares every member. (Changed in 1.9.2: before, `this.context` threw in a job, and `this.fetch()` sent no tracing headers.) | | `this.workerEnv` | On a Cloudflare Worker, the bindings of the request that ran `execute_job`, when it runs the job while the client waits. On a server from [`create()`](https://frontmcp.dev/reference/sdk/create#call-options), the `workerEnv` of the `executeJob()` call, or else of the server, in the background too. `undefined` elsewhere. (Since 1.9; before, always `undefined`. Changed in 1.9.4: before, a server from `create()` had none.) | | `this.metadata` | The job's options. | | `this.attempt` | The attempt number, from 1: `2` on the first retry. See [Retrying a job that fails](https://frontmcp.dev/reference/sdk/job#retrying-a-job-that-fails). (Changed in 1.9.2: before, it was always `1`.) | | `this.progress(progress, total?, message?)` | Meant to send a progress notification to the session that started the run. For the Playground's caller it sends nothing and returns `false`. | | `this.signal` | In a [workflow](https://frontmcp.dev/reference/sdk/workflow#stopping-work-when-a-step-times-out) step, an `AbortSignal` that aborts when the step's `timeout` passes, with the `WorkflowJobTimeoutError` as its reason. `undefined` when `execute_job` runs the job. Check it, or pass it to what the job waits for; `this.fetch()` takes it already. A `job()` function reads `ctx.signal`. New in 1.9.5. | | `this.respond(value)` | Ends the attempt with `value` as the job's result, as returning it does, and `value` is checked against `outputSchema` the same way. (Changed in 1.9.2: before, `value` replaced the whole `execute_job` response.) | ### `job(options)(handler)` The function form takes the same options. `handler(input, ctx)` receives the validated input and the `JobContext`. List the result in `jobs` like a class. ```ts import { job, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; export const countOpenTickets = job({ name: "count-open-tickets", inputSchema: { customer: z.string() }, outputSchema: { customer: z.string(), open: z.number() }, })(async ({ customer }, ctx) => { return { customer, open: ctx.get(TicketStore).openFor(customer).length }; }); ``` `ctx.log()` and `ctx.progress()` work in a handler. `fail()` is `protected`, so TypeScript rejects `ctx.fail()` with error `TS2445`: throw a `PublicMcpError` instead, which fails the attempt the same way. (Changed in 1.9.1: before, `log()` and `progress()` were `protected` too.) ### `JobHook` Each attempt of a job runs the [`jobs:execute-job` flow](https://frontmcp.dev/reference/server/flows#jobsexecute-job): a run `execute_job` starts, inline or in the background, each retry, and each workflow step. `JobHook` hooks it, as [`ToolHook`](https://frontmcp.dev/reference/sdk/hooks#decorator-sets-and-their-flows) hooks tool calls, and `FlowCtxOf<"jobs:execute-job">` types the hook's `ctx`. `@frontmcp/sdk` also exports the flow's class as the type `ExecuteJobFlow`. A hook runs once per attempt: three times for a job that succeeds on its third. (New in 1.9.4: before, jobs ran through no flow.) | Stage | Group | What it does | | --- | --- | --- | | `parseInput` | first | Sets `job`, `attempt`, `runId`, `workflow`, `authInfo` and `input`. | | `checkJobAuthorization` | first | Checks the job's `execute` [`permissions`](https://frontmcp.dev/reference/sdk/job#permissions) against the caller. A refusal fails the run with `JOB_NOT_AUTHORIZED`, without a retry. | | `validateInput` | first | Parses `input` with `inputSchema` into `parsedInput`. | | `createJobContext` | first | Builds the job's instance, the `JobContext`, as `jobContext`. | | `execute` | execute | Runs `execute()`. | | `validateOutput` | execute | Parses the result with `outputSchema`, and sets `answer`: `{ result, logs }`. | | `updateRunState` | always | Records the attempt on the run: `completed`, `retrying` or `failed`. | | `finalize` | always | Answers with `answer`. | `ctx.state` has: | Field | What it is | | --- | --- | | `job` | The job: `name`, and `metadata`, its options. | | `attempt` | The attempt number, from 1. | | `runId` | The run's id. `undefined` for a workflow step, whose workflow run keeps the record. | | `workflow` | For a workflow step, `{ name, stepId, runId }`: the workflow, the step's `id` and the workflow run's id. `undefined` otherwise. | | `authInfo` | The caller who started the run. | | `input`, `parsedInput` | The job's input as given, and once `inputSchema` has parsed it. A hook before `validateInput` can replace `input`. | | `jobContext` | The job's instance, from `createJobContext` on. | | `answer` | `{ result, logs }`, from `validateOutput` on. | | `flowError` | In the `always` stages, the error that failed the attempt. | - **Where hooks go.** A hook on a plugin or a provider of an app runs for that app's jobs; on `@FrontMcp`, for every job. On a `@Job` class, it runs for that job's attempts only: as an instance method from `createJobContext` on, with the job's context as `this`, or as a `static` method on any stage, with the class as `this`. A hook for another flow on a `@Job` class stops the server from starting. See [Hooking every attempt of a job](https://frontmcp.dev/reference/sdk/job#hooking-every-attempt-of-a-job). - **A hook that throws fails the attempt**, and the job's `retry` applies, unless the error is a `JobNotAuthorizedError`. - **`ctx.respond({ result, logs })`** answers for the job, from a cache say: the stages that haven't run are skipped, `execute()` included, apart from the `always` group, and the run completes with `result` and `logs`. `result` is checked against `outputSchema`: extra fields are dropped, and a mismatch fails with `INVALID_OUTPUT` without retrying. Changed in 1.9.5: before, hook results skipped validation. ### Built-in tools When any app declares a job or a workflow, FrontMCP adds these tools to the server: | Tool | In `tools/list` | Arguments | Returns | | --- | --- | --- | --- | | `execute_job` | Yes | `name`, `input?` (an object, default `{}`), `background?` (default `false`) | Without `background`: waits for the job, then `{ runId, state, result, logs }`. With `background: true`: `{ runId, state: "pending" }` at once. | | `get_job_status` | Yes | `runId` | `{ runId, jobName, state, result?, error?, startedAt, completedAt?, attempt, logs }`. Times are milliseconds since 1970. It also reads a workflow's run, whose `result` holds the step results. Only the caller who started the run can read it: see [who can read a run](https://frontmcp.dev/reference/sdk/job#who-can-read-a-run). | | `list_jobs` | No | `query?`, `tags?`, `labels?` | `{ jobs: [{ name, description?, tags, labels, inputSchema? }], count }`. `query` matches the name or description; `tags` needs any one of them; `labels` needs all of them. | | `remove_job` | No | `name` | Removes a job registered at run time with `register_job`. For a `@Job` class it fails with `Job "…" is not a dynamic job and cannot be removed`. | `execute_workflow` and `get_workflow_status` come with them; see [`@Workflow`](https://frontmcp.dev/reference/sdk/workflow#built-in-tools). Tools that aren't in `tools/list` can still be called by name. A fifth job tool, `register_job`, registers a job from a script sent by the client. It exists only on servers that set `jobs: { enabled: true, allowDynamicRegistration: true }`, and isn't in `tools/list` there either. It runs code a client sent you, so leave it off unless you know you need it. #### `@FrontMcp({ jobs })` The server's `jobs` option controls the subsystem. You don't need it to use jobs. | Option | Type | Default | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | on when an app declares jobs or workflows | Required when you set `jobs`. `false` leaves the tools out even when apps declare jobs; `true` adds them even when none do. | | `store` | `{ redis?, keyPrefix? }` | memory | Where runs are kept. By default, in the server's memory: they're lost on restart, and each server process has its own. `redis` keeps them in Redis for a day. It takes the server's [`redis`](https://frontmcp.dev/reference/deployment/redis#options) fields, `host`, `port`, `password`, `db` and `tls`, or a `url`, and the fields beside a `url` only fill in what it leaves out: one that contradicts it stops the server at startup. (Changed in 1.9.3: before, only `host` and `port` reached Redis, and `password`, `db` and `tls` were dropped, those in a `url` too.) `keyPrefix` defaults to `"mcp:jobs:"`. | | `allowDynamicRegistration` | `boolean` | `false` | Adds `register_job` and `register_workflow`. | The Playground always uses the memory store: Redis needs Node. The `redis` fields were checked in Node against a Redis that needs a password. Without the right password, the server doesn't start: `NOAUTH Authentication required.` when there's none, `WRONGPASS invalid username-password pair` when it's wrong. A field that contradicts the `url` stops it with a `ZodError` that names the field, like `redis password contradicts redis.url`. #### Run states | State | Meaning | | --- | --- | | `pending` | Created, not started yet. A background call returns this. | | `running` | An attempt is running. | | `retrying` | An attempt failed, and the job is waiting before the next one. | | `completed` | `result` holds the result. | | `failed` | Every attempt failed. `error` holds the last error's `message` and `name`. | #### Who can read a run A run belongs to the user who started it, identified by their `sub`. `get_job_status` answers only that user; everyone else gets `Run "" not found`, as if the run didn't exist. An anonymous caller on protocol 2026-07-28 gets a new identity on every request (`anon:` and a random id), so **it can never read its own runs**. That includes the Playground. To let clients poll background runs, [authenticate them](https://frontmcp.dev/learn/authenticating-clients). The examples below poll as a signed-in user, in-process, with `FrontMcpInstance.createDirect()`. #### Caveats - The class **must extend `JobContext`**, and like [`@Tool`](https://frontmcp.dev/reference/sdk/tool#caveats), `@Job` checks when TypeScript compiles that `execute()`'s parameter matches `inputSchema` and its return type matches `outputSchema`. - **Hooks run once per attempt.** A [`JobHook`](https://frontmcp.dev/reference/sdk/job#jobhook) sees every retry and every workflow step as an attempt of its own. Since 1.9.4: before, jobs ran through no flow, so a hook on the `execute_job` tool's call missed retries, background attempts and workflow steps, and a hook on a `@Job` class stopped the server from starting. - A result that **doesn't match `outputSchema` isn't retried**: the run fails at once, whatever `retry` says. - `timeout` is **only enforced for workflow steps**. A job started with `execute_job` runs until it finishes. - **Bad input isn't an `INVALID_INPUT` error.** `execute_job` fails with `TOOL_EXECUTION_ERROR` and the Zod issues (in production, only "Internal FrontMCP error"), and a job with `retry` retries it, waits included. - **Runs live in memory** unless you configure a Redis `store`: restarting the server loses them, and several processes each have their own. --- ## Usage ### Running a job and reading its result Call `execute_job` with the job's `name` and its `input`. It waits for the job and returns the run: its id, its state, the result, and the lines the job logged. Initial call configuration: ```js { tool: "execute_job", arguments: { name: "count-open-tickets", input: { customer: "Ada" } } } ``` ```ts count-open.job.ts active import { Job, JobContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", customer: "Ada", status: "open" }, { id: "T-2", customer: "Grace", status: "closed" }, { id: "T-3", customer: "Ada", status: "open" }, ]; @Job({ name: "count-open-tickets", description: "Count a customer's open tickets", inputSchema: { customer: z.string().min(1) }, outputSchema: { customer: z.string(), open: z.number() }, }) export class CountOpenTickets extends JobContext { async execute({ customer }: { customer: string }) { this.log(`Counting open tickets for ${customer}`); const open = tickets.filter((t) => t.customer === customer && t.status === "open").length; return { customer, open }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CountOpenTickets } from "./count-open.job"; @App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets] }) export class HelpDeskApp {} ``` ```ts count-open.test.ts import { test, expect } from "@frontmcp/testing"; test("the job tools are added to the server", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t) => t.name); expect(names).toEqual(["execute_job", "execute_workflow", "get_job_status", "get_workflow_status"]); }); test("execute_job returns the result and the logs", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Ada" } }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ runId: expect.any(String), state: "completed", result: { customer: "Ada", open: 2 }, logs: [expect.stringMatching(/^\[\d{4}-\d\d-\d\dT[\d:.]+Z\] Counting open tickets for Ada$/)], }); }); test("list_jobs isn't listed, but can be called", async ({ mcp }) => { const result = await mcp.tools.call("list_jobs", {}); expect(result.json()).toMatchObject({ count: 1, jobs: [{ name: "count-open-tickets", description: "Count a customer's open tickets" }] }); }); ``` Open the **Tests** tab. The app declares no tools, yet the server has four: FrontMCP added them for the job. ### Failing A job fails when `execute()` throws, and `execute_job` returns an `isError` result. What the client reads depends on how the job failed: ### Failures ### Example: Bad input Input that doesn't match `inputSchema` fails with `TOOL_EXECUTION_ERROR`, and the text lists each Zod issue with its path. In production, the client only gets "Internal FrontMCP error". Initial call configuration: ```js { tool: "execute_job", arguments: { name: "count-open-tickets", input: { customer: "" } } } ``` ```ts help-desk.app.ts import { App, Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "count-open-tickets", inputSchema: { customer: z.string().min(1) }, outputSchema: { customer: z.string(), open: z.number() }, }) export class CountOpenTickets extends JobContext { async execute({ customer }: { customer: string }) { return { customer, open: 2 }; } } @App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets] }) export class HelpDeskApp {} ``` ```ts bad-input.test.ts import { test, expect } from "@frontmcp/testing"; test("the Zod issues are in the text", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "" } }); expect(result).toBeError("TOOL_EXECUTION_ERROR"); expect(result.text()).toMatch(/^Tool "execute_job" execution failed: \[/); expect(result.text()).toMatch(/"path": \[\s*"customer"\s*\]/); }); ``` ### Example: A thrown error A plain `Error` fails with `TOOL_EXECUTION_ERROR`. In development the text has the message and the stack; in production, only "Internal FrontMCP error" and an error ID. Initial call configuration: ```js { tool: "execute_job", arguments: { name: "count-open-tickets", input: { customer: "Ada" } } } ``` ```ts help-desk.app.ts import { App, Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "count-open-tickets", inputSchema: { customer: z.string() }, outputSchema: { customer: z.string(), open: z.number() }, }) export class CountOpenTickets extends JobContext { async execute({ customer }: { customer: string }): Promise<{ customer: string; open: number }> { throw new Error("The ticket database is down"); } } @App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets] }) export class HelpDeskApp {} ``` ```ts thrown.test.ts import { test, expect } from "@frontmcp/testing"; test("the message follows the tool's", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Ada" } }); expect(result).toBeError("TOOL_EXECUTION_ERROR"); expect(result.text()).toMatch(/^Tool "execute_job" execution failed: The ticket database is down\n/); }); ``` ### Example: A public error `this.fail()` with a `PublicMcpError` sends your message and code as they are, in development and in production. Use it for failures the model can act on. Initial call configuration: ```js { tool: "execute_job", arguments: { name: "count-open-tickets", input: { customer: "Zed" } } } ``` ```ts help-desk.app.ts import { App, Job, JobContext, PublicMcpError, z } from "@frontmcp/sdk"; const customers = ["Ada", "Grace"]; @Job({ name: "count-open-tickets", inputSchema: { customer: z.string() }, outputSchema: { customer: z.string(), open: z.number() }, }) export class CountOpenTickets extends JobContext { async execute({ customer }: { customer: string }) { if (!customers.includes(customer)) { this.fail(new PublicMcpError(`There's no customer ${customer}.`, "CUSTOMER_NOT_FOUND")); } return { customer, open: 2 }; } } @App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets] }) export class HelpDeskApp {} ``` ```ts public.test.ts import { test, expect } from "@frontmcp/testing"; test("the client reads the message and the code", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Zed" } }); expect(result).toBeError("CUSTOMER_NOT_FOUND"); expect(result.text()).toBe("There's no customer Zed."); }); ``` ### Retrying a job that fails With `retry`, a failed attempt is tried again after a wait. Here the mail server is busy two times out of three, so the job succeeds on its third attempt, after waiting 100 and then 200 milliseconds: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "notify-agent", input: { agent: "nour", ticket: "T-1" } } } ``` ```ts notify-agent.job.ts active import { Job, JobContext, z } from "@frontmcp/sdk"; import { sendEmail } from "./mail"; @Job({ name: "notify-agent", description: "Email a support agent about a ticket", inputSchema: { agent: z.string(), ticket: z.string() }, outputSchema: { sent: z.boolean() }, retry: { maxAttempts: 3, backoffMs: 100 }, }) export class NotifyAgent extends JobContext { async execute({ agent, ticket }: { agent: string; ticket: string }) { this.log(`Attempt ${this.attempt}: emailing ${agent} about ${ticket}`); sendEmail(agent, `${ticket} needs you`); return { sent: true }; } } ``` ```ts mail.ts let calls = 0; // Stands in for a mail server that's busy two times out of three. export function sendEmail(to: string, subject: string) { calls += 1; if (calls % 3 !== 0) throw new Error("Mail server busy"); } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { NotifyAgent } from "./notify-agent.job"; @App({ id: "help-desk", name: "Help Desk", jobs: [NotifyAgent] }) export class HelpDeskApp {} ``` ```ts retry.test.ts import { test, expect } from "@frontmcp/testing"; test("the third attempt succeeds, after two waits", async ({ mcp }) => { const started = Date.now(); const result = await mcp.tools.call("execute_job", { name: "notify-agent", input: { agent: "nour", ticket: "T-1" } }); expect(Date.now() - started).toBeGreaterThanOrEqual(300); expect(result.json()).toMatchObject({ state: "completed", result: { sent: true } }); }); test("only the successful attempt's logs are kept, and it knows it's the third", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "notify-agent", input: { agent: "nour", ticket: "T-2" } }); expect(result.json().logs).toEqual([expect.stringMatching(/\] Attempt 3: emailing nour about T-2$/)]); }); ``` If the last attempt fails too, the call fails with that attempt's error. `this.attempt` says which attempt is running. Only retry work that's safe to repeat: here, a second email is better than none, but a job that charges a card needs to know whether the first attempt went through. ### Running a job in the background With `background: true`, `execute_job` returns a `runId` at once, and the job runs after the call has returned. The caller that started it polls `get_job_status` with the `runId` until the run is `completed` or `failed`. Initial call configuration: ```js { tool: "execute_job", arguments: { name: "export-tickets", input: { customer: "Ada" }, background: true } } ``` ```ts export-tickets.job.ts active import { Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "export-tickets", description: "Export a customer's tickets as CSV", inputSchema: { customer: z.string() }, outputSchema: { customer: z.string(), csv: z.string() }, }) export class ExportTickets extends JobContext { async execute({ customer }: { customer: string }) { this.log(`Exporting tickets for ${customer}`); await new Promise((resolve) => setTimeout(resolve, 100)); // stands in for a slow query return { customer, csv: "id,title\nT-1,Cannot log in\nT-3,Login link expired" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { ExportTickets } from "./export-tickets.job"; @App({ id: "help-desk", name: "Help Desk", jobs: [ExportTickets] }) export class HelpDeskApp {} ``` ```ts background.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("a background run returns at once", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "export-tickets", input: { customer: "Ada" }, background: true }); expect(result.json()).toEqual({ runId: expect.any(String), state: "pending" }); }); test("an anonymous caller can't read its own run", async ({ mcp }) => { const { runId } = (await mcp.tools.call("execute_job", { name: "export-tickets", input: { customer: "Ada" }, background: true })).json(); const status = await mcp.tools.call("get_job_status", { runId }); expect(status).toBeError("INVALID_INPUT"); expect(status.text()).toBe(`Run "${runId}" not found`); }); test("a signed-in caller polls until the run completes", async () => { // createDirect() runs the server in-process, as the user you name. const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); const asNour = { authContext: { user: { sub: "nour" } } }; const started = await server.callTool("execute_job", { name: "export-tickets", input: { customer: "Ada" }, background: true }, asNour); const { runId } = started.structuredContent as { runId: string }; const first = await server.callTool("get_job_status", { runId }, asNour); await sleep(250); const last = await server.callTool("get_job_status", { runId }, asNour); const asSam = server.callTool("get_job_status", { runId }, { authContext: { user: { sub: "sam" } } }); await expect(asSam).rejects.toThrow(`Run "${runId}" not found`); await server.dispose(); expect(first.structuredContent).toMatchObject({ runId, jobName: "export-tickets", state: "running", logs: [] }); expect(last.structuredContent).toMatchObject({ state: "completed", result: { customer: "Ada" }, attempt: 1, startedAt: expect.any(Number), completedAt: expect.any(Number), }); }); ``` Without the TC39 `AsyncContext`, FrontMCP's [browser build](https://frontmcp.dev/reference/sdk/create-fetch-handler#in-a-browser) serves one request at a time and runs a background job before the next one, so there the first poll already finds the run `completed`. The Playground provides a minimal `AsyncContext`, so here the run is still `running` at first, as on Node. The Call tab shows the `pending` run. Calling `get_job_status` with its `runId` from the Call tab fails with "not found": the Playground's caller is anonymous, and [nobody can read an anonymous caller's runs](https://frontmcp.dev/reference/sdk/job#who-can-read-a-run), not even that caller. The last test signs in as `nour`, who can, and `sam`, who can't. [Running Jobs in the Background](https://frontmcp.dev/learn/running-jobs-in-the-background) walks through polling. ### Using a provider A job gets providers with `this.get()`, from its app and from the server, as the app's tools do. Both get the same instance, so a tool and a job share data: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "count-open-tickets", input: { customer: "Ada" } } } ``` ```ts count-open.job.ts active import { Job, JobContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Job({ name: "count-open-tickets", inputSchema: { customer: z.string() }, outputSchema: { customer: z.string(), open: z.number() }, }) export class CountOpenTickets extends JobContext { async execute({ customer }: { customer: string }) { return { customer, open: this.get(TicketStore).openFor(customer).length }; } } ``` ```ts close-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Tool({ name: "close_ticket", description: "Close a ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { this.get(TicketStore).close(id); return { id, status: "closed" }; } } ``` ```ts ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets = [ { id: "T-1", customer: "Ada", status: "open" }, { id: "T-2", customer: "Grace", status: "closed" }, { id: "T-3", customer: "Ada", status: "open" }, ]; openFor(customer: string) { return this.tickets.filter((t) => t.customer === customer && t.status === "open"); } close(id: string) { const ticket = this.tickets.find((t) => t.id === id); if (ticket) ticket.status = "closed"; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; import { CountOpenTickets } from "./count-open.job"; import { TicketStore } from "./ticket-store"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket], jobs: [CountOpenTickets], providers: [TicketStore], // for the app's tools and its jobs }) export class HelpDeskApp {} @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class HelpDeskServer {} ``` ```ts provider.test.ts import { test, expect } from "@frontmcp/testing"; test("the job sees what the tool changed", async ({ mcp }) => { const count = async () => (await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Ada" } })).json().result.open; expect(await count()).toBe(2); await mcp.tools.call("close_ticket", { id: "T-1" }); expect(await count()).toBe(1); }); ``` A provider in `@FrontMcp({ providers })` works too, and is shared with every app. A provider on another app isn't available to the job: see [Troubleshooting](https://frontmcp.dev/reference/sdk/job#provider--is-not-available-in-a-job). (Changed in 1.9.2: before, a job only saw the server's providers.) ### Calling another service from a job `this.fetch()` works in a job as in a tool, and sends the request's tracing headers, so the other service's logs tie its request to the call that started the job. A background run keeps the caller's trace: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "sync-customer", input: { customerId: "C-1" } } } ``` ```ts sync-customer.job.ts active import { Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "sync-customer", description: "Copy a customer's plan from the CRM", inputSchema: { customerId: z.string() }, outputSchema: { customerId: z.string(), plan: z.string() }, }) export class SyncCustomer extends JobContext { async execute({ customerId }: { customerId: string }) { const response = await this.fetch(`https://crm.example/customers/${customerId}`); const { plan } = await response.json(); return { customerId, plan }; } } ``` ```ts crm.example.ts // Stands in for the CRM, which the Playground can't reach. It answers requests for // crm.example in-process, and keeps the tracing headers of each. A real server // doesn't need this file. export const received: { traceparent: string | null; requestId: string | null }[] = []; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "crm.example") return realFetch(input, init); const headers = new Headers(init?.headers); received.push({ traceparent: headers.get("traceparent"), requestId: headers.get("x-request-id") }); return Response.json({ plan: "business" }); }; ``` ```ts help-desk.app.ts import "./crm.example"; import { App } from "@frontmcp/sdk"; import { SyncCustomer } from "./sync-customer.job"; @App({ id: "help-desk", name: "Help Desk", jobs: [SyncCustomer] }) export class HelpDeskApp {} ``` ```ts fetch.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { received } from "./crm.example"; import { HelpDeskApp } from "./help-desk.app"; const traced = { traceparent: expect.stringMatching(/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$/), requestId: expect.any(String) }; test("the CRM gets the tracing headers", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-1" } }); expect(result.json().result).toEqual({ customerId: "C-1", plan: "business" }); expect(received.at(-1)).toEqual(traced); }); test("a background run sends them too", async () => { const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); const asNour = { authContext: { user: { sub: "nour" } } }; const before = received.length; await server.callTool("execute_job", { name: "sync-customer", input: { customerId: "C-1" }, background: true }, asNour); await new Promise((resolve) => setTimeout(resolve, 100)); await server.dispose(); expect(received.slice(before)).toEqual([traced]); }); ``` In the background, `this.context` is a copy of the starting call's: the same session, caller and trace, and a request id of its own, since the call it came from has already returned. (Changed in 1.9.2: before, a job had no request context, `this.context` threw, and `this.fetch()` sent no tracing headers.) ### Restricting who can run a job `permissions` limits a job to callers with a role, a scope, or anything a function can check. Anyone else gets the same error as for a job that doesn't exist, and `list_jobs` doesn't show them the job: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "export-invoices", input: { month: "2026-08" } } } ``` ```ts export-invoices.job.ts active import { Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "export-invoices", description: "Export a month's invoices", inputSchema: { month: z.string().regex(/^\d{4}-\d\d$/) }, outputSchema: { month: z.string(), invoices: z.number() }, permissions: [{ action: "execute", roles: ["billing"] }], }) export class ExportInvoices extends JobContext { async execute({ month }: { month: string }) { return { month, invoices: 42 }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { ExportInvoices } from "./export-invoices.job"; @App({ id: "help-desk", name: "Help Desk", jobs: [ExportInvoices] }) export class HelpDeskApp {} ``` ```ts permissions.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; test("an anonymous caller is refused, as if the job didn't exist", async ({ mcp }) => { const refused = await mcp.tools.call("execute_job", { name: "export-invoices", input: { month: "2026-08" } }); expect(refused).toBeError("JOB_NOT_AUTHORIZED"); expect(refused.text()).toBe('Job or workflow "export-invoices" not found or not permitted'); const missing = await mcp.tools.call("execute_job", { name: "export-payroll" }); expect(missing.text()).toBe('Job or workflow "export-payroll" not found or not permitted'); }); test("list_jobs doesn't show it to them", async ({ mcp }) => { expect((await mcp.tools.call("list_jobs", {})).json()).toEqual({ jobs: [], count: 0 }); }); test("someone in billing can run it", async () => { const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); const result = await server.callTool( "execute_job", { name: "export-invoices", input: { month: "2026-08" } }, { authContext: { user: { sub: "li", roles: ["billing"] } } }, ); await server.dispose(); expect(result.structuredContent).toMatchObject({ state: "completed", result: { month: "2026-08", invoices: 42 } }); }); ``` ### Hooking every attempt of a job A [`JobHook`](https://frontmcp.dev/reference/sdk/job#jobhook) runs for each attempt of a job, so a plugin can audit, time or cache job runs the way it hooks tool calls. `ctx.state.attempt` tells the attempts apart, and `ctx.state.flowError` is set in the `always` stages when the attempt failed. ### Job hooks ### Example: Seeing each attempt The plugin records how each attempt ended. The mail server is busy two times out of three, so one call makes three attempts: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "notify-agent", input: { agent: "nour", ticket: "T-1" } } } ``` ```ts attempt-log.plugin.ts active import { DynamicPlugin, FlowCtxOf, JobHook, Plugin } from "@frontmcp/sdk"; export const attempts: string[] = []; @Plugin({ name: "attempt-log" }) export class AttemptLogPlugin extends DynamicPlugin { @JobHook.Did("finalize") async record(ctx: FlowCtxOf<"jobs:execute-job">) { const { job, attempt, flowError } = ctx.state; attempts.push(`${job?.name} attempt ${attempt}: ${flowError ? flowError.message : "completed"}`); } } ``` ```ts notify-agent.job.ts import { Job, JobContext, z } from "@frontmcp/sdk"; import { sendEmail } from "./mail"; @Job({ name: "notify-agent", description: "Email a support agent about a ticket", inputSchema: { agent: z.string(), ticket: z.string() }, outputSchema: { sent: z.boolean() }, retry: { maxAttempts: 3, backoffMs: 50 }, }) export class NotifyAgent extends JobContext { async execute({ agent, ticket }: { agent: string; ticket: string }) { sendEmail(agent, `${ticket} needs you`); return { sent: true }; } } ``` ```ts mail.ts let calls = 0; // Stands in for a mail server that's busy two times out of three. export function sendEmail(to: string, subject: string) { calls += 1; if (calls % 3 !== 0) throw new Error("Mail server busy"); } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { AttemptLogPlugin } from "./attempt-log.plugin"; import { NotifyAgent } from "./notify-agent.job"; @App({ id: "help-desk", name: "Help Desk", jobs: [NotifyAgent], plugins: [AttemptLogPlugin] }) export class HelpDeskApp {} ``` ```ts attempts.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { attempts } from "./attempt-log.plugin"; import { HelpDeskApp } from "./help-desk.app"; test("the hook runs once per attempt", async ({ mcp }) => { attempts.length = 0; await mcp.tools.call("execute_job", { name: "notify-agent", input: { agent: "nour", ticket: "T-2" } }); expect(attempts).toEqual([ "notify-agent attempt 1: Mail server busy", "notify-agent attempt 2: Mail server busy", "notify-agent attempt 3: completed", ]); }); test("a background run's attempts too", async () => { const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); attempts.length = 0; const asNour = { authContext: { user: { sub: "nour" } } }; await server.callTool("execute_job", { name: "notify-agent", input: { agent: "nour", ticket: "T-3" }, background: true }, asNour); await new Promise((resolve) => setTimeout(resolve, 300)); await server.dispose(); expect(attempts).toHaveLength(3); expect(attempts.at(-1)).toBe("notify-agent attempt 3: completed"); }); ``` ### Example: Answering from a cache A hook can answer for the job with `ctx.respond({ result, logs })`. This one saves each report the job builds, and answers the next request for the same day from it, without running `execute()`: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "build-daily-report", input: { day: "2026-09-26" } } } ``` ```ts report-cache.plugin.ts active import { DynamicPlugin, FlowCtxOf, JobHook, Plugin } from "@frontmcp/sdk"; const reports = new Map(); @Plugin({ name: "report-cache" }) export class ReportCachePlugin extends DynamicPlugin { @JobHook.Will("execute", { filter: (ctx) => ctx.state.job?.name === "build-daily-report" }) async answerFromCache(ctx: FlowCtxOf<"jobs:execute-job">) { const { day } = ctx.state.parsedInput as { day: string }; if (reports.has(day)) ctx.respond({ result: reports.get(day), logs: [`Report for ${day} from the cache`] }); } @JobHook.Did("validateOutput", { filter: (ctx) => ctx.state.job?.name === "build-daily-report" }) async save(ctx: FlowCtxOf<"jobs:execute-job">) { const { day } = ctx.state.parsedInput as { day: string }; reports.set(day, ctx.state.answer?.result); } } ``` ```ts build-daily-report.job.ts import { Job, JobContext, z } from "@frontmcp/sdk"; export const built: string[] = []; @Job({ name: "build-daily-report", description: "Count the tickets opened and closed on one day.", inputSchema: { day: z.string() }, outputSchema: { day: z.string(), opened: z.number(), closed: z.number() }, }) export class BuildDailyReport extends JobContext { async execute({ day }: { day: string }) { built.push(day); this.log(`Read every ticket of ${day}`); return { day, opened: 12, closed: 9 }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { BuildDailyReport } from "./build-daily-report.job"; import { ReportCachePlugin } from "./report-cache.plugin"; @App({ id: "help-desk", name: "Help Desk", jobs: [BuildDailyReport], plugins: [ReportCachePlugin] }) export class HelpDeskApp {} ``` ```ts cache.test.ts import { test, expect } from "@frontmcp/testing"; import { built } from "./build-daily-report.job"; test("the second run of a day is answered from the cache", async ({ mcp }) => { built.length = 0; const run = () => mcp.tools.call("execute_job", { name: "build-daily-report", input: { day: "2026-09-25" } }); const first = (await run()).json(); const second = (await run()).json(); expect(built).toEqual(["2026-09-25"]); expect(first.logs).toEqual([expect.stringMatching(/\] Read every ticket of 2026-09-25$/)]); expect(second).toEqual({ runId: expect.any(String), state: "completed", result: { day: "2026-09-25", opened: 12, closed: 9 }, logs: ["Report for 2026-09-25 from the cache"], }); }); ``` The answer becomes the run's result, which `get_job_status` returns too. It is checked against `outputSchema`, as a result returned by the job is. ### Example: On the job class A `@Job` class can declare hooks for its own attempts. Stages before `createJobContext` run before the job's instance exists, so a hook on them is a `static` method, with the class as `this`. Here a static hook tidies the input before `inputSchema` checks it, and an instance hook adds a line to the run's logs: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "count-open-tickets", input: { customer: " ada " } } } ``` ```ts count-open.job.ts active import { FlowCtxOf, Job, JobContext, JobHook, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", customer: "Ada", status: "open" }, { id: "T-3", customer: "Ada", status: "open" }, ]; @Job({ name: "count-open-tickets", inputSchema: { customer: z.enum(["Ada", "Grace"]) }, outputSchema: { customer: z.string(), open: z.number() }, }) export class CountOpenTickets extends JobContext { @JobHook.Will("validateInput") static tidyCustomer(ctx: FlowCtxOf<"jobs:execute-job">) { const { customer } = ctx.state.input as { customer: string }; const name = customer.trim(); ctx.state.set("input", { customer: name.charAt(0).toUpperCase() + name.slice(1).toLowerCase() }); } @JobHook.Did("execute") async noteAttempt() { this.log(`Counted on attempt ${this.attempt}`); } async execute({ customer }: { customer: string }) { return { customer, open: tickets.filter((t) => t.customer === customer && t.status === "open").length }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CountOpenTickets } from "./count-open.job"; @App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets] }) export class HelpDeskApp {} ``` ```ts class-hooks.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance, Job, JobContext, JobHook, z } from "@frontmcp/sdk"; test("the static hook tidies the input, and the instance hook logs", async ({ mcp }) => { const run = (await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: " ada " } })).json(); expect(run.result).toEqual({ customer: "Ada", open: 2 }); expect(run.logs).toEqual([expect.stringMatching(/\] Counted on attempt 1$/)]); }); test("an instance hook before `createJobContext` stops the server", async () => { @Job({ name: "count-open-tickets", inputSchema: { customer: z.string() }, outputSchema: { open: z.number() } }) class TidyCountJob extends JobContext { @JobHook.Will("validateInput") async tidyCustomer() {} async execute() { return { open: 2 }; } } @App({ id: "help-desk", name: "Help Desk", jobs: [TidyCountJob] }) class HelpDeskApp {} const starting = FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); await expect(starting).rejects.toThrow( "Job \"TidyCountJob\" declares hooks that would never run: tidyCustomer() (will 'validateInput' of jobs:execute-job): runs before 'createJobContext' builds the instance it runs on; declare it as a static method to run it without an instance.", ); }); ``` A plugin or a provider on an app hooks that app's jobs only. To hook every job a server runs, register the plugin on `@FrontMcp`. [Hooks](https://frontmcp.dev/reference/sdk/hooks) covers `Will`, `Did`, `Around` and `Stage`, which work on `JobHook` as on every flow. ### Writing a job as a function For a small job that doesn't log, the function form is shorter. The handler gets the `JobContext` as its second argument, for `ctx.get()` and `ctx.auth`. Initial call configuration: ```js { tool: "execute_job", arguments: { name: "shout", input: { text: "your ticket is solved" } } } ``` ```ts help-desk.app.ts import { App, job, z } from "@frontmcp/sdk"; export const shout = job({ name: "shout", inputSchema: { text: z.string() }, outputSchema: { text: z.string() }, })(async ({ text }) => { return { text: text.toUpperCase() }; }); @App({ id: "help-desk", name: "Help Desk", jobs: [shout] }) export class HelpDeskApp {} ``` --- ### Checking a hook's cached result A cache can return stale data whose shape no longer matches the job. Hook answers go through `outputSchema` too: a mismatch fails with `INVALID_OUTPUT`, without retrying. Initial call configuration: ```js { tool: "execute_job", arguments: { name: "cached-count" } } ``` ```ts cached-count.job.ts active import { Job, JobContext, JobHook, z } from "@frontmcp/sdk"; export let cacheReads = 0; @Job({ name: "cached-count", inputSchema: {}, outputSchema: { open: z.number() }, retry: { maxAttempts: 3, backoffMs: 1 } }) export class CachedCount extends JobContext { @JobHook.Will("execute") cached(ctx: any) { cacheReads += 1; ctx.respond({ result: { open: "twelve" }, logs: [] }); } async execute() { return { open: 12 }; } } ``` ```ts cached-count.test.ts import { test, expect } from "@frontmcp/testing"; import { cacheReads } from "./cached-count.job"; test("a mismatched cache result fails without retrying", async ({ mcp }) => { const before = cacheReads; const result = await mcp.tools.call("execute_job", { name: "cached-count" }); expect(result).toBeError("INVALID_OUTPUT"); expect(cacheReads - before).toBe(1); }); ``` ## Troubleshooting ### `Job or workflow "…" not found or not permitted` `execute_job` found no job by that name that the caller may run. Check, in order: 1. The name is spelled as in `@Job({ name })`. If the job also sets `id`, use the `id`. 2. The class is in an app's `jobs` array, and the app is in `@FrontMcp({ apps })`. 3. The job's `permissions` let this caller run it. The error is the same on purpose, so a caller can't tell a job they may not run from one that doesn't exist. Call `list_jobs` as that caller to see what they can run. ### `Run "…" not found` `get_job_status` only answers the caller who started the run, and says "not found" to everyone else. An anonymous caller on protocol 2026-07-28, like the Playground's, can't read its own runs either, because each of its requests has a new identity. See [who can read a run](https://frontmcp.dev/reference/sdk/job#who-can-read-a-run). Also check that the `runId` is from this server since its last restart: runs live in memory by default. ### `Provider "…" is not available` in a job A job resolves providers from its own app and from the server, so a provider registered on another app is missing: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "count-open-tickets", input: { customer: "Ada" } } } ``` ```ts help-desk.app.ts import { App, FrontMcp, Job, JobContext, Provider, z } from "@frontmcp/sdk"; @Provider({ name: "TicketStore" }) export class TicketStore { openFor(customer: string) { return customer === "Ada" ? ["T-1", "T-3"] : []; } } @Job({ name: "count-open-tickets", inputSchema: { customer: z.string() }, outputSchema: { customer: z.string(), open: z.number() }, }) export class CountOpenTickets extends JobContext { async execute({ customer }: { customer: string }) { return { customer, open: this.get(TicketStore).openFor(customer).length }; } } @App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets] }) export class HelpDeskApp {} // 🚩 TicketStore is on another app @App({ id: "tickets", name: "Tickets", providers: [TicketStore] }) export class TicketsApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, TicketsApp] }) export default class Server {} ``` ```ts provider.test.ts import { test, expect } from "@frontmcp/testing"; test("the job can't find the other app's provider", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Ada" } }); expect(result).toBeError("TOOL_EXECUTION_ERROR"); expect(result.text()).toContain('Provider "TicketStore" is not available: not found in local or parent registries'); }); ``` Register the provider on the job's app, or on the server with `@FrontMcp({ providers })` to share it between apps, as in [Using a provider](https://frontmcp.dev/reference/sdk/job#using-a-provider). Before 1.9.2, a job didn't see its own app's providers either, so servers written for 1.9.1 often have them on `@FrontMcp`, where they still work. ### `Tool "execute_job" execution failed: [` followed by a list The input didn't match the job's `inputSchema`. Each entry in the list has the field's `path` and a `message`. See [Bad input](https://frontmcp.dev/reference/sdk/job#failing). If the job has `retry`, the bad input was retried too, so the call took as long as every attempt and wait. ### A job runs past its `timeout` `execute_job` doesn't enforce `timeout`; only a [workflow step](https://frontmcp.dev/reference/sdk/workflow#options) does. A job started with `execute_job` runs until `execute()` returns: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "export-tickets", input: {} } } ``` ```ts help-desk.app.ts import { App, Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "export-tickets", inputSchema: {}, outputSchema: { rows: z.number() }, timeout: 50 }) export class ExportTickets extends JobContext { async execute() { await new Promise((resolve) => setTimeout(resolve, 150)); return { rows: 3 }; } } @App({ id: "help-desk", name: "Help Desk", jobs: [ExportTickets] }) export class HelpDeskApp {} ``` ```ts timeout.test.ts import { test, expect } from "@frontmcp/testing"; test("the job finishes after its timeout", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "export-tickets", input: {} }); expect(result.json()).toMatchObject({ state: "completed", result: { rows: 3 } }); }); ``` If a job must stop, give the slow work inside `execute()` its own deadline. ### `Tool output validation failed (output does not match outputSchema at …)` The job returned something `outputSchema` doesn't describe, and the text names the first field that doesn't match. `execute_job` fails with `INVALID_OUTPUT`, a background run fails with `InvalidOutputError` in its `error`, and the job isn't retried, even with `retry`. Fields the schema doesn't declare are dropped instead: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "count-open-tickets", input: { customer: "Ada" } } } ``` ```ts help-desk.app.ts import { App, Job, JobContext, z } from "@frontmcp/sdk"; export let attempts = 0; // Stands in for rows from a database you don't control: "open" comes back as text. const rows: Record> = { Ada: { open: "2", internalNote: "VIP" }, Grace: { open: 0, internalNote: "Prefers email" }, }; @Job({ name: "count-open-tickets", inputSchema: { customer: z.string() }, outputSchema: { open: z.number() }, retry: { maxAttempts: 3, backoffMs: 10 } }) export class CountOpenTickets extends JobContext { async execute({ customer }: { customer: string }) { attempts += 1; return rows[customer] as { open: number }; } } @App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets] }) export class HelpDeskApp {} ``` ```ts output.test.ts import { test, expect } from "@frontmcp/testing"; import { attempts } from "./help-desk.app"; test("a value of the wrong type fails the run, without a retry", async ({ mcp }) => { const before = attempts; const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Ada" } }); expect(result).toBeError("INVALID_OUTPUT"); expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at open)"); expect(attempts - before).toBe(1); }); test("a field the schema doesn't declare is dropped", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Grace" } }); expect(result.json().result).toEqual({ open: 0 }); }); ``` TypeScript catches most mismatches when `execute()` returns a typed value. For data you don't control, build the result from the fields you mean to send, or parse it with the schema yourself, `z.object({ open: z.number() }).parse(row)`, and fail with a [`PublicMcpError`](https://frontmcp.dev/reference/sdk/job#failing) when it doesn't parse. (Changed in 1.9.2: before, FrontMCP didn't check the result, and sent it as it was.) ### `Job "…" declares hooks that would never run` The server doesn't start, with `InvalidHookFlowError`: a `@Job` class has an instance method hook on a stage before `createJobContext`, where the job's instance doesn't exist yet. The message names the method and the stage, and says to `declare it as a static method`. Make it `static`, and read what it needs from its `ctx` argument instead of `this`, as [On the job class](https://frontmcp.dev/reference/sdk/job#hooking-every-attempt-of-a-job) does. `Job "…" has hooks for unsupported flows` means a hook on the class is for another flow, like a `ToolHook`: move it to a plugin or a provider. ### TypeScript says `Unable to resolve signature of class decorator` `@Job` checks the class against its options, like [`@Tool`](https://frontmcp.dev/reference/sdk/tool#typescript-says-unable-to-resolve-signature-of-class-decorator). Error `TS1238` on the decorator line says which check failed: - `'execute() parameter error': "Parameter type does not match the input schema."`: `execute()`'s parameter type disagrees with `inputSchema`. - `'execute() return type error': "The method's return type is not assignable to the expected output schema type."`: `execute()` returns something `outputSchema` doesn't describe. - `'Job class error': "Class must extend JobContext"`: the class doesn't extend `JobContext`. A `@Job` without `outputSchema` doesn't compile either, and fails when the class is loaded with a Zod issue at the path `outputSchema`. --- # @Workflow > Chain jobs into steps that run in order or in parallel, each step's input built from the results of the steps before it. Source: https://frontmcp.dev/reference/sdk/workflow [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `@Workflow` declares a [workflow](https://frontmcp.dev/learn/chaining-jobs-into-workflows): a set of steps, each of which runs a [job](https://frontmcp.dev/reference/sdk/job). A step starts once the steps it depends on have finished, steps that don't depend on each other run in parallel, and a step's input can be built from the results of the steps before it. A client runs a workflow by name with the built-in `execute_workflow` tool. ```ts @Workflow(options) class MyWorkflow {} ``` --- ## Reference ### `@Workflow(options)` Apply `@Workflow` to an empty class: everything is in the options. List the class in an app's `workflows` array, and the jobs its steps run in the app's `jobs`. ```ts nightly-report.workflow.ts import { Workflow } from "@frontmcp/sdk"; @Workflow({ name: "nightly-report", description: "Collect one day's tickets, find the SLA breaches, and write a report", steps: [ { id: "collect", jobName: "collect-tickets", input: { day: "2026-09-26" } }, { id: "breaches", jobName: "find-sla-breaches", dependsOn: ["collect"], input: (steps) => ({ tickets: steps.get("collect").outputs.tickets }), }, { id: "report", jobName: "write-report", dependsOn: ["collect", "breaches"], input: (steps) => ({ tickets: steps.get("collect").outputs.tickets, breaches: steps.get("breaches").outputs.breaches }), }, ], }) export class NightlyReport {} ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CollectTickets, FindSlaBreaches, WriteReport } from "./jobs"; import { NightlyReport } from "./nightly-report.workflow"; @App({ id: "help-desk", name: "Help Desk", jobs: [CollectTickets, FindSlaBreaches, WriteReport], workflows: [NightlyReport], }) export class HelpDeskApp {} ``` [See more examples below](https://frontmcp.dev/reference/sdk/workflow#usage), and the whole server in the [Nightly Ticket Report](https://frontmcp.dev/examples/nightly-ticket-report) example. #### Options Required: | Option | Type | Description | | --- | --- | --- | | `name` | `string` | What clients pass to `execute_workflow`. Unique within the server. If you also set `id`, the id replaces it. | | `steps` | `WorkflowStep[]` | The steps, at least one. See [steps](https://frontmcp.dev/reference/sdk/workflow#steps). Their order in the array doesn't matter: `dependsOn` decides the order they run in. | Optional: | Option | Type | Default | Description | | --- | --- | --- | --- | | `description` | `string` | | What the workflow does. `list_workflows` shows it. | | `id` | `string` | `name` | A stable identifier. When set, it **replaces** `name`: clients and `list_workflows` use the id. | | `maxConcurrency` | `number` | `5` | How many steps may run at the same time. | | `timeout` | `number` | `600000` (10 minutes) | How long the whole workflow may run, in milliseconds. Checked each time a batch of steps finishes, so it doesn't interrupt a step that's running. | | `inputSchema` | object of Zod types | | Describes the input a client passes to `execute_workflow`. FrontMCP 1.8 doesn't check the input against it. | | `outputSchema` | an output schema | | Accepted, and not used by FrontMCP 1.8. | | `trigger` | `"manual" \| "webhook" \| "event"` | `"manual"` | How the workflow is meant to start. `list_workflows` shows it; nothing else in FrontMCP 1.8 reads it. | | `webhook` | `{ path?, secret?, methods? }` | | Settings for a webhook trigger. FrontMCP 1.8 doesn't serve a workflow's webhook, so they have no effect. (A [channel's webhook source](https://frontmcp.dev/reference/sdk/channel#sources) is served, since 1.8.5.) | | `tags` | `string[]` | `[]` | Labels to filter `list_workflows` by. | | `labels` | `Record` | `{}` | Key-value labels to filter `list_workflows` by. | | `hideFromDiscovery` | `boolean` | `false` | Leave the workflow out of `list_workflows`. It still runs by name. | | `permissions` | `JobPermission[]` | none: anyone may run it | Who may run and list the workflow. The rules work as [for jobs](https://frontmcp.dev/reference/sdk/job#permissions). | #### Steps | Field | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | | **Required.** Unique within the workflow. It names the step in `dependsOn`, in `steps.get()` and in the result's `stepResults`. | | `jobName` | `string` | | **Required.** The job to run: its `name`, or its `id` if it has one. | | `input` | object, or `(steps) => object` | the workflow's input | The job's input. A function receives [`steps`](https://frontmcp.dev/reference/sdk/workflow#stepsgetid) and builds the input from earlier steps' results. It doesn't receive the workflow's input. | | `dependsOn` | `string[]` | `[]` | Steps that must finish first. A step with none starts right away. | | `condition` | `(steps) => boolean` | | Checked when the step is ready to run. If it returns `false` or throws, the step is `skipped`. | | `continueOnError` | `boolean` | `false` | If the step fails, run its dependents anyway, and don't fail the workflow because of it. | | `retry` | `JobRetryConfig` | the job's `retry`, or else 3 attempts | Retries for this step, with the same fields as a [job's `retry`](https://frontmcp.dev/reference/sdk/job#retry). **Without `retry` on the step or the job, a step is tried three times**, with waits of 1 and 2 seconds. | | `timeout` | `number` | the job's `timeout` (5 minutes) | How long one attempt may run, in milliseconds. A step that runs longer fails and aborts the job's `this.signal` (`ctx.signal` for function jobs). Code must honor that signal to stop work. The attempt ends failed, `Did("execute")` is skipped, and finalize hooks see `WorkflowJobTimeoutError`. Changed in 1.9.5: before, an already-started attempt could finish as a success after the step timed out. | The job still checks its input against its own `inputSchema`, so input that doesn't match fails the step. #### `steps.get(id)` In `input` and `condition`, `steps.get(id)` returns a finished step's result: `{ outputs, state }`, where `outputs` is what the step's job returned and `state` is `"completed"`, `"failed"` or `"skipped"`. A failed or skipped step's `outputs` is `{}`. For a step that hasn't finished, or doesn't exist, `steps.get()` throws: in `input`, the step fails, and in `condition`, it's skipped. So list every step whose results a step reads in its `dependsOn`. #### How steps run 1. Every step whose `dependsOn` steps have all finished (completed or skipped) is ready. Up to `maxConcurrency` of them start together, as one batch. 2. The next batch starts when the whole batch has finished. 3. When a step fails, the steps that depend on it directly are `skipped`. Steps that don't depend on it keep running. 4. When every step has a result, the workflow is `failed` if any step failed without `continueOnError`, and `completed` otherwise. A skipped step counts as finished, so the steps after it run. That's what you want after a `condition` skip, but it also happens after a failure: [a step two levels below a failed step runs](https://frontmcp.dev/reference/sdk/workflow#a-step-ran-even-though-a-step-before-it-failed). Each attempt of a step runs the job's `jobs:execute-job` flow, as `execute_job` does, so a [`JobHook`](https://frontmcp.dev/reference/sdk/job#jobhook) sees it, and the job's own `permissions` are checked there against the caller who started the workflow. See [Seeing each step's attempts](https://frontmcp.dev/reference/sdk/workflow#seeing-each-steps-attempts). (New in 1.9.4: before, steps ran outside any flow.) #### `workflow(options)` The function form takes the same options and returns something to list in `workflows`: ```ts import { workflow } from "@frontmcp/sdk"; export const remindAda = workflow({ name: "remind-ada", steps: [{ id: "count", jobName: "count-open-tickets", input: { customer: "Ada" } }], }); ``` ### Built-in tools When any app declares a job or a workflow, FrontMCP adds these tools to the server, next to [the job tools](https://frontmcp.dev/reference/sdk/job#built-in-tools): | Tool | In `tools/list` | Arguments | Returns | | --- | --- | --- | --- | | `execute_workflow` | Yes | `name`, `input?` (an object), `background?` (default `false`) | Without `background`: waits for every step, then `{ runId, state, result }`, where `result` is `{ workflowName, state, stepResults, startedAt, completedAt }` and `stepResults` maps each step's `id` to its `{ outputs, state }`. With `background: true`: `{ runId, state: "pending" }` at once. | | `get_workflow_status` | Yes | `runId` | `{ runId, workflowName, state, stepResults, startedAt, completedAt? }`. In FrontMCP 1.8, `stepResults` is always `{}`: read the steps with `get_job_status` instead, which returns the whole `result` for a workflow's `runId`. Only the user who started the run can read it, [as for jobs](https://frontmcp.dev/reference/sdk/job#who-can-read-a-run). | | `list_workflows` | No | `query?`, `tags?`, `labels?` | `{ workflows: [{ name, description?, trigger, stepCount, tags }], count }`, without the workflows the caller can't list or run. | | `remove_workflow` | No | `name` | Removes a workflow registered at run time. For a `@Workflow` class it fails with `Workflow "…" is not dynamic and cannot be removed`. | A run's `state` is `pending`, `running`, `completed` or `failed`. A workflow that ran, even with failed steps, is a successful `execute_workflow` call: check `state`, not `isError`. The call itself fails only when the workflow can't run: an unknown name, a caller without permission, a `dependsOn` mistake, or its `timeout`. #### Caveats - **The result doesn't say why a step failed.** A failed step is `{ outputs: {}, state: "failed" }`, whatever the reason; the error goes to the server's log, and an error of the step's job to [job hooks](https://frontmcp.dev/reference/sdk/workflow#seeing-each-steps-attempts) too. - **Steps retry by default.** A step whose job has no `retry` is tried three times, so a failing step takes 3 seconds before it counts as failed. `execute_job` runs the same job once. - **A failure only skips the next step.** The steps after a skipped step run, so a step two levels below a failed step runs, with `{}` as the skipped step's `outputs`. [Guard it with a `condition`.](https://frontmcp.dev/reference/sdk/workflow#a-step-ran-even-though-a-step-before-it-failed) - **`dependsOn` mistakes are found when the workflow runs**, not when the server starts. - **A function `input` doesn't get the workflow's input.** Only steps without `input` do. [Pass it through a step.](https://frontmcp.dev/reference/sdk/workflow#a-steps-input-function-cant-see-the-workflows-input) - **The workflow's `inputSchema` isn't checked**, and `trigger` and `webhook` don't do anything. - **Jobs' `logs` aren't kept**: a workflow's result has no logs. - A step runs its job as the caller who started the workflow, so a step whose job has `permissions` the caller doesn't meet fails. - A step's job calls tools on the `"job"` surface, as it does from `execute_job`: a tool whose `availableWhen.surface` leaves out `"job"` isn't found, and [`getCallSurface()`](https://frontmcp.dev/reference/sdk/context#getcallsurface-and-getrunningtool) is `"job"` in the job and the tools it calls. --- ## Usage ### Running steps one after another A step that lists another in `dependsOn` waits for it, and its `input` function can read that step's `outputs`: Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "remind-customer" } } ``` ```ts remind-customer.workflow.ts active import { Workflow } from "@frontmcp/sdk"; @Workflow({ name: "remind-customer", description: "Count Ada's open tickets and draft a reminder about them", steps: [ { id: "count", jobName: "count-open-tickets", input: { customer: "Ada" } }, { id: "draft", jobName: "draft-reminder", dependsOn: ["count"], input: (steps) => steps.get("count").outputs, }, ], }) export class RemindCustomer {} ``` ```ts jobs.ts import { Job, JobContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", customer: "Ada", status: "open" }, { id: "T-2", customer: "Grace", status: "closed" }, { id: "T-3", customer: "Ada", status: "open" }, ]; @Job({ name: "count-open-tickets", inputSchema: { customer: z.string() }, outputSchema: { customer: z.string(), open: z.number() }, }) export class CountOpenTickets extends JobContext { async execute({ customer }: { customer: string }) { return { customer, open: tickets.filter((t) => t.customer === customer && t.status === "open").length }; } } @Job({ name: "draft-reminder", inputSchema: { customer: z.string(), open: z.number() }, outputSchema: { message: z.string() }, }) export class DraftReminder extends JobContext { async execute({ customer, open }: { customer: string; open: number }) { return { message: `Hi ${customer}, you have ${open} open tickets. Reply to any of them if you still need help.` }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CountOpenTickets, DraftReminder } from "./jobs"; import { RemindCustomer } from "./remind-customer.workflow"; @App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets, DraftReminder], workflows: [RemindCustomer] }) export class HelpDeskApp {} ``` ```ts workflow.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance, Job, JobContext, Tool, ToolContext, Workflow, getCallSurface, z } from "@frontmcp/sdk"; test("each step's outputs are in stepResults", async ({ mcp }) => { const result = await mcp.tools.call("execute_workflow", { name: "remind-customer" }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ runId: expect.any(String), state: "completed", result: { workflowName: "remind-customer", state: "completed", stepResults: { count: { state: "completed", outputs: { customer: "Ada", open: 2 } }, draft: { state: "completed", outputs: { message: "Hi Ada, you have 2 open tickets. Reply to any of them if you still need help." }, }, }, startedAt: expect.any(Number), completedAt: expect.any(Number), }, }); }); test("list_workflows describes it", async ({ mcp }) => { const list = (await mcp.tools.call("list_workflows", {})).json(); expect(list.workflows).toEqual([ { name: "remind-customer", description: "Count Ada's open tickets and draft a reminder about them", trigger: "manual", stepCount: 2, tags: [], }, ]); }); test("a step's job calls tools on the `job` surface", async () => { @Tool({ name: "open_count", description: "Count open tickets, for jobs", inputSchema: {}, availableWhen: { surface: ["job"] } }) class OpenCount extends ToolContext { async execute() { return { open: 2, surface: getCallSurface() }; } } @Job({ name: "count-with-tool", inputSchema: {}, outputSchema: { open: z.number(), surface: z.string() } }) class CountWithTool extends JobContext { async execute() { return (await this.callTool("open_count", {})).structuredContent as { open: number; surface: string }; } } @Workflow({ name: "count-report", steps: [{ id: "count", jobName: "count-with-tool" }] }) class CountReport {} @App({ id: "help-desk", name: "Help Desk", tools: [OpenCount], jobs: [CountWithTool], workflows: [CountReport] }) class Desk {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }); const run = await server.callTool("execute_workflow", { name: "count-report" }); const listed = (await server.listTools()).tools.map((tool) => tool.name); await server.dispose(); expect(run.structuredContent).toMatchObject({ result: { stepResults: { count: { state: "completed", outputs: { open: 2, surface: "job" } } } } }); expect(listed).not.toContain("open_count"); }); ``` ### Running steps in parallel Steps that don't depend on each other start together, up to `maxConcurrency` at a time. Here three queues load at once, and `report` waits for all three: Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "queue-report" } } ``` ```ts queue-report.workflow.ts active import { Workflow } from "@frontmcp/sdk"; const loadSteps = ["billing", "login", "shipping"].map((queue) => ({ id: queue, jobName: "load-queue", input: { queue }, })); @Workflow({ name: "queue-report", steps: [ ...loadSteps, { id: "report", jobName: "sum-queues", dependsOn: ["billing", "login", "shipping"], input: (steps) => ({ sizes: ["billing", "login", "shipping"].map((id) => steps.get(id).outputs.size as number), }), }, ], }) export class QueueReport {} @Workflow({ name: "queue-report-one-at-a-time", maxConcurrency: 1, steps: loadSteps }) export class QueueReportOneAtATime {} ``` ```ts jobs.ts import { Job, JobContext, z } from "@frontmcp/sdk"; const sizes: Record = { billing: 4, login: 9, shipping: 2 }; @Job({ name: "load-queue", inputSchema: { queue: z.string() }, outputSchema: { queue: z.string(), size: z.number(), startedAt: z.number(), finishedAt: z.number() }, }) export class LoadQueue extends JobContext { async execute({ queue }: { queue: string }) { const startedAt = Date.now(); await new Promise((resolve) => setTimeout(resolve, 100)); // stands in for a slow query return { queue, size: sizes[queue], startedAt, finishedAt: Date.now() }; } } @Job({ name: "sum-queues", inputSchema: { sizes: z.array(z.number()) }, outputSchema: { total: z.number() } }) export class SumQueues extends JobContext { async execute({ sizes }: { sizes: number[] }) { return { total: sizes.reduce((a, b) => a + b, 0) }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { LoadQueue, SumQueues } from "./jobs"; import { QueueReport, QueueReportOneAtATime } from "./queue-report.workflow"; @App({ id: "help-desk", name: "Help Desk", jobs: [LoadQueue, SumQueues], workflows: [QueueReport, QueueReportOneAtATime] }) export class HelpDeskApp {} ``` ```ts parallel.test.ts import { test, expect } from "@frontmcp/testing"; type Load = { startedAt: number; finishedAt: number }; async function loads(mcp: any, name: string): Promise { const { stepResults } = (await mcp.tools.call("execute_workflow", { name })).json().result; return ["billing", "login", "shipping"].map((id) => stepResults[id].outputs); } test("the three queues load at the same time", async ({ mcp }) => { const [a, b, c] = await loads(mcp, "queue-report"); const lastStart = Math.max(a.startedAt, b.startedAt, c.startedAt); expect(lastStart).toBeLessThan(Math.min(a.finishedAt, b.finishedAt, c.finishedAt)); }); test("report runs after them, with their sizes", async ({ mcp }) => { const { stepResults } = (await mcp.tools.call("execute_workflow", { name: "queue-report" })).json().result; expect(stepResults.report).toEqual({ state: "completed", outputs: { total: 15 } }); }); test("with maxConcurrency: 1, each waits for the one before", async ({ mcp }) => { const [a, b, c] = await loads(mcp, "queue-report-one-at-a-time"); expect(b.startedAt).toBeGreaterThanOrEqual(a.finishedAt); expect(c.startedAt).toBeGreaterThanOrEqual(b.finishedAt); }); ``` Without the TC39 `AsyncContext`, FrontMCP's [browser build](https://frontmcp.dev/reference/sdk/create-fetch-handler#in-a-browser) runs ready steps one at a time, as if `maxConcurrency` were `1`. (Changed in 1.8.5: before, steps that overlapped there failed and were [retried](https://frontmcp.dev/reference/sdk/workflow#caveats), and the workflow could end `failed`.) The Playground provides a minimal `AsyncContext`, so here they run at the same time, as on Node. ### Passing input to a workflow `execute_workflow`'s `input` goes to every step that has no `input` of its own. To use it in later steps, have the first step return it, and read it from that step's `outputs`: Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "remind-customer", input: { customer: "Grace" } } } ``` ```ts remind-customer.workflow.ts active import { Workflow, z } from "@frontmcp/sdk"; @Workflow({ name: "remind-customer", description: "Count a customer's open tickets and draft a reminder about them", inputSchema: { customer: z.string() }, steps: [ { id: "count", jobName: "count-open-tickets" }, // no input: gets the workflow's { id: "draft", jobName: "draft-reminder", dependsOn: ["count"], input: (steps) => steps.get("count").outputs, // includes the customer }, ], }) export class RemindCustomer {} ``` ```ts jobs.ts import { Job, JobContext, z } from "@frontmcp/sdk"; const openTickets: Record = { Ada: 2, Grace: 1 }; @Job({ name: "count-open-tickets", inputSchema: { customer: z.string() }, outputSchema: { customer: z.string(), open: z.number() }, }) export class CountOpenTickets extends JobContext { async execute({ customer }: { customer: string }) { return { customer, open: openTickets[customer] ?? 0 }; } } @Job({ name: "draft-reminder", inputSchema: { customer: z.string(), open: z.number() }, outputSchema: { message: z.string() }, }) export class DraftReminder extends JobContext { async execute({ customer, open }: { customer: string; open: number }) { return { message: `Hi ${customer}, you have ${open} open tickets.` }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CountOpenTickets, DraftReminder } from "./jobs"; import { RemindCustomer } from "./remind-customer.workflow"; @App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets, DraftReminder], workflows: [RemindCustomer] }) export class HelpDeskApp {} ``` ```ts input.test.ts import { test, expect } from "@frontmcp/testing"; test("the first step gets the workflow's input", async ({ mcp }) => { const { result } = (await mcp.tools.call("execute_workflow", { name: "remind-customer", input: { customer: "Grace" } })).json(); expect(result.stepResults.draft.outputs).toEqual({ message: "Hi Grace, you have 1 open tickets." }); }); test("the workflow's inputSchema isn't checked, but the job's is", async ({ mcp }) => { const call = await mcp.tools.call("execute_workflow", { name: "remind-customer", input: { customer: 7 } }); expect(call).toBeSuccessful(); expect(call.json().result.stepResults).toEqual({ count: { state: "failed", outputs: {} }, draft: { state: "skipped", outputs: {} }, }); }); ``` The second test runs the workflow with a number for `customer`. `execute_workflow` accepts it, and the `count` step fails when its job checks the input, after three attempts. ### Handling a failed step By default, a failed step skips the steps that depend on it and fails the workflow. `continueOnError` lets its dependents run anyway, and `condition` skips a step on purpose: ### Failed steps ### Example: Skipped dependents `notify` fails, so `archive`, which depends on it, is skipped. `stats` doesn't depend on it and runs. The call succeeds; the workflow's `state` is `failed`. Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "close-day" } } ``` ```ts help-desk.app.ts import { App, Job, JobContext, Workflow, z } from "@frontmcp/sdk"; @Job({ name: "notify-team", inputSchema: {}, outputSchema: { sent: z.boolean() } }) export class NotifyTeam extends JobContext { async execute(): Promise<{ sent: boolean }> { throw new Error("Mail server busy"); } } @Job({ name: "archive-tickets", inputSchema: {}, outputSchema: { archived: z.number() } }) export class ArchiveTickets extends JobContext { async execute() { return { archived: 12 }; } } @Job({ name: "daily-stats", inputSchema: {}, outputSchema: { closed: z.number() } }) export class DailyStats extends JobContext { async execute() { return { closed: 12 }; } } @Workflow({ name: "close-day", steps: [ { id: "notify", jobName: "notify-team", retry: { maxAttempts: 1 } }, { id: "archive", jobName: "archive-tickets", dependsOn: ["notify"] }, { id: "stats", jobName: "daily-stats" }, ], }) export class CloseDay {} @App({ id: "help-desk", name: "Help Desk", jobs: [NotifyTeam, ArchiveTickets, DailyStats], workflows: [CloseDay] }) export class HelpDeskApp {} ``` ```ts failed.test.ts import { test, expect } from "@frontmcp/testing"; test("the workflow fails, and the call doesn't", async ({ mcp }) => { const call = await mcp.tools.call("execute_workflow", { name: "close-day" }); expect(call).toBeSuccessful(); expect(call.json().state).toBe("failed"); expect(call.json().result.stepResults).toEqual({ notify: { state: "failed", outputs: {} }, archive: { state: "skipped", outputs: {} }, stats: { state: "completed", outputs: { closed: 12 } }, }); }); ``` ### Example: continueOnError With `continueOnError: true` on `notify`, `archive` runs, and can check how `notify` went. The workflow is `completed`. Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "close-day" } } ``` ```ts help-desk.app.ts import { App, Job, JobContext, Workflow, z } from "@frontmcp/sdk"; @Job({ name: "notify-team", inputSchema: {}, outputSchema: { sent: z.boolean() } }) export class NotifyTeam extends JobContext { async execute(): Promise<{ sent: boolean }> { throw new Error("Mail server busy"); } } @Job({ name: "archive-tickets", inputSchema: { teamNotified: z.boolean() }, outputSchema: { archived: z.number(), note: z.string() } }) export class ArchiveTickets extends JobContext { async execute({ teamNotified }: { teamNotified: boolean }) { return { archived: 12, note: teamNotified ? "Team notified" : "Team not notified" }; } } @Workflow({ name: "close-day", steps: [ { id: "notify", jobName: "notify-team", retry: { maxAttempts: 1 }, continueOnError: true }, { id: "archive", jobName: "archive-tickets", dependsOn: ["notify"], input: (steps) => ({ teamNotified: steps.get("notify").state === "completed" }), }, ], }) export class CloseDay {} @App({ id: "help-desk", name: "Help Desk", jobs: [NotifyTeam, ArchiveTickets], workflows: [CloseDay] }) export class HelpDeskApp {} ``` ```ts continue.test.ts import { test, expect } from "@frontmcp/testing"; test("archive runs after notify fails", async ({ mcp }) => { const { state, result } = (await mcp.tools.call("execute_workflow", { name: "close-day" })).json(); expect(state).toBe("completed"); expect(result.stepResults.notify).toEqual({ state: "failed", outputs: {} }); expect(result.stepResults.archive.outputs).toEqual({ archived: 12, note: "Team not notified" }); }); ``` ### Example: condition `escalate` only runs when there are breached tickets. Today there are none, so it's `skipped`, and `report`, which depends on it, still runs. Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "sla-check" } } ``` ```ts help-desk.app.ts import { App, Job, JobContext, Workflow, z } from "@frontmcp/sdk"; @Job({ name: "find-sla-breaches", inputSchema: {}, outputSchema: { ids: z.array(z.string()) } }) export class FindSlaBreaches extends JobContext { async execute() { return { ids: [] as string[] }; } } @Job({ name: "escalate", inputSchema: { ids: z.array(z.string()) }, outputSchema: { paged: z.string() } }) export class Escalate extends JobContext { async execute({ ids }: { ids: string[] }) { return { paged: `On-call lead, about ${ids.join(", ")}` }; } } @Job({ name: "write-report", inputSchema: { escalated: z.boolean() }, outputSchema: { line: z.string() } }) export class WriteReport extends JobContext { async execute({ escalated }: { escalated: boolean }) { return { line: escalated ? "Breaches escalated." : "No escalation needed." }; } } @Workflow({ name: "sla-check", steps: [ { id: "breaches", jobName: "find-sla-breaches" }, { id: "escalate", jobName: "escalate", dependsOn: ["breaches"], condition: (steps) => (steps.get("breaches").outputs.ids as string[]).length > 0, input: (steps) => ({ ids: steps.get("breaches").outputs.ids }), }, { id: "report", jobName: "write-report", dependsOn: ["escalate"], input: (steps) => ({ escalated: steps.get("escalate").state === "completed" }), }, ], }) export class SlaCheck {} @App({ id: "help-desk", name: "Help Desk", jobs: [FindSlaBreaches, Escalate, WriteReport], workflows: [SlaCheck] }) export class HelpDeskApp {} ``` ```ts condition.test.ts import { test, expect } from "@frontmcp/testing"; test("escalate is skipped, and report still runs", async ({ mcp }) => { const { state, result } = (await mcp.tools.call("execute_workflow", { name: "sla-check" })).json(); expect(state).toBe("completed"); expect(result.stepResults.escalate).toEqual({ state: "skipped", outputs: {} }); expect(result.stepResults.report.outputs).toEqual({ line: "No escalation needed." }); }); ``` ### Running a workflow in the background With `background: true`, `execute_workflow` returns a `runId` at once. The user who started the run reads its steps with `get_job_status`: in FrontMCP 1.8, `get_workflow_status` returns the run's state, but never its steps. Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "queue-report", background: true } } ``` ```ts help-desk.app.ts active import { App, Job, JobContext, Workflow, z } from "@frontmcp/sdk"; @Job({ name: "load-queue", inputSchema: { queue: z.string() }, outputSchema: { queue: z.string(), size: z.number() } }) export class LoadQueue extends JobContext { async execute({ queue }: { queue: string }) { await new Promise((resolve) => setTimeout(resolve, 100)); // stands in for a slow query return { queue, size: queue.length }; } } @Workflow({ name: "queue-report", steps: [ { id: "billing", jobName: "load-queue", input: { queue: "billing" } }, { id: "login", jobName: "load-queue", dependsOn: ["billing"], input: { queue: "login" } }, ], }) export class QueueReport {} @App({ id: "help-desk", name: "Help Desk", jobs: [LoadQueue], workflows: [QueueReport] }) export class HelpDeskApp {} ``` ```ts background.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("the call returns before the steps run", async ({ mcp }) => { const result = await mcp.tools.call("execute_workflow", { name: "queue-report", background: true }); expect(result.json()).toEqual({ runId: expect.any(String), state: "pending" }); }); test("an anonymous caller can't read its own run", async ({ mcp }) => { const { runId } = (await mcp.tools.call("execute_workflow", { name: "queue-report", background: true })).json(); const status = await mcp.tools.call("get_workflow_status", { runId }); expect(status).toBeError("SERVER_ERROR"); expect(status.text()).toMatch(new RegExp(`^Run "${runId}" not found`)); }); test("the owner reads the steps with get_job_status", async () => { const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); const asNour = { authContext: { user: { sub: "nour" } } }; const started = await server.callTool("execute_workflow", { name: "queue-report", background: true }, asNour); const { runId } = started.structuredContent as { runId: string }; const running = await server.callTool("get_workflow_status", { runId }, asNour); await sleep(350); const done = await server.callTool("get_workflow_status", { runId }, asNour); const job = await server.callTool("get_job_status", { runId }, asNour); await server.dispose(); expect(running.structuredContent).toMatchObject({ workflowName: "queue-report", state: "running" }); expect(done.structuredContent).toMatchObject({ state: "completed", stepResults: {} }); expect(job.structuredContent).toMatchObject({ jobName: "queue-report", state: "completed", result: { stepResults: { billing: { state: "completed", outputs: { queue: "billing", size: 7 } }, login: { state: "completed", outputs: { queue: "login", size: 5 } }, }, }, }); }); ``` As with jobs, [nobody can read an anonymous caller's runs](https://frontmcp.dev/reference/sdk/job#who-can-read-a-run), so the Playground's Call tab can start a background run but not poll it. The last test signs in as `nour`. [Chaining Jobs into Workflows](https://frontmcp.dev/learn/chaining-jobs-into-workflows) covers when to run a workflow in the background, and the [Nightly Ticket Report](https://frontmcp.dev/examples/nightly-ticket-report) example starts one from a tool and keeps its report where another tool can read it. ### Seeing each step's attempts A step's result doesn't say why it failed, but a [`JobHook`](https://frontmcp.dev/reference/sdk/job#jobhook) sees every attempt of every step, and the job's error when one fails. For a step, `ctx.state.workflow` is `{ name, stepId, runId }`: the workflow, the step's `id` and the workflow run's id. `ctx.state.runId` is `undefined`, since the workflow run keeps the record: Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "close-day" } } ``` ```ts step-log.plugin.ts active import { DynamicPlugin, FlowCtxOf, JobHook, Plugin } from "@frontmcp/sdk"; export const stepLog: string[] = []; @Plugin({ name: "step-log" }) export class StepLogPlugin extends DynamicPlugin { @JobHook.Did("finalize", { filter: (ctx) => ctx.state.workflow !== undefined }) async record(ctx: FlowCtxOf<"jobs:execute-job">) { const { workflow, attempt, flowError } = ctx.state; stepLog.push(`${workflow?.name}/${workflow?.stepId} attempt ${attempt}: ${flowError ? flowError.message : "completed"}`); } } ``` ```ts help-desk.app.ts import { App, Job, JobContext, Workflow, z } from "@frontmcp/sdk"; import { StepLogPlugin } from "./step-log.plugin"; @Job({ name: "close-stale", inputSchema: {}, outputSchema: { closed: z.number() } }) export class CloseStale extends JobContext { async execute() { return { closed: 4 }; } } @Job({ name: "escalate", inputSchema: {}, outputSchema: { paged: z.boolean() } }) export class Escalate extends JobContext { async execute(): Promise<{ paged: boolean }> { throw new Error("The on-call rota is empty"); } } @Workflow({ name: "close-day", steps: [ { id: "close", jobName: "close-stale" }, { id: "escalate", jobName: "escalate", retry: { maxAttempts: 2, backoffMs: 10 } }, ], }) export class CloseDay {} @App({ id: "help-desk", name: "Help Desk", jobs: [CloseStale, Escalate], workflows: [CloseDay], plugins: [StepLogPlugin] }) export class HelpDeskApp {} ``` ```ts step-log.test.ts import { test, expect } from "@frontmcp/testing"; import { stepLog } from "./step-log.plugin"; test("the hook sees each step's attempts, and why they failed", async ({ mcp }) => { stepLog.length = 0; const run = (await mcp.tools.call("execute_workflow", { name: "close-day" })).json(); expect(run.result.stepResults.escalate).toEqual({ outputs: {}, state: "failed" }); expect([...stepLog].sort()).toEqual([ "close-day/close attempt 1: completed", "close-day/escalate attempt 1: The on-call rota is empty", "close-day/escalate attempt 2: The on-call rota is empty", ]); }); ``` The two steps don't depend on each other, so they run together, and the test sorts the lines. The same hook sees `execute_job`'s runs too, without `workflow`; the filter leaves them out. (New in 1.9.4: before, steps ran outside any flow, and nothing but the server's log saw their errors.) --- ### Stopping work when a step times out A timeout aborts the job's `this.signal`, which is set only when a workflow step runs the job. Check it before doing more work. A job that returns after the timeout still ends as a failed attempt, and finalize hooks see the timeout error. ```ts timeout.app.ts active import { App, Job, JobContext, JobHook, Workflow, z } from "@frontmcp/sdk"; export const seen = { aborted: false, executedHook: false, error: "" }; @Job({ name: "slow-count", inputSchema: {}, outputSchema: { open: z.number() }, retry: { maxAttempts: 1 } }) class SlowCount extends JobContext { async execute() { await new Promise((resolve) => setTimeout(resolve, 100)); seen.aborted = this.signal?.aborted ?? false; return { open: 12 }; } @JobHook.Did("execute") afterExecute() { seen.executedHook = true; } @JobHook.Did("finalize") finalized(ctx: any) { seen.error = ctx.state.flowError?.constructor.name ?? ""; } } @Workflow({ name: "count-before-deadline", steps: [{ id: "count", jobName: "slow-count", timeout: 20 }] }) class CountBeforeDeadline {} @App({ id: "help-desk", name: "Help Desk", jobs: [SlowCount], workflows: [CountBeforeDeadline] }) export class HelpDeskApp {} ``` ```ts timeout.test.ts import { test, expect } from "@frontmcp/testing"; import { seen } from "./timeout.app"; test("a started attempt stays failed and sees an aborted signal", async ({ mcp }) => { const result = await mcp.tools.call("execute_workflow", { name: "count-before-deadline" }); expect(result.json().state).toBe("failed"); await new Promise((resolve) => setTimeout(resolve, 150)); expect(seen).toEqual({ aborted: true, executedHook: false, error: "WorkflowJobTimeoutError" }); }); ``` ## Troubleshooting ### A step failed, and the result doesn't say why A step's result is `{ outputs: {}, state: "failed" }` whatever went wrong: the job threw, its input didn't match, it timed out, its `jobName` doesn't exist, or its `input` function read a step that hadn't finished. The error only goes to the server's log; a [job hook](https://frontmcp.dev/reference/sdk/workflow#seeing-each-steps-attempts) also sees the errors of the step's job. To see it, run the step's job by itself with `execute_job` and the same input: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "escalate", input: { ids: ["T-7"] } } } ``` ```ts help-desk.app.ts import { App, Job, JobContext, PublicMcpError, Workflow, z } from "@frontmcp/sdk"; @Job({ name: "escalate", inputSchema: { ids: z.array(z.string()) }, outputSchema: { paged: z.string() } }) export class Escalate extends JobContext { async execute({ ids }: { ids: string[] }): Promise<{ paged: string }> { this.fail(new PublicMcpError("Nobody is on call tonight.", "NO_ON_CALL")); } } @Workflow({ name: "sla-check", steps: [{ id: "escalate", jobName: "escalate", input: { ids: ["T-7"] }, retry: { maxAttempts: 1 } }] }) export class SlaCheck {} @App({ id: "help-desk", name: "Help Desk", jobs: [Escalate], workflows: [SlaCheck] }) export class HelpDeskApp {} ``` ```ts why.test.ts import { test, expect } from "@frontmcp/testing"; test("the workflow only says the step failed", async ({ mcp }) => { const { result } = (await mcp.tools.call("execute_workflow", { name: "sla-check" })).json(); expect(result.stepResults.escalate).toEqual({ state: "failed", outputs: {} }); }); test("the job alone says why", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "escalate", input: { ids: ["T-7"] } }); expect(result).toBeError("NO_ON_CALL"); expect(result.text()).toBe("Nobody is on call tonight."); }); ``` If the step's input comes from a function, return the values it would build from an earlier step, and pass those. ### A failing step takes three seconds A step whose job has no `retry` is tried three times, waiting 1 and then 2 seconds, so it fails after 3 seconds. Set `retry` on the step, or on the job, to change that: Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "notify-once" } } ``` ```ts help-desk.app.ts import { App, Job, JobContext, Workflow, z } from "@frontmcp/sdk"; @Job({ name: "notify-team", inputSchema: {}, outputSchema: { sent: z.boolean() } }) export class NotifyTeam extends JobContext { async execute(): Promise<{ sent: boolean }> { throw new Error("Mail server busy"); } } @Workflow({ name: "notify", steps: [{ id: "notify", jobName: "notify-team" }] }) export class Notify {} @Workflow({ name: "notify-once", steps: [{ id: "notify", jobName: "notify-team", retry: { maxAttempts: 1 } }] }) export class NotifyOnce {} @App({ id: "help-desk", name: "Help Desk", jobs: [NotifyTeam], workflows: [Notify, NotifyOnce] }) export class HelpDeskApp {} ``` ```ts retry.test.ts import { test, expect } from "@frontmcp/testing"; test("by default, the step fails after 3 seconds", async ({ mcp }) => { const started = Date.now(); const { state } = (await mcp.tools.call("execute_workflow", { name: "notify" })).json(); expect(state).toBe("failed"); expect(Date.now() - started).toBeGreaterThanOrEqual(3000); }); test("with maxAttempts: 1, it fails at once", async ({ mcp }) => { const started = Date.now(); const { state } = (await mcp.tools.call("execute_workflow", { name: "notify-once" })).json(); expect(state).toBe("failed"); expect(Date.now() - started).toBeLessThan(1000); }); ``` ### A step ran even though a step before it failed When a step fails, FrontMCP 1.8 skips the steps that depend on it, and then treats those as finished, so the steps after them run. Here `archive` fails, `report` is skipped, and `notify`, which depends on `report`, runs, with `{}` as the report. A `condition` that checks the step before it stops that: Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "unguarded" } } ``` ```ts help-desk.app.ts import { App, Job, JobContext, Workflow, z } from "@frontmcp/sdk"; @Job({ name: "archive-tickets", inputSchema: {}, outputSchema: { archived: z.number() } }) export class ArchiveTickets extends JobContext { async execute(): Promise<{ archived: number }> { throw new Error("Archive is full"); } } @Job({ name: "write-report", inputSchema: {}, outputSchema: { text: z.string() } }) export class WriteReport extends JobContext { async execute() { return { text: "12 tickets archived" }; } } @Job({ name: "email-report", inputSchema: { text: z.string().optional() }, outputSchema: { sent: z.string() } }) export class EmailReport extends JobContext { async execute({ text }: { text?: string }) { return { sent: text ?? "(no report)" }; } } const steps = [ { id: "archive", jobName: "archive-tickets", retry: { maxAttempts: 1 } }, { id: "report", jobName: "write-report", dependsOn: ["archive"] }, ]; @Workflow({ name: "unguarded", steps: [ ...steps, { id: "notify", jobName: "email-report", dependsOn: ["report"], input: (s) => s.get("report").outputs }, ], }) export class Unguarded {} @Workflow({ name: "guarded", steps: [ ...steps, { id: "notify", jobName: "email-report", dependsOn: ["report"], condition: (s) => s.get("report").state === "completed", // ✅ only after a real report input: (s) => s.get("report").outputs, }, ], }) export class Guarded {} @App({ id: "help-desk", name: "Help Desk", jobs: [ArchiveTickets, WriteReport, EmailReport], workflows: [Unguarded, Guarded] }) export class HelpDeskApp {} ``` ```ts guard.test.ts import { test, expect } from "@frontmcp/testing"; test("without a condition, notify runs after the failure", async ({ mcp }) => { const { result } = (await mcp.tools.call("execute_workflow", { name: "unguarded" })).json(); expect(result.stepResults.report.state).toBe("skipped"); expect(result.stepResults.notify).toEqual({ state: "completed", outputs: { sent: "(no report)" } }); }); test("with the condition, notify is skipped too", async ({ mcp }) => { const { result } = (await mcp.tools.call("execute_workflow", { name: "guarded" })).json(); expect(result.stepResults.notify).toEqual({ state: "skipped", outputs: {} }); }); ``` ### `Step "…" depends on unknown step "…"` or `Workflow has a cycle involving step "…"` A step's `dependsOn` names a step id that isn't in `steps`, or steps depend on each other in a loop. FrontMCP checks this when the workflow runs, not when the server starts, so the server starts fine and every `execute_workflow` call fails: Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "close-day" } } ``` ```ts help-desk.app.ts import { App, Job, JobContext, Workflow, z } from "@frontmcp/sdk"; @Job({ name: "archive-tickets", inputSchema: {}, outputSchema: { archived: z.number() } }) export class ArchiveTickets extends JobContext { async execute() { return { archived: 12 }; } } @Workflow({ name: "close-day", steps: [ { id: "archive", jobName: "archive-tickets", dependsOn: ["notify"] }, // 🚩 there's no "notify" step ], }) export class CloseDay {} @App({ id: "help-desk", name: "Help Desk", jobs: [ArchiveTickets], workflows: [CloseDay] }) export class HelpDeskApp {} ``` ```ts dag.test.ts import { test, expect } from "@frontmcp/testing"; test("the call fails with the step's name", async ({ mcp }) => { const result = await mcp.tools.call("execute_workflow", { name: "close-day" }); expect(result).toBeError("TOOL_EXECUTION_ERROR"); expect(result.text()).toMatch(/^Tool "execute_workflow" execution failed: Step "archive" depends on unknown step "notify"\n/); }); ``` Fix the id, or break the loop. A test that runs each workflow once catches this before your users do. ### `Workflow "…" timed out after …ms` The workflow ran longer than its `timeout`. FrontMCP checks it each time a batch of steps finishes, so the step that was running finished first, and the call fails with `TOOL_EXECUTION_ERROR` instead of returning the steps' results: Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "queue-report" } } ``` ```ts help-desk.app.ts import { App, Job, JobContext, Workflow, z } from "@frontmcp/sdk"; @Job({ name: "load-queue", inputSchema: { queue: z.string() }, outputSchema: { queue: z.string() } }) export class LoadQueue extends JobContext { async execute({ queue }: { queue: string }) { await new Promise((resolve) => setTimeout(resolve, 60)); return { queue }; } } @Workflow({ name: "queue-report", timeout: 50, steps: [ { id: "billing", jobName: "load-queue", input: { queue: "billing" } }, { id: "login", jobName: "load-queue", dependsOn: ["billing"], input: { queue: "login" } }, ], }) export class QueueReport {} @App({ id: "help-desk", name: "Help Desk", jobs: [LoadQueue], workflows: [QueueReport] }) export class HelpDeskApp {} ``` ```ts timeout.test.ts import { test, expect } from "@frontmcp/testing"; test("the call fails after the first step", async ({ mcp }) => { const result = await mcp.tools.call("execute_workflow", { name: "queue-report" }); expect(result).toBeError("TOOL_EXECUTION_ERROR"); expect(result.text()).toMatch(/^Tool "execute_workflow" execution failed: Workflow "queue-report" timed out after 50ms\n/); }); ``` Raise `timeout`, or run the workflow in the background. To limit one step, set the step's `timeout`: a step that runs too long fails, and the workflow carries on as for any failed step. ### `Job or workflow "…" not found or not permitted` `execute_workflow` found no workflow by that name that the caller may run. Check that the name matches `@Workflow({ name })` (or its `id`, if it has one), that the class is in an app's `workflows`, and that its `permissions` let the caller run it. A step whose `jobName` is wrong doesn't cause this error: that step fails. ### `get_workflow_status` returns an empty `stepResults` In FrontMCP 1.8, `get_workflow_status` never fills in `stepResults`. Call `get_job_status` with the same `runId`: its `result` is the workflow's result, steps included. [Running a workflow in the background](https://frontmcp.dev/reference/sdk/workflow#running-a-workflow-in-the-background) shows both. ### A step's `input` function can't see the workflow's input An `input` function only receives `steps`. The workflow's input goes to steps without an `input`. Let the first step take it and return what later steps need, and read that from its `outputs`, as in [Passing input to a workflow](https://frontmcp.dev/reference/sdk/workflow#passing-input-to-a-workflow). --- # @Agent > Declare an agent, a tool that runs its own model loop with its own instructions and tools, so a client's model can hand it a whole task in one call. Source: https://frontmcp.dev/reference/sdk/agent [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `@Agent` declares an [agent](https://frontmcp.dev/learn/your-first-agent): a tool that runs a model of its own, with instructions and tools you give it, so a client's model can hand it a whole task in one call. Clients see one tool, `invoke_`. When it's called, FrontMCP runs the loop: it asks the agent's model, runs the tools the model asks for, sends back their results, and answers with the model's final reply. [Delegating to Agents](https://frontmcp.dev/learn/delegating-to-agents) explains when an agent is worth writing. ```ts @Agent(options) class MyAgent extends AgentContext {} ``` --- ## Reference ### `@Agent(options)` Apply `@Agent` to a class that extends `AgentContext`, and list the class in an app's `agents` array. You don't write `execute()`: `AgentContext` has one that runs the model loop. ```ts triage.agent.ts import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { GetTicket, SetPriority } from "./ticket.tools"; @Agent({ name: "triage", description: "Read a new support ticket and set its priority. Pass the ticket's id.", systemInstructions: "You triage tickets for a help desk. Read the ticket with get_ticket, then set its priority " + "with set_priority: high if the customer can't work, normal otherwise. Say what you did in one sentence.", inputSchema: { ticketId: z.string().describe("The ticket's id, like T-1") }, tools: [GetTicket, SetPriority], llm: { provider: "openai", model: "gpt-5", apiKey: { env: "OPENAI_API_KEY" } }, execution: { maxIterations: 5 }, }) export class Triage extends AgentContext {} ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) export class HelpDeskApp {} ``` [See more examples below.](https://frontmcp.dev/reference/sdk/agent#usage) #### Options Required: | Option | Type | Description | | --- | --- | --- | | `name` | `string` | Clients call the agent as `invoke_`, and the tool's `annotations.title` is `name`. Characters other than letters, digits, `_` and `-` become `_`, with a warning in the server's log: `"Ticket Triage"` is `invoke_Ticket_Triage`. | | `llm` | `AgentLlmConfig` | The model the agent runs: an adapter you write, OpenAI or Anthropic, or a provider token. See [`llm`](https://frontmcp.dev/reference/sdk/agent#llm). | Optional: | Option | Type | Default | Description | | --- | --- | --- | --- | | `description` | `string` | `"Invoke the agent."`, followed by the first 100 characters of `systemInstructions` and `...` | What the client's model reads in `tools/list` to decide when to call the agent. Always set it. | | `systemInstructions` | `string` | `""` | Sent as `system` with every prompt: the instructions for the agent's own model. | | `inputSchema` | object of Zod types | `{}` | The input schema of `invoke_`, checked like a tool's. Fields it doesn't declare are dropped, so an agent without `inputSchema` sends its model `{}` whatever the client passed. | | `outputSchema` | object of Zod types | none | Advertised on `invoke_`. The result is checked against it: fields it doesn't declare are dropped, and a result that doesn't match fails the call with `INVALID_OUTPUT`. The model has to answer with JSON. See [Returning structured output](https://frontmcp.dev/reference/sdk/agent#returning-structured-output). | | `id` | `string` | `name` | Replaces `name` in the tool's name: `invoke_`. | | `tools` | tool classes | `[]` | The agent's tools. Only its model sees and calls them: they aren't in `tools/list`, and clients can't call them. The model calls them on the `"agent"` surface, so a tool whose `availableWhen.surface` leaves out `"agent"` isn't offered to it. An agent class in the list is refused when the agent is declared: `tools items must be annotated with @Tool()`. See [Giving an agent tools](https://frontmcp.dev/reference/sdk/agent#giving-an-agent-tools). | | `execution` | `AgentExecutionConfig` | see [`execution`](https://frontmcp.dev/reference/sdk/agent#execution) | Limits and switches for the model loop. | | `hideFromDiscovery` | `boolean` | `false` | Leaves `invoke_` out of `tools/list`. It can still be called by name. | | `tags` | `string[]` | `[]` | Labels. They aren't sent to clients in `tools/list`. | | `providers` | provider classes | `[]` | Providers for the agent's tools. The agent's own `execute()` gets them only through `exports`. See [Sharing a provider with the agent's tools](https://frontmcp.dev/reference/sdk/agent#sharing-a-provider-with-the-agents-tools). (Changed in 1.9: before, each one also had to be registered on the app or the server, or the server didn't start.) | | `agents` | agent classes | `[]` | Agents this one may call. Its model is offered each as `invoke_`, and `this.invokeAgent()` calls them. They aren't registered for clients. See [Calling other agents](https://frontmcp.dev/reference/sdk/agent#calling-other-agents). (New in 1.9: before, nested agents were ignored.) | | `swarm` | `AgentSwarmConfig` | see [`swarm`](https://frontmcp.dev/reference/sdk/agent#swarm) | Lets the agent's model call the app's other agents, and limits how deep agents call each other. | | `resources`, `prompts` | resource and prompt classes | `[]` | Scoped to the agent. Its model reads them, exported or not, with built-in tools: `list_resources` and `read_resource`, `list_prompts` and `get_prompt`. Clients see only those in `exports`. See [Reading the agent's resources and prompts](https://frontmcp.dev/reference/sdk/agent#reading-the-agents-resources-and-prompts). (Changed in 1.9.4: before, the model was only sent tools, so nothing read them unless they were exported, and the server logged a warning naming them.) | | `exports` | `{ resources?, prompts?, providers? }` | none | Serves the agent's resources and prompts to clients, in `resources/list` and `prompts/list`, and shares its providers with the app. An export that isn't one of the agent's own, or another key, like `tools`, stops the server from starting. (Changed in 1.9: before, exports were ignored.) | | `plugins`, `adapters` | plugin and adapter classes | `[]` | Plugins and adapters scoped to the agent. Its plugins run for its own tools; the app's run for them only with [`execution.inheritPlugins`](https://frontmcp.dev/reference/sdk/agent#execution). See [Giving the agent's tools a plugin](https://frontmcp.dev/reference/sdk/agent#giving-the-agents-tools-a-plugin). | | `authorities` | a profile name or a rule | none | Who may call the agent, as on a tool. Callers the rule refuses don't see `invoke_` in `tools/list`, and get `AUTHORITY_DENIED` if they call it. Needs [`@FrontMcp({ authorities })`](https://frontmcp.dev/reference/auth/authorities), or the server doesn't start. See [Limiting who calls an agent, and how often](https://frontmcp.dev/reference/sdk/agent#limiting-who-calls-an-agent-and-how-often). | | `rateLimit`, `concurrency`, `timeout`, `availableWhen` | as on [`@Tool`](https://frontmcp.dev/reference/sdk/tool#options) | none | Limit `invoke_` as they limit a tool. A refused call never reaches the model. See [Limiting who calls an agent, and how often](https://frontmcp.dev/reference/sdk/agent#limiting-who-calls-an-agent-and-how-often). | Options that plugins add to `@Tool`, like `approval` or `featureFlag`, apply to `invoke_` too, and need their plugin on the agent's app: without it, the server doesn't start (`Unenforced metadata: Agent "…" declares 'approval'`). #### `llm` `llm` takes one of three forms: | Form | Example | Notes | | --- | --- | --- | | An adapter | `{ adapter: model }` | Any object with a `completion()` method: see [The model adapter](https://frontmcp.dev/reference/sdk/agent#the-model-adapter). `adapter` can also be a function that returns one, `(providers) => adapter`; `providers.get(token)` reads a provider. | | A built-in provider | `{ provider: "openai", model: "gpt-5", apiKey: { env: "OPENAI_API_KEY" } }` | FrontMCP creates an [`OpenAIAdapter` or `AnthropicAdapter`](https://frontmcp.dev/reference/sdk/agent#built-in-adapters). The fields are below. | | A token | `LLM_ADAPTER` | A symbol registered as a provider that returns an adapter, like `{ provide: LLM_ADAPTER, name: "model", inject: () => [], useFactory: () => adapter }`. A class as the token fails `@Agent`'s validation. | The built-in provider's fields: | Field | Type | Default | Description | | --- | --- | --- | --- | | `provider` | `"openai" \| "anthropic"` | required | Which adapter to create. Install its package too: `openai` or `@anthropic-ai/sdk`. | | `model` | `string` | required | The provider's model id. | | `apiKey` | `string \| { env: string } \| WithConfig` | required | The key, or `{ env: "OPENAI_API_KEY" }` to read it from the environment. The variable is read when the server starts, and the server doesn't start without it: `Environment variable OPENAI_API_KEY is not set`. | | `baseUrl` | `string` | the provider's | Another endpoint that speaks the same API. | | `temperature` | `number` | the provider's | Sent with every request. | | `maxTokens` | `number` | the provider's; `4096` for Anthropic | The longest reply, in tokens. | A real model can't run in the Playground: it has no network and no API key. [Connecting a Real Model](https://frontmcp.dev/learn/connecting-a-real-model) sets one up, and [Using a real model](https://frontmcp.dev/reference/sdk/agent#using-a-real-model) below shows what an adapter sends. #### `execution` | Field | Type | Default | Description | | --- | --- | --- | --- | | `maxIterations` | `number` | `10` | How many times the model may be asked. If it still asks for tools the last time, the tools run, and the call fails with `Agent reached maximum iterations`. See [Troubleshooting](https://frontmcp.dev/reference/sdk/agent#agent-reached-maximum-iterations--without-completing). | | `timeout` | `number` | `120000` (2 minutes) | How long the model loop may take, in milliseconds. Then the call fails with `Agent execution timed out`, but the loop keeps running in the background. See [Troubleshooting](https://frontmcp.dev/reference/sdk/agent#agent-execution-timed-out-after-ms). | | `useToolFlow` | `boolean` | `true` | Runs the agent's tools through FrontMCP's full call flow, which validates the model's arguments against each tool's `inputSchema`. With `false`, a tool gets the model's arguments unchecked. A tool's `authorities` are checked either way. | | `enableNotifications` | `boolean` | `true` | Sends the client a log message, at level `info`, for each tool call the model asks for: `Calling tool: `; and one at level `error` for each that fails: `Tool failed: ` and the error's public message, the one a client would get for it in production. `false` turns these off, and `enableAutoProgress` with them. The agent's own `this.notify()` and `this.progress()` still send. See [Telling the client how the loop is going](https://frontmcp.dev/reference/sdk/agent#telling-the-client-how-the-loop-is-going). | | `enableAutoProgress` | `boolean` | `false` | Sends progress, out of 100, as the loop goes: `Starting LLM call (iteration 1/10)` before each request to the model, `LLM response received` after it, `Executing tool 1/2: get_ticket` before each tool call, and `Agent completed` at the end, or `Agent failed: ` and the error's public message when the model still asks for tools after `maxIterations`. When the adapter throws, the call fails with no last update. Each value is higher than the one before. It also sends a log message for each reply that asks for tools: `Identified 2 tool call(s): get_ticket, get_customer`. A run that [streams its reply](https://frontmcp.dev/reference/sdk/agent#streaming-the-reply) sends that log message, but none of the progress updates. (Changed in 1.9.3: before, the value could go down, and `Agent failed: ` carried the error's internal message.) | | `enableStreaming` | `boolean` | `false` | Sends the model's text to the client as the model writes it, each piece as a progress update on the call's progress token. Only for a call that has a progress token, with a model that can stream. See [Streaming the reply](https://frontmcp.dev/reference/sdk/agent#streaming-the-reply). (Changed in 1.9.4: before, it had no effect, and `true` logged a warning at startup.) | | `inheritParentTools` | `boolean` | `false` | Also offers the model the tools of the app the agent is in, other agents excepted. They run through the app's call flow, as a client's call would. (Changed in 1.9: before, it had no effect, and its default was documented as `true`.) | | `notificationInterval` | `number` | `1000` | The shortest time between two of `enableAutoProgress`'s progress updates, in milliseconds. An update that comes sooner is dropped, except the last one. (Changed in 1.9.2: before, it was never read.) | | `inheritPlugins` | `boolean` | `false` | Also runs the app's and the server's plugins for the agent's tools. A plugin installed in both places runs once. See [Giving the agent's tools a plugin](https://frontmcp.dev/reference/sdk/agent#giving-the-agents-tools-a-plugin). (New in 1.9: before, it was never read.) | #### `swarm` | Field | Type | Default | Description | | --- | --- | --- | --- | | `canSeeOtherAgents` | `boolean` | `false` | Offers the agent's model the app's other agents, each as `invoke_`, besides its own `agents`. `this.invokeAgent()` can call them too. | | `visibleAgents` | `string[]` | all agents | Limits which ones, by name. | | `isVisible` | `boolean` | `true` | `false` hides this agent from the other agents' models and from their `invokeAgent()`. Clients still see it. | | `maxCallDepth` | `number` | `3` | How many agent-to-agent calls a chain may make. The next one fails with `AGENT_CALL_DEPTH_EXCEEDED`, however it's made: by a model, by `invokeAgent()`, or by `this.callTool("invoke_")`. The default applies without `swarm` too. | Since 1.9, these settings take effect. Before, FrontMCP accepted `swarm` and ignored it. [Calling other agents](https://frontmcp.dev/reference/sdk/agent#calling-other-agents) shows them, and [Agents That Call Agents](https://frontmcp.dev/learn/agents-that-call-agents) teaches when to use which. #### How a call runs When a client calls `invoke_`: 1. FrontMCP checks the caller against the agent's `authorities`, `availableWhen` and limits, then the arguments against `inputSchema`, as for any tool, and drops fields it doesn't declare. Inside that `tools:call-tool` flow, the agent runs in the `agents:call-agent` flow, so [`AgentCallHook`](https://frontmcp.dev/reference/sdk/hooks#decorator-sets-and-their-flows) hooks run for it too, on a plugin or on the agent class (since 1.8.5). 2. It builds the **user message** from the input: the `query`, `message`, `prompt` or `input` field, if the input has one that isn't empty, as it is; otherwise the whole input as JSON, like `{"ticketId":"T-1"}`. 3. It calls the model: `completion({ system, messages }, tools)`, where `system` is `systemInstructions` and `messages` starts with the user message. A run that [streams its reply](https://frontmcp.dev/reference/sdk/agent#streaming-the-reply) calls `streamCompletion()` instead, with the same arguments. 4. If the model answers with `finishReason: "tool_calls"` and at least one tool call, FrontMCP runs the calls **one after another**, in order, adds the model's message and one `tool` message per call to `messages`, and goes back to step 3. A tool that fails, or that the agent doesn't have, doesn't fail the call: its message is `{"error":"…"}`, for the model to read. 5. Any other answer ends the loop. Its `content` becomes the result: text that starts with `{` or `[` and parses as JSON is the result as it is (an array arrives as `{ "value": [...] }`, like a tool's); anything else becomes `{ "response": content }`, and `null` becomes `{ "response": "" }`. The result's `_meta["agent/execution"]` is `{ agentId, agentName, durationMs }`. 6. With an `outputSchema`, the result is checked against it. #### The model adapter An adapter is any object with this method: ```ts interface AgentLlmAdapter { completion(prompt: AgentPrompt, tools?: AgentToolDefinition[], options?: AgentCompletionOptions): Promise; streamCompletion?(prompt: AgentPrompt, tools?: AgentToolDefinition[], options?: AgentCompletionOptions): AsyncGenerator; } ``` FrontMCP calls `completion()` once per turn of the loop, or `streamCompletion()` in a run that [streams its reply](https://frontmcp.dev/reference/sdk/agent#streaming-the-reply), with: - `prompt.system`: the agent's `systemInstructions`, or `""`. - `prompt.messages`: the conversation so far, as `AgentMessage`s. FrontMCP keeps adding to this same array after `completion()` returns, so copy it, with `structuredClone(prompt)`, if you keep it. - `tools`: the tools the model is offered, as `{ name, description, parameters }`, where `parameters` is the tool's input as JSON Schema: the agent's tools, and the [built-in tools](https://frontmcp.dev/reference/sdk/agent#reading-the-agents-resources-and-prompts) that read its resources and prompts, when it has any. `undefined` when there are none. - `options`: what [`completionOptions()`](https://frontmcp.dev/reference/sdk/agent#agentcontext) returns: by default `temperature` and `maxTokens` from a built-in provider's `llm`, when it sets them, and `{}` otherwise. (Changed in 1.9.2: before, it was always `undefined`.) An `AgentMessage` has: | Field | Type | Description | | --- | --- | --- | | `role` | `"user" \| "assistant" \| "tool" \| "system"` | Who wrote it. | | `content` | `string \| null` | The text. A `tool` message's content is the tool's result as JSON (or the text, if the result is a string). | | `toolCalls` | `AgentToolCall[]` | On an `assistant` message: the calls the model asked for. | | `toolCallId` | `string` | On a `tool` message: the `id` of the call it answers. | | `name` | `string` | On a `tool` message: the tool's name. | `completion()` returns an `AgentCompletion`: | Field | Type | Description | | --- | --- | --- | | `content` | `string \| null` | The model's text. | | `finishReason` | `"stop" \| "tool_calls" \| "length" \| "content_filter"` | `"tool_calls"`, with at least one entry in `toolCalls`, runs the tools and asks again. Anything else ends the loop, with `content` as the answer. | | `toolCalls` | `{ id: string; name: string; arguments: Record }[]` | The tools to call, with their arguments as an object. Each `id` comes back as the `toolCallId` of that call's `tool` message. | | `usage` | `{ promptTokens, completionTokens, totalTokens? }` | Optional. It isn't passed on to the client. | | `raw` | `unknown` | Optional: the provider's own response. | `streamCompletion()` is optional. It yields the reply in pieces, as `AgentCompletionChunk`s: | Field | Type | Description | | --- | --- | --- | | `type` | `"content" \| "tool_call" \| "done"` | What the chunk carries. | | `content` | `string` | On a `content` chunk: the next piece of the model's text, which the client gets as it arrives. | | `toolCall` | `{ id, name?, arguments? }` | On a `tool_call` chunk: a call the model is starting. Chunks with the same `id` add to it. | | `completion` | `AgentCompletion` | On the last chunk, `done`: the whole reply. The loop takes `finishReason` and `toolCalls` from it, so the tools get their full arguments. Without a `done` chunk, the calls the `tool_call` chunks announced are run. | The reply's text is what the `content` chunks carried, or the `done` chunk's `content` when none did. `OpenAIAdapter` and `AnthropicAdapter` have a `streamCompletion()`. #### `AgentContext` The class your agent extends. It has most of what a tool's context has, the request included: in an agent's own `execute()`, `this.context` is the request's context, `this.clientInfo` and `this.platform` describe the client, and `this.fetch()` sends the tracing headers, as in a tool. (Changed in 1.8.5: before, `this.context` threw there, `this.clientInfo` was empty, and `this.fetch()` sent no tracing headers.) [Context classes](https://frontmcp.dev/reference/sdk/contexts) compares every member. These are the members that matter for agents: | Member | Description | | --- | --- | | `execute(input)` | An override can omit the parameter for an empty input schema. Runs the model loop and returns the result. Override it to add steps before or after the loop, and call `super.execute(input)` to run it. See [Adding steps before and after the loop](https://frontmcp.dev/reference/sdk/agent#adding-steps-before-and-after-the-loop). | | `buildUserMessage(input)` | `protected`. Builds the [user message](https://frontmcp.dev/reference/sdk/agent#how-a-call-runs). Override it to write the message yourself. | | `parseAgentResponse(content)` | `protected`. Turns the final `content` into the result. Override it to parse the answer your way. | | `this.input` | The validated input. | | `this.metadata` | The agent's options. | | `this.get(token)`, `this.tryGet(token)` | [Providers](https://frontmcp.dev/reference/sdk/provider), from the app and the server, and the agent's own `providers` that it exports. The agent's *tools* see the app's, the server's and all of the agent's own: see [Sharing a provider with the agent's tools](https://frontmcp.dev/reference/sdk/agent#sharing-a-provider-with-the-agents-tools). | | `this.callTool(name, args)` | Calls a tool as the same caller: one of the agent's own `tools`, the `invoke_` of an agent in its `agents`, or any other tool of the server, another agent's `invoke_` included. It returns the tool's result, and throws if that tool fails. The call is on the `"agent"` surface, like the model's: a tool whose `availableWhen.surface` leaves out `"agent"` answers `Tool "…" not found`. (Changed in 1.9.2: before, it only reached the server's tools.) | | `this.invokeAgent(name, input)` | `protected`. Calls an agent this one can reach, in its `agents` or through `swarm`, and returns its result. It throws `AgentNotFoundError` (`AGENT_NOT_FOUND`) for any other name, `AgentVisibilityError` (`AGENT_VISIBILITY_DENIED`) for an agent with `swarm.isVisible: false`, and `InvalidInputError` when `input` isn't an object. (New in 1.9: before, it always threw.) | | `this.fail(error)` | Ends the call with `error`. A `PublicMcpError`'s message and code reach the client as they are. | | `this.auth` | The caller, as in [tools](https://frontmcp.dev/learn/authorizing-calls): `user.sub`, `isAnonymous`, `roles`, `scopes`. | | `this.notify()`, `this.progress()` | Send the client a log message or a progress update, as in a tool. A log message's `logger` is the agent's `name`. See [Telling the client how the loop is going](https://frontmcp.dev/reference/sdk/agent#telling-the-client-how-the-loop-is-going). (Changed in 1.9.2: before, under MCP 2026-07-28, they returned `false` and sent nothing.) | | `this.elicit()` | Asks the user, as in a tool. Under MCP 2026-07-28, `execute()` runs again from the top with the answer, so ask before `super.execute(input)`. See [Asking the user from an agent](https://frontmcp.dev/reference/sdk/agent#asking-the-user-from-an-agent). | | `this.respond(value)` | Ends the call with `value` as the result, sent as a returned value would be, with `content` and `structuredContent`. Returning the value does the same. (Changed in 1.8.5: before, `value` replaced the whole result, with no `content`.) | | `executeTool(name, args)` | `protected`. The loop calls it for each tool call the model asks for, so an override sees them all, and can change the arguments or the result. (Changed in 1.9: before, the loop ran the tools directly.) | | `completion(prompt, tools, options)` | `protected`. The loop calls it for each request to the model, so an override sees them all, and can change the prompt or the reply. Pass all three on to `super.completion()`, which calls the adapter: without `tools`, the model isn't offered any. (Changed in 1.9.2: before, the loop called the adapter directly, and an override never ran.) | | `completionOptions()` | `protected`. Returns the `options` the loop passes to every `completion()`. Override it to send your adapter other options. (New in 1.9.2.) | | `streamCompletion(prompt, tools, options)` | `protected`. What a [streamed run](https://frontmcp.dev/reference/sdk/agent#streaming-the-reply) calls instead of `completion()`. It yields the adapter's chunks, or, for an adapter without `streamCompletion()`, calls `completion()` and yields one `done` chunk. Override it to stream from a model the adapter can't. (Changed in 1.9.4: before, nothing called it.) | | `streamsReplies()` | `protected`. Whether this run streams: `true` when `execution.enableStreaming` is set, the call has a progress token, and the model can stream. Override it to decide per run. (New in 1.9.4.) | | `streamAgentLoop(events)` | `protected`. Runs a streamed loop to its end, sending each piece of text as a progress update, and returns the loop's result. Override it to send the pieces some other way. (New in 1.9.4.) | #### Built-in adapters `OpenAIAdapter` and `AnthropicAdapter` translate the loop's prompts and tool calls to each provider's API. `llm: { provider }` creates one for you; create one yourself for the other options: ```ts import { OpenAIAdapter } from "@frontmcp/sdk"; llm: { adapter: new OpenAIAdapter({ model: "gpt-5", apiKey: process.env.OPENAI_API_KEY!, maxRetries: 1 }) }, ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `model` | `string` | required | The provider's model id. | | `apiKey` | `string` | required, unless you pass `client` | A non-empty key. `new OpenAIAdapter({ model, apiKey: "" })` throws `apiKey is required and must be a non-empty string`. | | `client` | the provider SDK's client | none | A client you created, used instead of `apiKey` and `baseUrl`. The adapter then doesn't load the provider's package. | | `baseUrl` | `string` | the provider's | Another endpoint that speaks the same API. | | `temperature` | `number` | the provider's | | | `maxTokens` | `number` | the provider's; `4096` for `AnthropicAdapter` | | | `timeout` | `number` | `60000` | How long one request may take, in milliseconds. | | `maxRetries` | `number` | `3` | How many times to retry a failed request, after waits of 1, 2 and 4 seconds. | | `api` | `"chat" \| "responses"` | `"chat"` | `OpenAIAdapter` only: the Chat Completions API or the Responses API. | Without `client`, the adapter loads `openai` or `@anthropic-ai/sdk` on the first request, and fails with `The "openai" package is not installed.` if it can't. #### Errors What a client that calls `invoke_` gets: | When | Code | Text | | --- | --- | --- | | The caller fails the agent's `authorities` | `AUTHORITY_DENIED` | `Access denied to Tool "help-desk:invoke_refunds": ` and the reason | | The agent's `availableWhen` doesn't match where the server runs | `ENTRY_UNAVAILABLE` | `Tool "invoke_read_logs" is not available in the current environment`, then what it requires and what the server has | | A call over the agent's `rateLimit` or `concurrency` | `RATE_LIMIT_EXCEEDED`, `CONCURRENCY_LIMIT` | As for a tool: see [Guard options](https://frontmcp.dev/reference/sdk/guard#what-a-caller-gets-back) | | The arguments don't match `inputSchema` | `INVALID_INPUT` | `Invalid tool input`, then each Zod issue | | The model still asks for tools after `maxIterations` turns | `TOOL_EXECUTION_ERROR` | `Tool "invoke_triage" execution failed: Agent reached maximum iterations (10) without completing` | | `execution.timeout` runs out | `TOOL_EXECUTION_ERROR` | `Tool "invoke_triage" execution failed: Agent execution timed out after 120000ms` | | The agent's `timeout.executeMs` runs out | `EXECUTION_TIMEOUT` | `Execution of "invoke_triage" timed out after 30000ms` | | The adapter throws: no network, a bad key, a rate limit | `TOOL_EXECUTION_ERROR` | `Tool "invoke_triage" execution failed: ` and the adapter's message | | A chain of agents calling agents goes past `swarm.maxCallDepth` | `AGENT_CALL_DEPTH_EXCEEDED` | `Agent "escalate" can't be called from "escalate" -> "escalate" -> "escalate" -> "escalate": that would be agent call 4, deeper than maxCallDepth 3` (see [Troubleshooting](https://frontmcp.dev/reference/sdk/agent#agent--cant-be-called-from--that-would-be-agent-call-n-deeper-than-maxcalldepth-m)) | | `execute()` lets an error from `this.invokeAgent()` through | `AGENT_NOT_FOUND`, `AGENT_VISIBILITY_DENIED` | `Agent not found: …`, `Agent "…" does not have visibility to agent "…"` | | The result doesn't match `outputSchema` | `INVALID_OUTPUT` | `Tool output validation failed (output does not match outputSchema at priority)`, naming the first field that doesn't match (see [Returning structured output](https://frontmcp.dev/reference/sdk/agent#returning-structured-output)) | | `execute()` calls `this.fail(new PublicMcpError(message, code))` | your `code` | your `message` | In development, a `TOOL_EXECUTION_ERROR`'s text goes on with the original error and its stack. In production, the client gets FrontMCP's [generic message](https://frontmcp.dev/reference/sdk/tool#the-client-sees-internal-frontmcp-error-instead-of-my-message) instead, as for any tool. Errors *inside* the loop go to the model, not the client: a tool that fails, a tool the agent doesn't have, arguments that don't match a tool's schema, or a resource or prompt the agent doesn't have each become that call's `{"error":"…"}` message, and the loop goes on. A `PublicMcpError`'s message reaches the model word for word, whether the tool throws it or passes it to `this.fail()` (see [Giving an Agent Tools](https://frontmcp.dev/learn/giving-an-agent-tools#when-a-tool-fails)). The client's log gets `Tool failed: ` and the error's public message for each, unless `execution.enableNotifications` is `false`: a `PublicMcpError`'s message, or else FrontMCP's generic message with an error id, which the server's log line for the failure has too. (Changed in 1.9.3: before, the log message had the error's own text, like `connect ECONNREFUSED 10.0.4.7:5432`.) `@frontmcp/sdk` also exports `AgentLoopExceededError`, `AgentTimeoutError`, `AgentToolNotFoundError`, `AgentLlmError` and `AgentExecutionError`. A client calling an agent never gets them: check the codes and texts above instead. `AgentCallDepthExceededError`, `AgentVisibilityError` and `AgentNotFoundError` do reach it, with the codes above. #### Caveats - An agent's own **`execute()` doesn't see the agent's `providers`** unless they're in `exports.providers`, and neither do the app's tools. The agent's tools see them, and the app's and the server's too. - **Nested `agents` aren't tools of the server.** A client can't call them, and neither can another tool's `this.callTool()`. The agent that lists them can, with `this.invokeAgent()` or `this.callTool("invoke_")`. - **The agent's model reads only the agent's own `resources` and `prompts`**, and those a nested agent exports to it, never the app's. Clients see them only when they're exported. (Before 1.9.4, the model got tools only, and they reached nobody unless they were exported.) - An agent's `authorities`, `rateLimit`, `concurrency`, `timeout` and `availableWhen` **apply to `invoke_`**, as a tool's do. (Before 1.8.3, they had no effect.) `authorities` on the agent's own tools are checked against the caller the agent runs for, and the model gets the refusal as that call's `{"error":"…"}`. See [Resources, prompts, skills and agents](https://frontmcp.dev/reference/auth/authorities#resources-prompts-skills-and-agents). - **The app's plugins don't run for the agent's tools** unless `execution.inheritPlugins` is `true`. A plugin option on one of them, like `approval`, [stops the server from starting](https://frontmcp.dev/reference/sdk/agent#unenforced-metadata-tool--declares-approval) when no plugin that runs for that tool enforces it. - An agent **sends the client a log message for each tool call** its model asks for, `Calling tool: `, unless `execution.enableNotifications` is `false`. - `execution.timeout` **fails the call but doesn't stop the loop**: the model and the tools keep running in the background, and their side effects happen anyway. - Every call to an agent is at least one model call, and each round of tool calls is another. With a real model, that's time and money per call: set `maxIterations` to what the task needs. --- ## Usage ### Calling an agent The Playground can't reach a real model, so each example here has a `model.example.ts` that plays one: an object with a `completion()` method that answers from a fixed rule, and keeps what it was sent so the tests can check it. A real server doesn't need it. Initial call configuration: ```js { tool: "invoke_triage", arguments: { query: "Nobody on our team can log in since this morning." } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority for a new support ticket. Pass what the customer wrote.", systemInstructions: "You triage tickets for a help desk. Answer high, normal or low, and say why in one sentence.", inputSchema: { query: z.string().describe("What the customer wrote") }, llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) export class HelpDeskApp {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. FrontMCP calls // completion() each time the agent needs the model; this one answers from a // fixed rule, and keeps what it was sent. A real server doesn't need this file. import type { AgentLlmAdapter, AgentPrompt, AgentToolDefinition } from "@frontmcp/sdk"; export const calls: { prompt: AgentPrompt; tools?: AgentToolDefinition[] }[] = []; export const model: AgentLlmAdapter = { async completion(prompt, tools) { calls.push({ prompt: structuredClone(prompt), tools }); const blocked = /can't|cannot|log in|down/i.test(prompt.messages[0].content ?? ""); return { content: blocked ? "High: the customer can't work." : "Normal: nothing is blocked.", finishReason: "stop", }; }, }; ``` ```ts triage.test.ts import { test, expect } from "@frontmcp/testing"; import { calls } from "./model.example"; test("clients see one tool, `invoke_triage`, with the agent's input", async ({ mcp }) => { const [tool] = await mcp.tools.list(); expect(tool).toMatchObject({ name: "invoke_triage", description: "Suggest a priority for a new support ticket. Pass what the customer wrote.", inputSchema: { properties: { query: { type: "string" } }, required: ["query"] }, annotations: { title: "triage", readOnlyHint: false, openWorldHint: true }, }); }); test("the result is the model's answer, as `response`", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { query: "Nobody can log in since this morning." }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ response: "High: the customer can't work." }); }); test("the model is sent the instructions and the query", async ({ mcp }) => { calls.length = 0; await mcp.tools.call("invoke_triage", { query: "Please change our invoice address." }); expect(calls).toHaveLength(1); expect(calls[0].prompt).toEqual({ system: "You triage tickets for a help desk. Answer high, normal or low, and say why in one sentence.", messages: [{ role: "user", content: "Please change our invoice address." }], }); expect(calls[0].tools).toBeUndefined(); }); ``` Open the **Tests** tab. The model was asked once, with the agent's instructions as `system` and the query as the only message. Because the input has a `query` field, the model gets it as it is; other inputs are sent as JSON, as in the next example. [Your First Agent](https://frontmcp.dev/learn/your-first-agent) walks through all of this. ### Giving an agent tools `tools` gives the agent's model tools to call. Here it reads the ticket, sets its priority, and says what it did, in three turns of the loop: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; import { GetTicket, SetPriority } from "./ticket.tools"; @Agent({ name: "triage", description: "Read a new support ticket and set its priority. Pass the ticket's id.", systemInstructions: "You triage tickets for a help desk. Read the ticket with get_ticket, then set its priority " + "with set_priority: high if the customer can't work, normal otherwise. Say what you did in one sentence.", inputSchema: { ticketId: z.string().describe("The ticket's id, like T-1") }, tools: [GetTicket, SetPriority], llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts ticket.tools.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { tickets } from "./tickets"; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets[id]; if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); return ticket; } } @Tool({ name: "set_priority", description: "Set a ticket's priority.", inputSchema: { id: z.string(), priority: z.enum(["high", "normal"]) }, }) export class SetPriority extends ToolContext { async execute({ id, priority }: { id: string; priority: "high" | "normal" }) { tickets[id].priority = priority; return { id, priority }; } } ``` ```ts tickets.ts export type Ticket = { id: string; customer: string; title: string; priority: "unset" | "high" | "normal" }; export const tickets: Record = { "T-1": { id: "T-1", customer: "Acme", title: "Cannot log in since this morning", priority: "unset" }, "T-2": { id: "T-2", customer: "Globex", title: "Please update our invoice address", priority: "unset" }, }; ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) export class HelpDeskApp {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. It reads the // ticket, sets a priority from a fixed rule, then says what it did, and keeps // what it was sent. A real server doesn't need this file. import type { AgentLlmAdapter, AgentPrompt, AgentToolDefinition } from "@frontmcp/sdk"; export const calls: { prompt: AgentPrompt; tools?: AgentToolDefinition[] }[] = []; export const model: AgentLlmAdapter = { async completion(prompt, tools) { calls.push({ prompt: structuredClone(prompt), tools }); const last = prompt.messages[prompt.messages.length - 1]; // First turn: read the ticket the input names. if (last.role === "user") { const { ticketId } = JSON.parse(last.content ?? "{}"); return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: ticketId } }] }; } const result = JSON.parse(last.content ?? "{}"); if (result.error) return { content: `I couldn't triage it: ${result.error}`, finishReason: "stop" }; // Second turn: set a priority from the title. if (last.name === "get_ticket") { const priority = /can't|cannot|down/i.test(result.title) ? "high" : "normal"; return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_2", name: "set_priority", arguments: { id: result.id, priority } }] }; } // Last turn: say what it did. return { content: `${result.id} is now ${result.priority} priority.`, finishReason: "stop" }; }, }; ``` ```ts triage.test.ts import { test, expect } from "@frontmcp/testing"; import { Agent, AgentContext, App, FrontMcpInstance, Tool, ToolContext, z } from "@frontmcp/sdk"; import { calls, model } from "./model.example"; import { GetTicket, SetPriority } from "./ticket.tools"; import { tickets } from "./tickets"; test("clients see the agent, not its tools", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["invoke_triage"]); const direct = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(direct).toBeError(); expect(direct.text()).toBe('Tool "get_ticket" not found'); }); test("the agent reads the ticket, sets its priority and answers", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json()).toEqual({ response: "T-1 is now high priority." }); expect(tickets["T-1"].priority).toBe("high"); }); test("each tool result goes back to the model", async ({ mcp }) => { calls.length = 0; await mcp.tools.call("invoke_triage", { ticketId: "T-2" }); expect(calls).toHaveLength(3); expect(calls[0].prompt.messages).toEqual([{ role: "user", content: '{"ticketId":"T-2"}' }]); expect(calls[0].tools?.map((t) => t.name)).toEqual(["get_ticket", "set_priority"]); expect(calls[1].prompt.messages.slice(1)).toEqual([ { role: "assistant", content: null, toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: "T-2" } }] }, { role: "tool", toolCallId: "call_1", name: "get_ticket", content: '{"id":"T-2","customer":"Globex","title":"Please update our invoice address","priority":"unset"}', }, ]); }); test("a tool's error goes to the model, not the client", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-404" }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ response: "I couldn't triage it: There's no ticket T-404." }); }); test("a tool whose `surface` leaves out `agent` isn't offered to the model", async () => { @Tool({ name: "close_ticket", description: "Close a ticket.", inputSchema: { id: z.string() }, availableWhen: { surface: ["mcp"] } }) class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, closed: true }; } } @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket, SetPriority, CloseTicket], llm: { adapter: model } }) class Triage extends AgentContext {} @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) class HelpDeskApp {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); calls.length = 0; await server.callTool("invoke_triage", { ticketId: "T-2" }); await server.dispose(); expect(calls[0].tools?.map((t) => t.name)).toEqual(["get_ticket", "set_priority"]); }); ``` The client's model sees only `invoke_triage`. The agent's model got the two tools, and after each call, the call's result as a `tool` message. When `get_ticket` failed, the model got `{"error":"There's no ticket T-404."}` and answered with it: the call itself succeeded. [Giving an Agent Tools](https://frontmcp.dev/learn/giving-an-agent-tools) goes further. The model calls the agent's tools on the `"agent"` surface, and [`getCallSurface()`](https://frontmcp.dev/reference/sdk/context#getcallsurface-and-getrunningtool) is `"agent"` inside them. So an [`availableWhen`](https://frontmcp.dev/reference/server/environment) whose `surface` leaves out `"agent"` keeps a tool from the model, as the last test shows. Changed in 1.8.4: before, nothing marked an agent's calls, and `surface: ["mcp"]` didn't keep a tool from agents. ### Reading the agent's resources and prompts An agent's `resources` and `prompts` are for its model. An agent that declares a resource or a resource template offers its model two more tools, `list_resources` and `read_resource`, and one that declares a prompt offers `list_prompts` and `get_prompt`. Here triage reads its priority policy and the ticket, then gets the reply it should start from: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { PriorityPolicy, Reply, Ticket } from "./knowledge"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority for a support ticket. Pass the ticket's id.", systemInstructions: "Read the ticket and the priority policy, then get the reply prompt for that priority.", inputSchema: { ticketId: z.string() }, resources: [PriorityPolicy, Ticket], prompts: [Reply], llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts knowledge.ts import { Prompt, PromptContext, Resource, ResourceContext, ResourceNotFoundError, ResourceTemplate, type GetPromptResult } from "@frontmcp/sdk"; @Resource({ name: "priority-policy", uri: "policy://priorities", mimeType: "text/markdown", description: "When a ticket is high priority." }) export class PriorityPolicy extends ResourceContext { async execute(uri: string) { return { contents: [{ uri, mimeType: "text/markdown", text: "High: the customer can't work. Normal: anything else." }] }; } } @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "A support ticket." }) export class Ticket extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { if (id !== "T-1") throw new ResourceNotFoundError(uri); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ id, title: "Cannot log in since this morning" }) }] }; } } @Prompt({ name: "reply", description: "The first reply to a customer.", arguments: [{ name: "priority", required: true }] }) export class Reply extends PromptContext { async execute({ priority }: Record): Promise { return { description: "The first reply to a customer", messages: [ { role: "user", content: { type: "text", text: `Write the first reply to a ${priority}-priority ticket.` } }, { role: "assistant", content: { type: "text", text: "Thanks for telling us. We're on it." } }, ], }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) export class HelpDeskApp {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. It lists the // resources, reads two and gets the reply prompt, then answers, and keeps what // it was sent. A real server doesn't need this file. import type { AgentLlmAdapter, AgentMessage } from "@frontmcp/sdk"; export const turns: { tools?: string[]; messages: AgentMessage[] }[] = []; export const model: AgentLlmAdapter = { async completion(prompt, tools) { turns.push({ tools: tools?.map((t) => t.name), messages: structuredClone(prompt.messages) }); if (prompt.messages.length > 1) return { content: "High: the customer can't work.", finishReason: "stop" }; const { ticketId } = JSON.parse(prompt.messages[0].content ?? "{}"); return { content: null, finishReason: "tool_calls", toolCalls: [ { id: "call_1", name: "list_resources", arguments: {} }, { id: "call_2", name: "read_resource", arguments: { uri: "policy://priorities" } }, { id: "call_3", name: "read_resource", arguments: { uri: `tickets://${ticketId}` } }, { id: "call_4", name: "get_prompt", arguments: { name: "reply", arguments: { priority: "high" } } }, ], }; }, }; ``` ```ts reading.test.ts import { test, expect } from "@frontmcp/testing"; import { turns } from "./model.example"; const toolResults = () => turns[1].messages.filter((m) => m.role === "tool").map((m) => m.content); test("the model is offered a tool for each kind of read", async ({ mcp }) => { turns.length = 0; await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(turns[0].tools).toEqual(["list_resources", "read_resource", "list_prompts", "get_prompt"]); }); test("a listing comes back as JSON, a resource as its text", async ({ mcp }) => { turns.length = 0; await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); const [listing, policy, ticket] = toolResults(); expect(JSON.parse(listing ?? "")).toEqual({ resources: [{ uri: "policy://priorities", name: "priority-policy", description: "When a ticket is high priority.", mimeType: "text/markdown" }], resourceTemplates: [{ uriTemplate: "tickets://{id}", name: "ticket", description: "A support ticket.", mimeType: "application/json" }], }); expect(policy).toBe("High: the customer can't work. Normal: anything else."); expect(ticket).toBe('{"id":"T-1","title":"Cannot log in since this morning"}'); }); test("a prompt comes back as its description, then each message under its role", async ({ mcp }) => { turns.length = 0; await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(toolResults()[3]).toBe( "The first reply to a customer\n\n[user]\nWrite the first reply to a high-priority ticket.\n\n[assistant]\nThanks for telling us. We're on it.", ); }); test("a ticket the template doesn't have is that call's error, for the model", async ({ mcp }) => { turns.length = 0; const result = await mcp.tools.call("invoke_triage", { ticketId: "T-404" }); expect(result).toBeSuccessful(); expect(toolResults()[2]).toBe('{"error":"Resource not found: tickets://T-404"}'); }); test("clients see none of them, since the agent doesn't export them", async ({ mcp }) => { expect(await mcp.resources.list()).toEqual([]); expect(await mcp.resources.listTemplates()).toEqual([]); expect(await mcp.prompts.list()).toEqual([]); }); ``` The model reads every resource and prompt the agent declares, exported or not: `exports` only decides what clients see. It gets text, never a protocol result: | Tool | Arguments | What the model gets | | --- | --- | --- | | `list_resources` | none | `{ resources: [{ uri, name, title?, description?, mimeType? }], resourceTemplates: [{ uriTemplate, name, title?, description?, mimeType? }] }`, as JSON | | `read_resource` | `{ uri }`: a listed URI, or a template's with its parts filled in | The resource's text. A resource with several contents has each under its URI and MIME type, and binary content is replaced by a line like `[binary content omitted: brand://logo (image/png, 6 bytes)]`. | | `list_prompts` | none | `{ prompts: [{ name, title?, description?, arguments? }] }`, as JSON | | `get_prompt` | `{ name, arguments? }` | The prompt's description, then each message under `[user]` or `[assistant]` | Each read runs through the agent's own `resources:read-resource`, `prompts:get-prompt` and list [flows](https://frontmcp.dev/reference/server/flows), as a client's would, so the agent's plugins' `ResourceHook` and `PromptHook` hooks run for it (with [`inheritPlugins`](https://frontmcp.dev/reference/sdk/agent#giving-the-agents-tools-a-plugin), the app's and the server's too), and a read that fails, like the unknown ticket in the fourth test, becomes the call's `{"error":"…"}` for the model. The app's resources and prompts aren't offered: an agent that declares none gets no extra tools. Resources and prompts a [nested agent](https://frontmcp.dev/reference/sdk/agent#calling-other-agents) exports are offered to the agent that lists it. None of the agent's own tools may take one of these four names, or [the server doesn't start](https://frontmcp.dev/reference/sdk/agent#agent--has-a-tool-named--the-name-of-a-built-in-tool-its-model-reads-the-agents-resources-and-prompts-with). New in 1.9.4: before, the model was only sent tools, so an agent's resources and prompts reached nothing unless they were exported, and the server logged a warning naming them. ### Returning structured output With `outputSchema`, `invoke_` promises a shape, and the client gets `structuredContent` it can rely on. The agent's model has to answer with JSON in that shape, so say so in `systemInstructions`. An answer that doesn't parse, or doesn't match, fails the call: Initial call configuration: ```js { tool: "invoke_triage", arguments: { query: "Nobody on our team can log in since this morning." } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority for a new support ticket. Pass what the customer wrote.", systemInstructions: 'You triage tickets for a help desk. Answer only with JSON: {"priority": "high" | "normal", "reason": ""}.', inputSchema: { query: z.string().describe("What the customer wrote") }, outputSchema: { priority: z.enum(["high", "normal"]), reason: z.string() }, llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. It answers // with JSON, except about refunds, where it forgets the format. A real server // doesn't need this file. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion(prompt) { const text = prompt.messages[0].content ?? ""; if (/refund/i.test(text)) return { content: "Refunds go to billing, so I'd say normal.", finishReason: "stop" }; const answer = /can't|cannot|down/i.test(text) ? { priority: "high", reason: "The customer can't work." } : { priority: "normal", reason: "Nothing is blocked." }; return { content: JSON.stringify(answer), finishReason: "stop" }; }, }; ``` ```ts triage.test.ts import { test, expect } from "@frontmcp/testing"; test("`invoke_triage` advertises the output schema", async ({ mcp }) => { const [tool] = await mcp.tools.list(); expect(tool.outputSchema).toMatchObject({ properties: { priority: { enum: ["high", "normal"] } }, required: ["priority", "reason"] }); }); test("a JSON answer is the result", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { query: "Our dashboard is down." }); expect(result.raw.structuredContent).toEqual({ priority: "high", reason: "The customer can't work." }); }); test("an answer that isn't JSON fails the call", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { query: "I want a refund." }); expect(result).toBeError("INVALID_OUTPUT"); expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at priority)"); }); ``` The plain-text answer became `{ "response": "Refunds go to billing, so I'd say normal." }`, which has no `priority`, and the error names that first field. Real models mostly follow a format they're asked for, but not always, so a client should be ready for the call to fail. To accept other answers, override [`parseAgentResponse()`](https://frontmcp.dev/reference/sdk/agent#agentcontext). Changed in 1.8.7: a mismatch is `INVALID_OUTPUT` with that one-line text, as it is for a tool. In 1.8.5 and 1.8.6 it was `TOOL_EXECUTION_ERROR`, with the original `InvalidOutputError` and its stack trace in the development text; 1.8.4 sent `INVALID_OUTPUT`. ### Sharing a provider with the agent's tools An agent's tools see the providers of the agent's app and of the server, as the app's own tools do. Put a provider they share with the app's tools in `@App({ providers })`: both get the same instance, so what the agent changes, the client reads: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { ListTickets } from "./ticket.tools"; import { TicketStore } from "./ticket-store"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", tools: [ListTickets], agents: [Triage], providers: [TicketStore], // for the app's tools and the agent's }) export class HelpDeskApp {} @FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class HelpDeskServer {} ``` ```ts triage.agent.ts import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; import { GetTicket, SetPriority } from "./ticket.tools"; @Agent({ name: "triage", description: "Read a new support ticket and set its priority. Pass the ticket's id.", systemInstructions: "Read the ticket with get_ticket, then set its priority with set_priority.", inputSchema: { ticketId: z.string() }, tools: [GetTicket, SetPriority], llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts ticket.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Tool({ name: "list_tickets", description: "List the support tickets and their priorities.", inputSchema: {} }) export class ListTickets extends ToolContext { async execute() { return { tickets: this.get(TicketStore).all() }; } } @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return this.get(TicketStore).get(id); } } @Tool({ name: "set_priority", description: "Set a ticket's priority.", inputSchema: { id: z.string(), priority: z.enum(["high", "normal"]) }, }) export class SetPriority extends ToolContext { async execute({ id, priority }: { id: string; priority: "high" | "normal" }) { return this.get(TicketStore).setPriority(id, priority); } } ``` ```ts ticket-store.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; type Ticket = { id: string; title: string; priority: "unset" | "high" | "normal" }; @Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL }) export class TicketStore { private tickets: Ticket[] = [ { id: "T-1", title: "Cannot log in since this morning", priority: "unset" }, { id: "T-2", title: "Please update our invoice address", priority: "unset" }, ]; all() { return this.tickets; } get(id: string) { return this.tickets.find((t) => t.id === id); } setPriority(id: string, priority: "high" | "normal") { const ticket = this.tickets.find((t) => t.id === id)!; ticket.priority = priority; return ticket; } } ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. It reads the // ticket, sets a priority from a fixed rule, then says what it did. A real // server doesn't need this file. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion(prompt) { const last = prompt.messages[prompt.messages.length - 1]; if (last.role === "user") { const { ticketId } = JSON.parse(last.content ?? "{}"); return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: ticketId } }] }; } const result = JSON.parse(last.content ?? "{}"); if (last.name === "get_ticket") { const priority = /can't|cannot|down/i.test(result.title) ? "high" : "normal"; return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_2", name: "set_priority", arguments: { id: result.id, priority } }] }; } return { content: `${result.id} is now ${result.priority} priority.`, finishReason: "stop" }; }, }; ``` ```ts shared.test.ts import { test, expect } from "@frontmcp/testing"; import { Agent, AgentContext, App, FrontMcpInstance, z } from "@frontmcp/sdk"; import { model } from "./model.example"; import { GetTicket, ListTickets, SetPriority } from "./ticket.tools"; import { TicketStore } from "./ticket-store"; test("the client reads what the agent's tools changed", async ({ mcp }) => { const priorities = async () => (await mcp.tools.call("list_tickets", {})).json().tickets.map((t: { priority: string }) => t.priority); expect(await priorities()).toEqual(["unset", "unset"]); await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(await priorities()).toEqual(["high", "unset"]); }); test("a provider on the server is shared too", async () => { @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket, SetPriority], llm: { adapter: model } }) class Triage extends AgentContext {} @App({ id: "help-desk", name: "Help Desk", tools: [ListTickets], agents: [Triage] }) class HelpDeskApp {} const server = await FrontMcpInstance.createDirect({ info: { name: "Help Desk", version: "1.0.0" }, apps: [HelpDeskApp], providers: [TicketStore] }); const priorityOfT2 = async () => ((await server.callTool("list_tickets", {})).structuredContent as { tickets: { priority: string }[] }).tickets[1].priority; try { expect(await priorityOfT2()).toBe("unset"); await server.callTool("invoke_triage", { ticketId: "T-2" }); expect(await priorityOfT2()).toBe("normal"); } finally { await server.dispose(); } }); test("a provider on the agent, and in its `exports`, is shared with the app too", async () => { @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket, SetPriority], providers: [TicketStore], exports: { providers: [TicketStore] }, llm: { adapter: model }, }) class Triage extends AgentContext {} @App({ id: "help-desk", name: "Help Desk", tools: [ListTickets], agents: [Triage] }) class HelpDeskApp {} const server = await FrontMcpInstance.createDirect({ info: { name: "Help Desk", version: "1.0.0" }, apps: [HelpDeskApp] }); try { await server.callTool("invoke_triage", { ticketId: "T-1" }); const { tickets } = (await server.callTool("list_tickets", {})).structuredContent as { tickets: { priority: string }[] }; expect(tickets.map((t) => t.priority)).toEqual(["high", "unset"]); } finally { await server.dispose(); } }); ``` A provider in `@FrontMcp({ providers })` works too, and is shared by every app. A provider only the agent's tools use can go in `@Agent({ providers })`. Add it to `exports: { providers }` as well, as the last test does, and the app's tools and the agent's own `execute()` get the same instance. (Changed in 1.9: before, a provider in `@Agent({ providers })` alone stopped the server from starting, and `exports` was ignored. Changed in 1.9.2: before, an agent's tools didn't see the app's providers.) The same tool class can also be in both lists, the app's `tools` and the agent's, when the client should be able to call it too. ### Running an agent without input With `inputSchema: {}`, an `execute()` override needs no parameter. Pass `{}` to `super.execute()` to run the model loop. Changed in 1.9.7: before, the decorator rejected this override with `Parameter type does not match the input schema`. An input schema with fields still requires a matching parameter. Initial call configuration: ```js { tool: "invoke_daily_summary", arguments: {} } ``` ```ts daily-summary.agent.ts active import { Agent, AgentContext } from "@frontmcp/sdk"; @Agent({ name: "daily_summary", description: "Summarize today's support tickets.", inputSchema: {}, llm: { adapter: { // Stands in for a model the Playground cannot reach. async completion() { return { content: "Three tickets remain open.", finishReason: "stop" as const }; }, }, }, }) export class DailySummary extends AgentContext { override async execute() { return super.execute({}); } } ``` ```ts daily-summary.test.ts import { test, expect } from "@frontmcp/testing"; test("an agent with an empty schema runs without an execute parameter", async ({ mcp }) => { const result = await mcp.tools.call("invoke_daily_summary", {}); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ response: "Three tickets remain open." }); }); ``` ### Adding steps before and after the loop Override `execute()` to check the input, prepare what the model gets, or add to its answer, and call `super.execute(input)` for the loop. `execute()` and `buildUserMessage()` see the app's providers. Here the agent refuses unknown tickets before asking the model, sends the model the ticket rather than its id, and adds the id to the answer: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, PublicMcpError, z } from "@frontmcp/sdk"; import { model } from "./model.example"; import { TicketStore } from "./ticket-store"; @Agent({ name: "triage", description: "Suggest a priority for a support ticket. Pass the ticket's id.", systemInstructions: "You triage tickets for a help desk. Answer high or normal, and say why in one sentence.", inputSchema: { ticketId: z.string() }, llm: { adapter: model }, }) export class Triage extends AgentContext { async execute(input: { ticketId: string }) { if (!this.get(TicketStore).get(input.ticketId)) { this.fail(new PublicMcpError(`There's no ticket ${input.ticketId}.`, "TICKET_NOT_FOUND")); } const answer = (await super.execute(input)) as { response: string }; return { ticketId: input.ticketId, ...answer }; } protected buildUserMessage(input: { ticketId: string }) { const ticket = this.get(TicketStore).get(input.ticketId)!; return `${ticket.customer} wrote: ${ticket.title}`; } } ``` ```ts ticket-store.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "TicketStore" }) export class TicketStore { private tickets = [ { id: "T-1", customer: "Acme", title: "Cannot log in since this morning" }, { id: "T-2", customer: "Globex", title: "Please update our invoice address" }, ]; get(id: string) { return this.tickets.find((t) => t.id === id); } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", providers: [TicketStore], agents: [Triage] }) export class HelpDeskApp {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. It answers from // a fixed rule and keeps what it was sent. A real server doesn't need this file. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const sent: (string | null)[] = []; export const model: AgentLlmAdapter = { async completion(prompt) { const text = prompt.messages[0].content; sent.push(text); return { content: /can't|cannot|down/i.test(text ?? "") ? "High: the customer can't work." : "Normal: nothing is blocked.", finishReason: "stop" }; }, }; ``` ```ts triage.test.ts import { test, expect } from "@frontmcp/testing"; import { sent } from "./model.example"; test("the model gets the ticket, and the answer gets the id", async ({ mcp }) => { sent.length = 0; const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(sent).toEqual(["Acme wrote: Cannot log in since this morning"]); expect(result.json()).toEqual({ ticketId: "T-1", response: "High: the customer can't work." }); }); test("an unknown ticket fails before the model is asked", async ({ mcp }) => { sent.length = 0; const result = await mcp.tools.call("invoke_triage", { ticketId: "T-404" }); expect(result).toBeError("TICKET_NOT_FOUND"); expect(result.text()).toBe("There's no ticket T-404."); expect(sent).toEqual([]); }); ``` Failing before the loop saves a model call. `execute()` can also call other tools of the server with `this.callTool()`, another agent's `invoke_` included: [Agents That Call Agents](https://frontmcp.dev/learn/agents-that-call-agents#handing-a-task-off-from-code) hands a task from one agent to another that way. ### Telling the client how the loop is going An agent's call lasts as long as its whole loop. While it runs, the client hears from the agent and from its tools: the agent's own `this.notify()` and `this.progress()`, a `Calling tool: ` log message for each tool call the model asks for, and whatever the tools send. `execution.enableAutoProgress` adds progress updates for each step of the loop: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts agents.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model, slowModel } from "./model.example"; import { GetTicket } from "./ticket.tools"; @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket], execution: { enableAutoProgress: true }, llm: { adapter: model }, }) export class Triage extends AgentContext { async execute(input: { ticketId: string }) { await this.notify(`Triaging ${input.ticketId}`); return super.execute(input); } } @Agent({ name: "quiet_triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket], execution: { enableNotifications: false }, llm: { adapter: model }, }) export class QuietTriage extends AgentContext {} @Agent({ name: "slow_triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket], execution: { enableAutoProgress: true, notificationInterval: 10 }, llm: { adapter: slowModel }, }) export class SlowTriage extends AgentContext {} ``` ```ts ticket.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { await this.notify(`Reading ${id}`); await new Promise((resolve) => setTimeout(resolve, 20)); // a database read return { id, title: "Cannot log in since this morning" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { QuietTriage, SlowTriage, Triage } from "./agents"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage, QuietTriage, SlowTriage] }) export class HelpDeskApp {} ``` ```ts model.example.ts // Stand-ins for a real model: they read the ticket, then answer. `slowModel` // reads two tickets, and takes 20 ms per request, as a real model takes seconds. // A real server doesn't need this file. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion(prompt) { if (prompt.messages.length === 1) { return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: "T-1" } }] }; } return { content: "High", finishReason: "stop" }; }, }; export const slowModel: AgentLlmAdapter = { async completion(prompt) { await new Promise((resolve) => setTimeout(resolve, 20)); if (prompt.messages.length === 1) { const read = (id: string) => ({ id: `call_${id}`, name: "get_ticket", arguments: { id } }); return { content: null, finishReason: "tool_calls", toolCalls: [read("T-1"), read("T-2")] }; } return { content: "High", finishReason: "stop" }; }, }; ``` ```ts progress.test.ts import { test, expect } from "@frontmcp/testing"; const logOf = (received: { method: string; params?: any }[]) => received.filter((n) => n.method === "notifications/message").map((n) => `${n.params.logger}: ${n.params.data.message}`); const progressOf = (received: { method: string; params?: any }[]) => received.filter((n) => n.method === "notifications/progress").map((n) => `${n.params.progress} ${n.params.message}`); test("the client hears from the agent, its loop and its tool", async ({ mcp }) => { const notifications = mcp.notifications.collect(); await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(logOf(notifications.received)).toEqual([ "triage: Triaging T-1", "triage: Identified 1 tool call(s): get_ticket", "triage: Calling tool: get_ticket", "get_ticket: Reading T-1", ]); }); test("a quick loop reports only its start and its end", async ({ mcp }) => { const notifications = mcp.notifications.collect(); await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(progressOf(notifications.received)).toEqual(["0 Starting LLM call (iteration 1/10)", "100 Agent completed"]); }); test("`enableNotifications: false` leaves the tool's own message", async ({ mcp }) => { const notifications = mcp.notifications.collect(); await mcp.tools.call("invoke_quiet_triage", { ticketId: "T-1" }); expect(logOf(notifications.received)).toEqual(["get_ticket: Reading T-1"]); }); test("a slower loop reports more, each value higher than the last", async ({ mcp }) => { const notifications = mcp.notifications.collect(); await mcp.tools.call("invoke_slow_triage", { ticketId: "T-1" }); expect(progressOf(notifications.received)).toEqual([ "0 Starting LLM call (iteration 1/10)", "4 LLM response received", "7 Executing tool 2/2: get_ticket", "8 Starting LLM call (iteration 2/10)", "12 LLM response received", "100 Agent completed", ]); }); ``` Each log message's `logger` is the name of whoever sent it: the agent, or the tool. With `enableAutoProgress`, progress goes from 0 to 100 over the loop, and an update that comes less than `notificationInterval` (1000 ms by default) after the last one is dropped, except the last: a quick loop reports only its start and its end, and `Executing tool 1/2`, which comes right after `LLM response received`, is dropped here too. Progress updates go only to a client that sent a progress token with its call, as for a tool. Each turn of the loop has an equal share of the range from 0 to 80: its model call gets the first half of it, its tool calls the second. So with `maxIterations: 10`, the second turn starts at 8, and every update is higher than the one before, as MCP requires. (Changed in 1.9.3: before, a turn's model call only counted the turns before it, so its value could be lower than the last tool call's: 14, then 8.) `enableNotifications: false` stops the `Calling tool:` and `Tool … failed:` messages, and `enableAutoProgress`'s updates with them. A `Tool … failed:` message carries the error's public message, as a client would get it in production: a `PublicMcpError`'s own, otherwise `Internal FrontMCP error. Please contact support with error ID: …`, and the server log has the error under that id (see [Troubleshooting](https://frontmcp.dev/reference/sdk/agent#an-agents-tool-fails-with-provider--is-not-available)). A called agent and its tools report to the client of the outer call, as [called tools do](https://frontmcp.dev/reference/sdk/call-tool#progress-from-the-called-tool): the [Research Assistant](https://frontmcp.dev/examples/research-assistant) shows it. Changed in 1.9.2: before, under MCP 2026-07-28, an agent's own messages and progress never reached the client, only its tools'. ### Streaming the reply A model writes its reply a piece at a time. With `execution.enableStreaming`, the agent sends each piece to the client as it arrives, as a progress update on the call's progress token, so a client can show the reply before the loop ends. The model has to stream too: the adapter needs a `streamCompletion()`, as `OpenAIAdapter` and `AnthropicAdapter` have: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; import { GetTicket } from "./ticket.tools"; @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket], execution: { enableStreaming: true, enableAutoProgress: true }, llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts ticket.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { await this.notify(`Reading ${id}`); return { id, title: "Cannot log in since this morning" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) export class HelpDeskApp {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. It says what // it's doing, reads the ticket, then answers, in the pieces a real model streams // its text in, and keeps which method FrontMCP called. A real server doesn't need this file. import type { AgentCompletion, AgentLlmAdapter, AgentPrompt } from "@frontmcp/sdk"; const getTicket = { id: "call_1", name: "get_ticket", arguments: { id: "T-1" } }; function reply(prompt: AgentPrompt): { pieces: string[]; completion: AgentCompletion } { if (prompt.messages.length === 1) { const pieces = ["Reading ", "the ticket. "]; return { pieces, completion: { content: pieces.join(""), finishReason: "tool_calls", toolCalls: [getTicket] } }; } const pieces = ["High: ", "the customer can't log in."]; return { pieces, completion: { content: pieces.join(""), finishReason: "stop" } }; } export const called: string[] = []; export const model: AgentLlmAdapter = { async completion(prompt) { called.push("completion"); return reply(prompt).completion; }, async *streamCompletion(prompt) { called.push("streamCompletion"); const { pieces, completion } = reply(prompt); for (const content of pieces) yield { type: "content", content }; yield { type: "done", completion }; }, }; ``` ```ts streaming.test.ts import { test, expect } from "@frontmcp/testing"; import { called } from "./model.example"; type Received = { method: string; params?: any }[]; const streamOf = (received: Received) => received.map((n) => (n.method === "notifications/progress" ? `${n.params.progress} ${n.params.message}` : `${n.params.logger}: ${n.params.data.message}`)); test("each piece of the model's text is a progress update, as it arrives", async ({ mcp }) => { const notifications = mcp.notifications.collect(); await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(streamOf(notifications.received)).toEqual([ "1 Reading ", "2 the ticket. ", "triage: Identified 1 tool call(s): get_ticket", "triage: Calling tool: get_ticket", "get_ticket: Reading T-1", "3 High: ", "4 the customer can't log in.", ]); const progress = notifications.received.filter((n) => n.method === "notifications/progress"); expect(progress.every((n) => n.params?.total === undefined)).toBe(true); }); test("the result is the final reply, as without streaming", async ({ mcp }) => { called.length = 0; const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json()).toEqual({ response: "High: the customer can't log in." }); expect(called).toEqual(["streamCompletion", "streamCompletion"]); }); test("a call without a progress token isn't streamed", async ({ mcp }) => { called.length = 0; const response = await mcp.raw.request({ method: "tools/call", params: { name: "invoke_triage", arguments: { ticketId: "T-1" } } }); expect(response.result.structuredContent).toEqual({ response: "High: the customer can't log in." }); expect(called).toEqual(["completion", "completion"]); }); ``` Each piece is one `notifications/progress`: `progress` counts the pieces across the whole run, 1, 2, 3, so it only goes up, `message` is the piece, and there's no `total`, since nobody knows how long the reply will be. Join the messages to rebuild the text. The text the model writes before it calls a tool is streamed too, and the count goes on after the tool runs. The tools get their arguments from the stream's `done` chunk, as [The model adapter](https://frontmcp.dev/reference/sdk/agent#the-model-adapter) describes. The result is what it would be without streaming. An agent with an `outputSchema` streams its text too, and the result is checked once the loop ends, so a reply that doesn't match fails with `INVALID_OUTPUT` after the client has had all of it. A streamed run sends none of `enableAutoProgress`'s progress updates, which would be mixed into the text on the same token; its `Identified 1 tool call(s)` log message still goes out, as the first test shows. The run isn't streamed, and works as without the option, when: - the call has no progress token, as the last test shows: in the Call tab, untick **Stream progress & logs** and call again; - the adapter has no `streamCompletion()`, unless the agent class overrides `streamCompletion()`; - the agent class overrides `completion()` but not `streamCompletion()`, so the override still sees every request to the model. An agent that another agent's model calls isn't streamed either: its reply is the calling model's tool result. To decide per run, override `streamsReplies()`. New in 1.9.4: before, `enableStreaming` had no effect, and `true` logged a warning at startup. ### Calling other agents An agent's `agents` are agents it may call. Its model is offered each one as a tool, `invoke_`, with that agent's `description` and input, and calling it runs the other agent's own loop, with its own model and tools. Its answer comes back to the first model as the call's result. Clients don't see the nested agent: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-2" } } ``` ```ts agents.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { billingModel, triageModel } from "./model.example"; import { GetTicket } from "./ticket.tools"; @Agent({ name: "billing", description: "Answer a customer's question about an invoice or a charge.", inputSchema: { question: z.string().describe("The customer's question, with the invoice number") }, llm: { adapter: billingModel }, }) export class Billing extends AgentContext {} @Agent({ name: "triage", description: "Read a support ticket and answer it. Pass the ticket's id.", systemInstructions: "Read the ticket with get_ticket. Ask the billing agent about invoices and charges, and pass on its answer.", inputSchema: { ticketId: z.string() }, tools: [GetTicket], agents: [Billing], // triage's model may call billing llm: { adapter: triageModel }, }) export class Triage extends AgentContext {} ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { Triage } from "./agents"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) export class HelpDeskApp {} ``` ```ts ticket.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets: Record = { "T-1": "I can't log in since this morning.", "T-2": "I was charged twice for invoice INV-7.", }; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, text: tickets[id] ?? "" }; } } ``` ```ts model.example.ts // Stand-ins for the two agents' models, which the Playground can't reach. Triage's // reads the ticket, asks billing about charges, and passes billing's answer on. // `offered` keeps the tools each model was offered. A real server doesn't need this file. import type { AgentLlmAdapter, AgentToolDefinition } from "@frontmcp/sdk"; export const offered: Record = { triage: [], billing: [] }; export const triageModel: AgentLlmAdapter = { async completion(prompt, tools = []) { offered.triage.push(tools); const last = prompt.messages.at(-1)!; if (last.role === "user") { const { ticketId } = JSON.parse(last.content!); return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c1", name: "get_ticket", arguments: { id: ticketId } }] }; } if (last.name === "get_ticket") { const ticket = JSON.parse(last.content!); if (!/invoice|charge/i.test(ticket.text)) return { content: "The accounts team should take it.", finishReason: "stop" }; return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c2", name: "invoke_billing", arguments: { question: ticket.text } }] }; } return { content: `Billing says: ${JSON.parse(last.content!).response}`, finishReason: "stop" }; }, }; export const billingModel: AgentLlmAdapter = { async completion(prompt, tools) { offered.billing.push(tools ?? []); return { content: "INV-7 was charged twice: refund the second charge.", finishReason: "stop" }; }, }; ``` ```ts agents.test.ts import { test, expect } from "@frontmcp/testing"; import { Agent, AgentContext, App, FrontMcpInstance, z } from "@frontmcp/sdk"; import { Billing } from "./agents"; import { offered, triageModel } from "./model.example"; import { GetTicket } from "./ticket.tools"; test("triage's model is offered billing as a tool, and gets its answer", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" }); expect(result.json()).toEqual({ response: "Billing says: INV-7 was charged twice: refund the second charge." }); expect(offered.triage[0].map((t) => t.name)).toEqual(["get_ticket", "invoke_billing"]); expect(offered.triage[0][1].description).toBe("Answer a customer's question about an invoice or a charge."); }); test("clients see triage, not billing", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["invoke_triage"]); expect(await mcp.tools.call("invoke_billing", { question: "INV-7?" })).toBeError("TOOL_NOT_FOUND"); }); test("`invokeAgent()` and `this.callTool()` call it from code", async () => { @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, agents: [Billing], llm: { adapter: triageModel } }) class Triage extends AgentContext { async execute(input: { ticketId: string }) { const question = `${input.ticketId}: I was charged twice for invoice INV-7.`; const invoked = await this.invokeAgent("billing", { question }); const called = await this.callTool("invoke_billing", { question }); return { invoked, called: called.structuredContent }; } } @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) class HelpDeskApp {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); try { const result = await server.callTool("invoke_triage", { ticketId: "T-2" }); const answer = { response: "INV-7 was charged twice: refund the second charge." }; expect(result.structuredContent).toEqual({ invoked: answer, called: answer }); } finally { await server.dispose(); } }); test("with `swarm`, the model sees the app's other agents instead", async () => { @Agent({ name: "accounts", inputSchema: { question: z.string() }, llm: { adapter: triageModel } }) class Accounts extends AgentContext {} @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket], swarm: { canSeeOtherAgents: true, visibleAgents: ["billing"] }, llm: { adapter: triageModel }, }) class Triage extends AgentContext {} @App({ id: "help-desk", name: "Help Desk", agents: [Triage, Billing, Accounts] }) class HelpDeskApp {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); try { offered.triage.length = 0; await server.callTool("invoke_triage", { ticketId: "T-1" }); expect(offered.triage[0].map((t) => t.name)).toEqual(["get_ticket", "invoke_billing"]); expect((await server.listTools()).tools.map((t) => t.name).sort()).toEqual(["invoke_accounts", "invoke_billing", "invoke_triage"]); } finally { await server.dispose(); } }); ``` A nested agent belongs to the agent that lists it: the model's calls to it, and the agent's own `this.invokeAgent()` and `this.callTool("invoke_billing")`, reach it, while a client's call answers `Tool "invoke_billing" not found`, and so does another tool's `this.callTool()`. (Changed in 1.9.2: before, the agent's own `this.callTool()` didn't reach it either.) Agents registered side by side on the app are tools of the server: `swarm: { canSeeOtherAgents: true }` offers them to the model, `visibleAgents` picks which, and clients can call them too, as the last test shows. `this.callTool("invoke_")` reaches them from code, as [Agents That Call Agents](https://frontmcp.dev/learn/agents-that-call-agents) does. Each call from one agent to another counts toward `swarm.maxCallDepth`, 3 by default, so a chain that keeps going stops with [`AGENT_CALL_DEPTH_EXCEEDED`](https://frontmcp.dev/reference/sdk/agent#agent--cant-be-called-from--that-would-be-agent-call-n-deeper-than-maxcalldepth-m). New in 1.9: before, `agents` and `swarm` were ignored, `invokeAgent()` always threw, and nothing limited the depth. ### Writing an agent as a function `agent(options)(handler)` declares an agent without a class. The handler runs instead of the model loop: it gets the input and the agent's context, and what it returns is the result. `llm` is still required, but the model isn't asked, so the function form suits an agent whose work is code. For the loop, write a class. Initial call configuration: ```js { tool: "invoke_summarize", arguments: { text: "Nobody on our team can log in since this morning." } } ``` ```ts summarize.agent.ts active import { agent, z } from "@frontmcp/sdk"; import { model } from "./model.example"; export const Summarize = agent({ name: "summarize", description: "Shorten what a customer wrote to its first five words.", inputSchema: { text: z.string() }, llm: { adapter: model }, })((input) => ({ summary: input.text.split(" ").slice(0, 5).join(" ") })); ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { Summarize } from "./summarize.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Summarize] }) export class HelpDeskApp {} ``` ```ts model.example.ts // Stands in for a real model. The handler doesn't ask it. A real server doesn't need this file. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export let asked = 0; export const model: AgentLlmAdapter = { async completion() { asked += 1; return { content: "", finishReason: "stop" }; }, }; ``` ```ts summarize.test.ts import { test, expect } from "@frontmcp/testing"; import { asked } from "./model.example"; test("the handler's result is the call's result, and the model isn't asked", async ({ mcp }) => { const result = await mcp.tools.call("invoke_summarize", { text: "Nobody on our team can log in since this morning." }); expect(result.json()).toEqual({ summary: "Nobody on our team can" }); expect(asked).toBe(0); }); ``` Changed in 1.9: before, the handler never ran, and every call failed. ### Asking the user from an agent `this.elicit()` works in an agent's own `execute()` as it does [in a tool](https://frontmcp.dev/reference/sdk/elicit): the client shows the form, and under MCP 2026-07-28 FrontMCP runs `execute()` again from the top with the answer. Ask before `super.execute(input)`, so the model isn't asked until the user has answered, and not at all if they decline: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, llm: { adapter: model } }) export class Triage extends AgentContext { async execute(input: { ticketId: string }) { const answer = await this.elicit(`Set ${input.ticketId} to high priority?`, z.object({ confirm: z.boolean() })); if (answer.status !== "accept" || !answer.content?.confirm) return { response: "Left as it is." }; return super.execute(input); } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true } }; @FrontMcp(config) export default class HelpDeskServer {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. It counts how // often it's asked. A real server doesn't need this file. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export let asked = 0; export const model: AgentLlmAdapter = { async completion() { asked += 1; return { content: "High: the customer can't work.", finishReason: "stop" }; }, }; ``` ```ts elicit.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; import { asked } from "./model.example"; test("the client shows the question, and the agent goes on with the answer", async ({ mcp }) => { let question: string | undefined; mcp.onElicitation((request) => { question = request.message; return { action: "accept", content: { confirm: true } }; }); const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(question).toBe("Set T-1 to high priority?"); expect(result.json()).toEqual({ response: "High: the customer can't work." }); }); test("a declined question ends the call before the model is asked", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "decline" })); const before = asked; const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json()).toEqual({ response: "Left as it is." }); expect(asked).toBe(before); }); test("a client without forms gets the fallback, and the answer runs the agent", async () => { // createDirect() runs the server in-process, as the user you name. Its client can't show forms. const server = await FrontMcpInstance.createDirect(config); const asNour = { authContext: { user: { sub: "nour" } } }; const result = await server.callTool("invoke_triage", { ticketId: "T-1" }, asNour); const { elicitId } = result._meta?.elicitationPending as { elicitId: string }; const answer = await server.callTool("sendElicitationResult", { elicitId, action: "accept", content: { confirm: true } }, asNour); await server.dispose(); expect(answer.structuredContent).toEqual({ response: "High: the customer can't work." }); }); ``` Code before `this.elicit()` runs once per round, so keep side effects after it. A client that can't show forms gets FrontMCP's [fallback](https://frontmcp.dev/reference/sdk/elicit#the-model-is-asked-to-call-sendelicitationresult): the result asks its model to collect the answer and call `sendElicitationResult`, which only the caller who was asked may answer, and that answer runs the agent again, as the last test shows. Changed in 1.8.4: before, an agent's `this.elicit()` always ended the call with the fallback, even for a client that shows forms, and the answer failed with `Tool "triage" not found` instead of running the agent. ### Limiting who calls an agent, and how often `authorities`, `rateLimit`, `concurrency`, `timeout` and `availableWhen` work on `@Agent` as on `@Tool`: they apply to `invoke_`. A caller the agent's rule refuses doesn't see it in `tools/list`, and a call over a limit is refused before the model is asked, so these options also cap what clients can spend on your model: Initial call configuration: ```js { tool: "invoke_triage", arguments: { query: "Nobody on our team can log in since this morning." } } ``` ```ts agents.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority for a new support ticket. At most 2 calls a minute.", inputSchema: { query: z.string().describe("What the customer wrote") }, rateLimit: { maxRequests: 2, windowMs: 60_000 }, llm: { adapter: model }, }) export class Triage extends AgentContext {} @Agent({ name: "refunds", description: "Decide whether to refund an order. For team leads, one decision at a time.", inputSchema: { query: z.string().describe("The order and what went wrong") }, authorities: "lead", concurrency: { maxConcurrent: 1 }, llm: { adapter: model }, }) export class Refunds extends AgentContext {} @Agent({ name: "read_logs", description: "Explain the server's recent errors.", inputSchema: { query: z.string() }, availableWhen: { runtime: ["deno"] }, // only on the Deno deployment llm: { adapter: model }, }) export class ReadLogs extends AgentContext {} ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { ReadLogs, Refunds, Triage } from "./agents"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage, Refunds, ReadLogs] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], // The rules `authorities` names. Without this option, the server doesn't start. authorities: { profiles: { lead: { roles: { any: ["lead"] } } } }, }; @FrontMcp(config) export default class HelpDeskServer {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. It takes a // moment to answer, like a real one, and counts how often it's asked. // A real server doesn't need this file. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export let asked = 0; export const model: AgentLlmAdapter = { async completion(prompt) { asked += 1; await new Promise((resolve) => setTimeout(resolve, 50)); const text = prompt.messages[0].content ?? ""; return { content: /refund/i.test(text) ? "Refund it: the order arrived broken." : "High: the customer can't work.", finishReason: "stop" }; }, }; ``` ```ts limits.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; import { asked } from "./model.example"; test("`rateLimit`: the third call in a minute is refused, and the model isn't asked", async ({ mcp }) => { const query = "Nobody can log in."; expect(await mcp.tools.call("invoke_triage", { query })).toBeSuccessful(); expect(await mcp.tools.call("invoke_triage", { query })).toBeSuccessful(); const before = asked; const third = await mcp.tools.call("invoke_triage", { query }); expect(third).toBeError("RATE_LIMIT_EXCEEDED"); expect(third.text()).toMatch(/^Rate limit exceeded\. Retry after \d+ seconds$/); expect(asked).toBe(before); }); test("`authorities`: an anonymous caller doesn't see `invoke_refunds`, and can't call it", async ({ mcp }) => { expect(await mcp.tools.list()).not.toContainTool("invoke_refunds"); const result = await mcp.tools.call("invoke_refunds", { query: "Refund order 1042? It arrived broken." }); expect(result).toBeError("AUTHORITY_DENIED"); expect(result.text()).toBe(`Access denied to Tool "help-desk:invoke_refunds": profile:lead: roles.any: user has none of 'lead'`); }); test("a lead can call it, one call at a time (`concurrency`)", async () => { // createDirect() runs the server in-process, as the user you name. const server = await FrontMcpInstance.createDirect(config); const asLead = { authContext: { user: { sub: "nour", roles: ["lead"] } } }; const query = "Refund order 1042? It arrived broken."; const [first, second] = await Promise.allSettled([ server.callTool("invoke_refunds", { query }, asLead), server.callTool("invoke_refunds", { query }, asLead), ]); await server.dispose(); expect(first).toMatchObject({ status: "fulfilled", value: { structuredContent: { response: "Refund it: the order arrived broken." } } }); expect(second).toMatchObject({ status: "rejected", reason: { message: 'Concurrency limit reached for "invoke_refunds" (max: 1)' } }); }); test("`availableWhen`: an agent for Deno isn't offered here", async ({ mcp }) => { expect(await mcp.tools.list()).not.toContainTool("invoke_read_logs"); const result = await mcp.tools.call("invoke_read_logs", { query: "Why did last night's export fail?" }); expect(result).toBeError("ENTRY_UNAVAILABLE"); expect(result.text()).toMatch(/^Tool "invoke_read_logs" is not available in the current environment \(requires: \{"runtime":\["deno"\]\}\)/); }); ``` The Playground's caller is anonymous, so it sees only `invoke_triage`. The limits and their errors are the same as a tool's, and [Guard options](https://frontmcp.dev/reference/sdk/guard) covers them: whose calls share a count (`partitionBy`), queueing with `queueTimeoutMs`, and `timeout: { executeMs }`, which fails the call with `EXECUTION_TIMEOUT` but, like [`execution.timeout`](https://frontmcp.dev/reference/sdk/agent#agent-execution-timed-out-after-ms), doesn't stop the loop. Say the limits in the agent's `description`, so the client's model can plan around them. [Authorities](https://frontmcp.dev/reference/auth/authorities) has the rules `authorities` takes. Before FrontMCP 1.8.3, these options had no effect on an agent. They now apply, so check the ones your agents already declare. ### Giving the agent's tools a plugin An agent's tools run apart from the app, and so do its plugins: a plugin in `@Agent({ plugins })` runs for the tools the agent's model calls, and a plugin in `@App({ plugins })` runs for the app's tools and for `invoke_`, but not for the agent's tools unless the agent sets `execution: { inheritPlugins: true }`. Here one plugin that records every tool that runs is in both places: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { CallLog } from "./call-log.plugin"; import { model } from "./model.example"; import { GetTicket } from "./ticket.tools"; @Agent({ name: "triage", description: "Read a support ticket and suggest a priority. Pass the ticket's id.", inputSchema: { ticketId: z.string() }, tools: [GetTicket], plugins: [CallLog], // runs for get_ticket llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CallLog } from "./call-log.plugin"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage], plugins: [CallLog] }) // runs for invoke_triage export class HelpDeskApp {} ``` ```ts call-log.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; export const calls: string[] = []; @Plugin({ name: "call-log", description: "Records the name of every tool that runs" }) export class CallLog extends DynamicPlugin { @ToolHook.Will("execute") async record(ctx: FlowCtxOf<"tools:call-tool">) { calls.push(ctx.state.tool?.metadata.name ?? "unknown"); } } ``` ```ts ticket.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in since this morning" }; } } ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. It reads the // ticket, then answers. A real server doesn't need this file. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion(prompt) { const last = prompt.messages[prompt.messages.length - 1]; if (last.role === "user") { const { ticketId } = JSON.parse(last.content ?? "{}"); return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: ticketId } }] }; } return { content: "High: the customer can't log in.", finishReason: "stop" }; }, }; ``` ```ts plugins.test.ts import { test, expect } from "@frontmcp/testing"; import { Agent, AgentContext, App, FrontMcpInstance, z } from "@frontmcp/sdk"; import { CallLog, calls } from "./call-log.plugin"; import { model } from "./model.example"; import { GetTicket } from "./ticket.tools"; test("the app's plugin runs for `invoke_triage`, the agent's for `get_ticket`", async ({ mcp }) => { calls.length = 0; await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(calls).toEqual(["invoke_triage", "get_ticket"]); }); const runWithAppPluginOnly = async (execution: { inheritPlugins?: boolean }) => { @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket], execution, llm: { adapter: model } }) class Triage extends AgentContext {} @App({ id: "help-desk", name: "Help Desk", agents: [Triage], plugins: [CallLog] }) class HelpDeskApp {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); calls.length = 0; await server.callTool("invoke_triage", { ticketId: "T-1" }); await server.dispose(); return [...calls]; }; test("with the plugin only on the app, it doesn't run for the agent's tools", async () => { expect(await runWithAppPluginOnly({})).toEqual(["invoke_triage"]); }); test("unless the agent sets `inheritPlugins`", async () => { expect(await runWithAppPluginOnly({ inheritPlugins: true })).toEqual(["invoke_triage", "get_ticket"]); }); ``` With `inheritPlugins`, the agent's tools also run the app's and the server's plugins, and a plugin installed in both places runs once. (New in 1.9: before, FrontMCP never read `inheritPlugins`.) A plugin that enforces an option of the agent's tools, like [Approval](https://frontmcp.dev/reference/plugins/approval) for `approval`, has to run for them, in the agent's `plugins` or on the app with `inheritPlugins`, or the server doesn't start: see [Troubleshooting](https://frontmcp.dev/reference/sdk/agent#unenforced-metadata-tool--declares-approval). ### Using a real model With a real model, only `llm` changes. Install the provider's package and give the agent a key: ```bash yarn add openai export OPENAI_API_KEY=sk-... ``` ```ts triage.agent.ts @Agent({ name: "triage", // ... llm: { provider: "openai", model: "gpt-5", apiKey: { env: "OPENAI_API_KEY" } }, }) export class Triage extends AgentContext {} ``` The Playground can't do that: it has no network and no key. It can run the adapter, though, with a stand-in client, which shows what FrontMCP sends OpenAI for each turn of the loop: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, OpenAIAdapter, z } from "@frontmcp/sdk"; import { openai } from "./openai.example"; import { GetTicket } from "./ticket.tools"; @Agent({ name: "triage", description: "Suggest a priority for a support ticket. Pass the ticket's id.", systemInstructions: "You triage tickets. Read the ticket with get_ticket, then answer high or normal, and why.", inputSchema: { ticketId: z.string() }, tools: [GetTicket], // With the real `openai` package, pass `apiKey` instead of `client`. llm: { adapter: new OpenAIAdapter({ model: "gpt-5", client: openai }) }, }) export class Triage extends AgentContext {} ``` ```ts ticket.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in since this morning" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) export class HelpDeskApp {} ``` ```ts openai.example.ts // Stands in for the OpenAI client, which the Playground can't use: no network, // no key. It answers the way the Chat Completions API does, and keeps every // request it gets. With the real `openai` package, you don't need this file. type Request = { messages: { role: string; content: string | null }[] }; export const requests: Request[] = []; export const openai = { chat: { completions: { async create(request: Request) { requests.push(structuredClone(request)); const last = request.messages[request.messages.length - 1]; if (last.role === "user") { const { ticketId } = JSON.parse(last.content ?? "{}"); const call = { id: "call_1", type: "function", function: { name: "get_ticket", arguments: JSON.stringify({ id: ticketId }) } }; return { choices: [{ index: 0, finish_reason: "tool_calls", message: { role: "assistant", content: null, tool_calls: [call] } }] }; } const answer = "High: the customer can't log in."; return { choices: [{ index: 0, finish_reason: "stop", message: { role: "assistant", content: answer } }] }; }, }, }, responses: { async create() { throw new Error("This example uses the Chat Completions API."); }, }, }; ``` ```ts openai.test.ts import { test, expect } from "@frontmcp/testing"; import { requests } from "./openai.example"; test("the adapter sends a chat request with the instructions and the tools", async ({ mcp }) => { requests.length = 0; const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json()).toEqual({ response: "High: the customer can't log in." }); expect(requests).toHaveLength(2); expect(requests[0]).toMatchObject({ model: "gpt-5", messages: [ { role: "system", content: "You triage tickets. Read the ticket with get_ticket, then answer high or normal, and why." }, { role: "user", content: '{"ticketId":"T-1"}' }, ], tools: [{ type: "function", function: { name: "get_ticket", description: "Get a support ticket by id." } }], }); }); test("the tool's result goes back as a `tool` message", async ({ mcp }) => { requests.length = 0; await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(requests[1].messages.slice(2)).toEqual([ { role: "assistant", content: null, tool_calls: [{ id: "call_1", type: "function", function: { name: "get_ticket", arguments: '{"id":"T-1"}' } }], }, { role: "tool", tool_call_id: "call_1", content: '{"id":"T-1","title":"Cannot log in since this morning"}' }, ]); }); ``` `AnthropicAdapter` takes the same options, except `api`, and sends Anthropic's Messages API format: `system` as its own field, `tools` with an `input_schema`, and tool results as `tool_result` blocks. [Connecting a Real Model](https://frontmcp.dev/learn/connecting-a-real-model) covers keys, packages, cost and latency. --- ## Troubleshooting ### `Agent reached maximum iterations (…) without completing` The model was asked `maxIterations` times (10 by default), and still asked for tools the last time. The tools of that last turn ran, but their results never reached the model: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, Tool, ToolContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in since this morning" }; } } @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket], execution: { maxIterations: 3, enableAutoProgress: true }, llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts model.example.ts // Stands in for a model that never decides: it reads the ticket again every // time it's asked. A real server doesn't need this file. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export let asked = 0; export const model: AgentLlmAdapter = { async completion() { asked += 1; return { content: null, finishReason: "tool_calls", toolCalls: [{ id: `call_${asked}`, name: "get_ticket", arguments: { id: "T-1" } }] }; }, }; ``` ```ts limit.test.ts import { test, expect } from "@frontmcp/testing"; import { asked } from "./model.example"; test("the call fails after three turns", async ({ mcp }) => { const before = asked; const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result).toBeError("TOOL_EXECUTION_ERROR"); expect(result.text()).toMatch(/^Tool "invoke_triage" execution failed: Agent reached maximum iterations \(3\) without completing/); expect(asked - before).toBe(3); }); test("the last progress update says only that the agent failed", async ({ mcp }) => { const notifications = mcp.notifications.collect(); await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); const progress = notifications.received.filter((n) => n.method === "notifications/progress"); expect(progress.at(-1)?.params).toMatchObject({ progress: 100, message: expect.stringMatching(/^Agent failed: Internal FrontMCP error\. Please contact support with error ID: err_\w+$/), }); }); ``` Check that the instructions say when the model is done, and that the tools give it what it needs to finish: a model that doesn't find what it's looking for tends to ask again. Raise `maxIterations` only if the task really takes more turns. The error, and every model call before it, is lost to the client, so a low limit that fails is cheaper than a high one that fails. With `enableAutoProgress`, the last progress update is `Agent failed: ` and FrontMCP's generic message with an error id, as the second test shows. (Changed in 1.9.3: before, it had the error's own text.) ### `Agent execution timed out after …ms` The model loop took longer than `execution.timeout` (2 minutes by default). The client gets the error then, but the loop isn't stopped: the model call in progress, and any tool calls after it, still run in the background. An agent's `timeout: { executeMs }` is a second limit, the [one tools have](https://frontmcp.dev/reference/sdk/guard#timeout). It fails the call with `EXECUTION_TIMEOUT` and `Execution of "invoke_triage" timed out after …ms` instead, and doesn't stop the loop either, as the second test shows. Initial call configuration: ```js { tool: "invoke_triage", arguments: { query: "Nobody can log in." } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", inputSchema: { query: z.string() }, execution: { timeout: 50 }, llm: { adapter: model }, }) export class Triage extends AgentContext {} ``` ```ts model.example.ts // Stands in for a model that takes 200 ms to answer. A real server doesn't need this file. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const answered: string[] = []; export const model: AgentLlmAdapter = { async completion() { await new Promise((resolve) => setTimeout(resolve, 200)); answered.push("High"); return { content: "High", finishReason: "stop" }; }, }; ``` ```ts timeout.test.ts import { test, expect } from "@frontmcp/testing"; import { Agent, AgentContext, App, FrontMcpInstance, z } from "@frontmcp/sdk"; import { answered, model } from "./model.example"; test("the call fails at the timeout, and the model answers later anyway", async ({ mcp }) => { answered.length = 0; const started = Date.now(); const result = await mcp.tools.call("invoke_triage", { query: "Nobody can log in." }); expect(Date.now() - started).toBeLessThan(200); expect(result).toBeError("TOOL_EXECUTION_ERROR"); expect(result.text()).toMatch(/^Tool "invoke_triage" execution failed: Agent execution timed out after 50ms/); expect(answered).toEqual([]); await new Promise((resolve) => setTimeout(resolve, 250)); expect(answered).toEqual(["High"]); }); test("`timeout: { executeMs }` fails it with `EXECUTION_TIMEOUT`, and doesn't stop the loop either", async () => { @Agent({ name: "triage", inputSchema: { query: z.string() }, timeout: { executeMs: 50 }, llm: { adapter: model } }) class Triage extends AgentContext {} @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) class HelpDeskApp {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); answered.length = 0; await expect(server.callTool("invoke_triage", { query: "Nobody can log in." })).rejects.toThrow( 'Execution of "invoke_triage" timed out after 50ms', ); expect(answered).toEqual([]); await new Promise((resolve) => setTimeout(resolve, 250)); expect(answered).toEqual(["High"]); await server.dispose(); }); ``` Give the timeout room for a few slow model calls: each one can take seconds. Tools with side effects may run after the client was told the call failed, so make them safe to repeat. ### An agent's tool fails with `Provider "…" is not available` The agent's tool asked for a provider that isn't registered where the agent is: on the agent, its app or the server. A provider on another app doesn't count. The client doesn't see this error, because it's a tool error inside the loop: the model gets it, and answers as best it can. The client's log gets `Tool get_ticket failed: ` with only an error id, and the server's log has the error under that id. Here `TicketStore` is on the `tickets` app, and the agent is in `help-desk`. The scripted model passes the error on: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts main.ts active import { Agent, AgentContext, App, FrontMcp, Provider, Tool, ToolContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Provider({ name: "TicketStore" }) export class TicketStore { get(id: string) { return { id, title: "Cannot log in since this morning" }; } } @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return this.get(TicketStore).get(id); } } @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket], llm: { adapter: model } }) export class Triage extends AgentContext {} // 🚩 TicketStore is on another app @App({ id: "tickets", name: "Tickets", tools: [GetTicket], providers: [TicketStore] }) export class TicketsApp {} @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) export class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [TicketsApp, HelpDeskApp] }) export default class Server {} ``` ```ts model.example.ts // Stands in for a model that reads the ticket, then repeats what the tool said. // A real server doesn't need this file. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion(prompt) { const last = prompt.messages[prompt.messages.length - 1]; if (last.role === "user") { return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: "T-1" } }] }; } return { content: last.content, finishReason: "stop" }; }, }; ``` ```ts provider.test.ts import { test, expect } from "@frontmcp/testing"; test("the model gets the error, and the call succeeds", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result).toBeSuccessful(); expect(result.json().error).toContain('Provider "TicketStore" is not available: not found in local or parent registries'); }); test("the client's log gets only an error id", async ({ mcp }) => { const log = mcp.notifications.collect(); await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); const errors = log.received.filter((n) => n.params?.level === "error").map((n) => n.params?.data.message); expect(errors).toEqual([expect.stringMatching(/^Tool get_ticket failed: Internal FrontMCP error\. Please contact support with error ID: err_\w+$/)]); }); test("the other app's own tool finds it", async ({ mcp }) => { expect((await mcp.tools.call("get_ticket", { id: "T-1" })).json()).toEqual({ id: "T-1", title: "Cannot log in since this morning" }); }); ``` Register the provider on the agent's app, on the agent, or, to share it between apps, on the server with `@FrontMcp({ providers })`: see [Sharing a provider with the agent's tools](https://frontmcp.dev/reference/sdk/agent#sharing-a-provider-with-the-agents-tools). (Changed in 1.9.2: before, an agent's tools didn't see their own app's providers either, so servers written for 1.9.1 often have them on `@FrontMcp`, where they still work.) ### The model gets `Tool "…" not found in agent "…". Available tools: […]` The model asked for a tool the agent doesn't offer it. An agent's model gets its `tools`, its nested `agents`, the agents `swarm` lets it see, and, with `execution.inheritParentTools`, the app's tools: nothing else. The model reads this message as the call's result and can try again; if it keeps asking for tools the agent doesn't have, the call ends with [`Agent reached maximum iterations`](https://frontmcp.dev/reference/sdk/agent#agent-reached-maximum-iterations--without-completing). Add the tool to `tools`, or check that `systemInstructions` names the tools as they're spelled. ### `Tool "invoke_…" not found` The client called an agent the server doesn't have. Check, in order: 1. The class is in an app's `agents` array, and the app is in `@FrontMcp({ apps })`. An agent only in another agent's `agents` is for that agent: clients can't call it, and neither can other tools' `this.callTool()`. See [Calling other agents](https://frontmcp.dev/reference/sdk/agent#calling-other-agents). 2. The name: `invoke_` and the agent's `id` if it has one, else its `name`, with characters other than letters, digits, `_` and `-` replaced by `_`. 3. If `tools/list` doesn't show it, it may set `hideFromDiscovery: true`. It can still be called by name. `tools/list` also leaves an agent out for callers its `authorities` refuse, who get `AUTHORITY_DENIED`, and where its `availableWhen` doesn't match, which answers `ENTRY_UNAVAILABLE`: see [Limiting who calls an agent, and how often](https://frontmcp.dev/reference/sdk/agent#limiting-who-calls-an-agent-and-how-often). ### `Environment variable … is not set` The server didn't start because an agent's `llm.apiKey` is `{ env: "…" }` and that variable isn't set. FrontMCP reads it when the server starts, not when the agent is first called. Set it in the environment the server runs in. ### `Tool output validation failed (output does not match outputSchema at …)` The agent has an `outputSchema`, and the model's answer didn't match it, often because it answered in plain text, which becomes `{ "response": "…" }`. The client gets the code `INVALID_OUTPUT`, and the text names the first field that didn't match. See [Returning structured output](https://frontmcp.dev/reference/sdk/agent#returning-structured-output). ### `Unenforced metadata: Tool "…" declares 'approval'` The server didn't start, because a tool in an agent's `tools` has `approval`, and no [Approval plugin](https://frontmcp.dev/reference/plugins/approval) runs for it. An app's plugins run for its agents' tools only with `execution.inheritPlugins`, so without it the option would do nothing, and FrontMCP refuses to start rather than run the tool unchecked. `featureFlag` without the Feature Flags plugin stops it the same way. Put the plugin in `@Agent({ plugins })`, or set `inheritPlugins` with the plugin on the app: Initial call configuration: ```js { tool: "invoke_refunds", arguments: { orderId: "1042" } } ``` ```ts refunds.agent.ts active import { Agent, AgentContext, Tool, ToolContext, z } from "@frontmcp/sdk"; import { ApprovalPlugin } from "@frontmcp/plugin-approval"; import { model } from "./model.example"; @Tool({ name: "issue_refund", description: "Refund an order.", inputSchema: { orderId: z.string() }, approval: true }) export class IssueRefund extends ToolContext { async execute({ orderId }: { orderId: string }) { return { orderId, refunded: true }; } } @Agent({ name: "refunds", description: "Refund an order. Pass its id.", inputSchema: { orderId: z.string() }, tools: [IssueRefund], plugins: [ApprovalPlugin.init()], // enforces `approval` on the agent's tools llm: { adapter: model }, }) export class Refunds extends AgentContext {} ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { Refunds } from "./refunds.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Refunds] }) export class HelpDeskApp {} ``` ```ts model.example.ts // Stands in for a model that asks for the refund, then repeats what the tool // said. A real server doesn't need this file. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion(prompt) { const last = prompt.messages[prompt.messages.length - 1]; if (last.role === "user") { const { orderId } = JSON.parse(last.content ?? "{}"); return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "issue_refund", arguments: { orderId } }] }; } return { content: last.content, finishReason: "stop" }; }, }; ``` ```ts approval.test.ts import { test, expect } from "@frontmcp/testing"; import { Agent, AgentContext, App, FrontMcpInstance, z } from "@frontmcp/sdk"; import { ApprovalPlugin } from "@frontmcp/plugin-approval"; import { model } from "./model.example"; import { IssueRefund } from "./refunds.agent"; test("the agent's plugin holds the refund until someone approves it", async ({ mcp }) => { const result = await mcp.tools.call("invoke_refunds", { orderId: "1042" }); expect(result.json()).toEqual({ error: 'Tool "agent:refunds:issue_refund" requires approval to execute. Allow?' }); }); const startWithPluginOnApp = (execution: { inheritPlugins?: boolean }) => { @Agent({ name: "refunds", inputSchema: { orderId: z.string() }, tools: [IssueRefund], execution, llm: { adapter: model } }) class Refunds extends AgentContext {} @App({ id: "help-desk", name: "Help Desk", agents: [Refunds], plugins: [ApprovalPlugin.init()] }) class HelpDeskApp {} return FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); }; test("with the plugin only on the app, the server doesn't start", async () => { await expect(startWithPluginOnApp({})).rejects.toThrow( `Unenforced metadata: Tool "refunds:issue_refund" declares 'approval' (enforced by ApprovalPlugin from @frontmcp/plugin-approval)`, ); }); test("with `inheritPlugins`, the app's plugin enforces it", async () => { const server = await startWithPluginOnApp({ inheritPlugins: true }); try { const result = await server.callTool("invoke_refunds", { orderId: "1042" }); expect(result.structuredContent).toEqual({ error: 'Tool "agent:refunds:issue_refund" requires approval to execute. Allow?' }); } finally { await server.dispose(); } }); ``` The model gets the plugin's refusal as the tool's result; [Approval](https://frontmcp.dev/reference/plugins/approval) covers how a user grants it. (Changed in 1.9.2: before, the startup check didn't count a plugin the agent inherits, so `inheritPlugins` didn't help here.) When the message names an `Agent "…"`, the option is on `@Agent` itself, where it applies to `invoke_`: that plugin goes on the app. ### The client's log has `Calling tool: …` messages you didn't send The agent sends one for each tool call its model asks for, and `Tool … failed: …` for each that fails, with the error's public message. Set `execution: { enableNotifications: false }` to stop them: see [Telling the client how the loop is going](https://frontmcp.dev/reference/sdk/agent#telling-the-client-how-the-loop-is-going). ### `Agent "…" can't be called from …: that would be agent call N, deeper than maxCallDepth M` A chain of agents calling agents went past `swarm.maxCallDepth`, 3 by default. The message lists the agents running, from the one the client called. When a model made the call, the model gets this as the call's `{"error":"…"}`, and the loop goes on; when code made it, with `invokeAgent()` or `this.callTool("invoke_")`, it throws, and the client gets `AGENT_CALL_DEPTH_EXCEEDED` unless the agent catches it. An agent that calls itself, or two that hand a task back and forth, are the usual cause: give the chain a way to end, as [Agents That Call Agents](https://frontmcp.dev/learn/agents-that-call-agents#handoffs-that-never-stop) does. Raise `maxCallDepth` only when the chain really is that long. (New in 1.9: before, nothing limited the depth.) ### `Agent "…" exports resources "…", which is not one of its own resources` The server didn't start, because `exports` names a resource, prompt or provider that isn't in the agent's own `resources`, `prompts` or `providers`. Add it there too. `exports` takes only those three keys: any other, like `tools`, fails with `Unrecognized key`. ### `Agent "…" has a tool named "…", the name of a built-in tool its model reads the agent's resources and prompts with` The server didn't start, with an `AgentConfigurationError`. The agent declares resources or prompts, so its model is offered [`list_resources` and `read_resource`, or `list_prompts` and `get_prompt`](https://frontmcp.dev/reference/sdk/agent#reading-the-agents-resources-and-prompts), and one of the agent's own tools has one of those names: the model couldn't tell them apart. That includes a plugin's or an adapter's tool on the agent, and a nested agent's `invoke_`. Rename the tool: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, Resource, ResourceContext, Tool, ToolContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Resource({ name: "priority-policy", uri: "policy://priorities", mimeType: "text/markdown" }) export class PriorityPolicy extends ResourceContext { async execute(uri: string) { return { contents: [{ uri, text: "High: the customer can't work. Normal: anything else." }] }; } } @Tool({ name: "read_ticket", description: "Read a support ticket by id.", inputSchema: { id: z.string() } }) export class ReadTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in since this morning" }; } } @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [ReadTicket], resources: [PriorityPolicy], llm: { adapter: model } }) export class Triage extends AgentContext {} ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) export class HelpDeskApp {} ``` ```ts model.example.ts // Stands in for a real model: it answers at once, and keeps the names of the // tools it was offered. A real server doesn't need this file. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const offered: (string[] | undefined)[] = []; export const model: AgentLlmAdapter = { async completion(prompt, tools) { offered.push(tools?.map((t) => t.name)); return { content: "High", finishReason: "stop" }; }, }; ``` ```ts names.test.ts import { test, expect } from "@frontmcp/testing"; import { Agent, AgentContext, App, FrontMcpInstance, Tool, ToolContext, z } from "@frontmcp/sdk"; import { model, offered } from "./model.example"; import { PriorityPolicy } from "./triage.agent"; test("a tool named `read_resource` stops the server from starting", async () => { @Tool({ name: "read_resource", description: "Read a support ticket by id.", inputSchema: { id: z.string() } }) class ReadResource extends ToolContext { async execute({ id }: { id: string }) { return { id }; } } @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [ReadResource], resources: [PriorityPolicy], llm: { adapter: model } }) class Triage extends AgentContext {} @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) class HelpDeskApp {} await expect(FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] })).rejects.toThrow( `Agent "triage" has a tool named "read_resource", the name of a built-in tool its model reads the agent's resources and prompts with. Rename the tool.`, ); }); test("renamed, the model is offered the built-ins and the tool", async ({ mcp }) => { offered.length = 0; await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(offered).toEqual([["list_resources", "read_resource", "read_ticket"]]); }); ``` Only the names the agent is offered count: an agent without prompts may have a tool named `get_prompt`. A tool of the app that [`inheritParentTools`](https://frontmcp.dev/reference/sdk/agent#execution) would offer under one of these names isn't offered; the built-in is. (New in 1.9.4, with the built-in tools.) ### `execute() requires a parameter matching the input schema` An `execute()` override has no parameter, but the agent's input schema has fields. Add a parameter with the schema's inferred type, or use an empty input schema for an agent that takes no input. Changed in 1.9.7: the empty-schema override compiles; a missing parameter for a schema with fields reports this message. --- # @Skill > Declare a skill, a written procedure with the tools it uses, that clients find and load for their model to follow. Source: https://frontmcp.dev/reference/sdk/skill [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `@Skill` declares a [skill](https://frontmcp.dev/learn/teaching-the-model-skills): a procedure written down for the client's model, with the tools it uses, its parameters and examples. FrontMCP serves it as `skill://` resources, and answers FrontMCP's own `skills/search`, `skills/list` and `skills/load` requests. A skill adds no tool: the client's model reads the steps and does the work itself, with your tools. A skill written as a folder with a `SKILL.md` loads with [`skillDir()`](https://frontmcp.dev/reference/sdk/skill#skilldirdir). To run a procedure on your server's model instead, write an [agent](https://frontmcp.dev/reference/sdk/agent). ```ts @Skill(options) class MySkill {} ``` --- ## Reference ### `@Skill(options)` Apply `@Skill` to a class, and list the class in an app's `skills` array. The class holds no code: FrontMCP only reads the options. ```ts triage-ticket.skill.ts import { Skill } from "@frontmcp/sdk"; @Skill({ name: "triage-ticket", description: "Decide a new support ticket's priority and who handles it.", instructions: `1. Read the ticket with get_ticket. 2. Search for tickets from the same customer with search_tickets. If one is open about the same problem, say so. 3. Set the priority with set_priority: high if the customer can't work, normal otherwise. 4. Assign it with assign_ticket: billing questions to the billing team, everything else to support.`, tools: [ "get_ticket", "search_tickets", { name: "set_priority", purpose: "Record the priority", required: true }, "assign_ticket", ], parameters: [{ name: "ticketId", description: "The ticket to triage, like T-1", required: true }], examples: [{ scenario: "Acme can't log in", parameters: { ticketId: "T-1" }, expectedOutcome: "T-1 is high priority, assigned to support" }], tags: ["support"], }) export class TriageTicket {} ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { AssignTicket, GetTicket, SearchTickets, SetPriority } from "./ticket.tools"; import { TriageTicket } from "./triage-ticket.skill"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, SearchTickets, SetPriority, AssignTicket], skills: [TriageTicket], }) export class HelpDeskApp {} ``` [See more examples below.](https://frontmcp.dev/reference/sdk/skill#usage) #### Options Required: | Option | Type | Description | | --- | --- | --- | | `name` | `string` | The skill's name, in kebab-case: lowercase letters, digits and hyphens. Any other name stops the server from starting. The resource is `skill:///SKILL.md`. | | `description` | `string` | What the procedure is for. Clients show it in search results and in `skill://index.json`, and the client's model decides from it whether to load the skill. | | `instructions` | `string \| { file: string } \| { url: string }` | The procedure: inline, or read from a file or a URL. See [`instructions`](https://frontmcp.dev/reference/sdk/skill#instructions). | Optional: | Option | Type | Default | Description | | --- | --- | --- | --- | | `tools` | `(string \| ToolClass \| { name, purpose?, required? } \| { tool, purpose?, required? })[]` | `[]` | The tools the procedure uses, by name or by class. They're listed by name in `SKILL.md` and in `skills/load`, each marked `available` if it's in the server's `tools/list`. A tool that `availableWhen.surface` keeps from the caller counts as missing too (since 1.8.5). `purpose` says what the tool is for in this procedure. Tool classes work since 1.9: before, they [stopped the server from starting](https://frontmcp.dev/reference/sdk/skill#invalid-tool-class--tool-class-must-be-decorated-with-tool-and-have-a-name-property). | | `parameters` | `{ name, description?, required?, type?, default? }[]` | `[]` | What the procedure needs to know before it starts. `type` is `"string"` (the default), `"number"`, `"boolean"`, `"object"` or `"array"`. | | `examples` | `{ scenario, parameters?, expectedOutcome? }[]` | `[]` | When to use the skill, and what should happen. | | `tags` | `string[]` | `[]` | For clients to filter `skills/search` and `skills/list` by. Not in `SKILL.md`. | | `id` | `string` | `name` | Replaces `name` as the skill's id in `skills/search`, `skills/list` and `skills/load`. The resource is listed as `skill:///SKILL.md`, and since 1.8.5 `skill:///SKILL.md` reads it too. An `id` that is another skill's `name` stops the server from starting, with `InvalidSkillError`. | | `priority` | `number` | `0` | `skills/list` can sort by it. It doesn't change the order of `skills/search` results. | | `hideFromDiscovery` | `boolean` | `false` | Leaves the skill out of the resources, `skill://index.json`, `skills/search` and `skills/list`. `skills/load` still loads it by id. See [Hiding a skill](https://frontmcp.dev/reference/sdk/skill#hiding-a-skill). | | `visibility` | `"mcp" \| "http" \| "both"` | `"both"` | `"http"` leaves the skill out of the resources, the index and `skills/search`, for the [HTTP endpoints](https://frontmcp.dev/reference/sdk/skill#server-options-skillsconfig) only. It's still in `skills/list`, and `skills/load` loads it. | | `toolValidation` | `"strict" \| "warn" \| "ignore"` | `"warn"` | What to do when a tool in `tools` doesn't exist. `"warn"` logs a warning, and the skill loads with the tool in `missingTools`. `"strict"` stops the server from starting, with a [`SkillValidationError`](https://frontmcp.dev/reference/sdk/skill#skill--failed-tool-validation-missing-tools-) (since 1.9; before, it started anyway). | | `skillPath` | `string[]` | `[name]` | A longer resource path, ending with `name`: `["billing", "refunds"]` serves `skill://billing/refunds/SKILL.md`. | | `license`, `compatibility`, `specMetadata`, `allowedTools` | `string`, `string`, `Record`, `string` | none | Fields from the [Agent Skills](https://agentskills.io/specification) format. They're written into `SKILL.md`'s frontmatter as `license`, `compatibility`, `metadata` and `allowed-tools`. FrontMCP doesn't act on `allowedTools`: it's there for the client to read. | | `resources` | `{ scripts?, references?, assets?, examples? }` | none | Folders of files that come with the skill, each an absolute path or one relative to the file that declares the skill. Clients read a file in them at `skill:////`, like `skill://triage-ticket/references/priorities.md`, and the files in `references` are listed in a table under the instructions. [`skillDir()`](https://frontmcp.dev/reference/sdk/skill#skilldirdir) fills it in from a skill's folder. They're read from disk, so the Playground can't show them: see [Loading a skill from a SKILL.md folder](https://frontmcp.dev/reference/sdk/skill#loading-a-skill-from-a-skillmd-folder). | | `availableWhen` | `EntryAvailability` | none | Only serve the skill on some platforms or runtimes, or to some callers: a `surface` without `"mcp"` keeps it from MCP clients. Where it doesn't match, the skill isn't served at all: it's left out of the resources, `skill://index.json`, `skills/list`, `skills/search`, `skills/load` and the HTTP endpoints, and its `SKILL.md` can't be read. See [A skill isn't listed, and can't be loaded](https://frontmcp.dev/reference/sdk/skill#a-skill-isnt-listed-and-cant-be-loaded) and [Environment awareness](https://frontmcp.dev/reference/server/environment). | | `category`, `rating` | `string`, `number` | none | For the HTTP endpoints. Not in the MCP responses. | | `referencedOperations`, `alwaysLoad` | | none | For FrontMCP's code-execution plugin. This page doesn't cover them. | #### `instructions` | Source | Example | When it's read | | --- | --- | --- | | Inline | `instructions: "1. Read the ticket…"` | At once. | | A file | `instructions: { file: "./triage-ticket.md" }` | When the server starts, like a URL, then kept. The path is relative to the file that declares the skill. | | A URL | `instructions: { url: "https://docs.example.com/triage.md" }` | When the server starts, with `fetch`, then kept. | A file or URL that can't be read doesn't stop the server. The skill is left out of `skills/list`, and each request that needs it tries again and fails, as in [Troubleshooting](https://frontmcp.dev/reference/sdk/skill#skillsload-fails-with-enoent-or-fetch-failed). A frontmatter block at the top of a fetched file is kept as it is, so `SKILL.md` ends up with two. The Playground has no files and no network, so its examples use inline instructions. #### What a skill adds to the server | What | Kind | Description | | --- | --- | --- | | `skill://index.json` | resource | The discovery index, in the [Agent Skills discovery](https://agentskills.io/specification) format: each skill's `name`, `description` and `url`. | | `skill:///SKILL.md` | resource, one per skill | The skill as a Markdown file: YAML frontmatter, then the instructions. See [`SKILL.md`](https://frontmcp.dev/reference/sdk/skill#skillmd). | | `skill://{+skillPath}/SKILL.md`, `skill://{+skillPath}/{+filePath}` | resource templates | Read any skill's `SKILL.md`, or a file in its directory. | | `skills/search`, `skills/list`, `skills/load` | JSON-RPC requests | FrontMCP's own requests, not part of MCP. See [below](https://frontmcp.dev/reference/sdk/skill#skillssearch). | | `io.modelcontextprotocol/skills` | capability | In `capabilities.extensions` of `server/discover`, so clients know the server has skills. | | A list of the skills | `instructions` | Added to the server's `instructions` in `server/discover` and `initialize`, after the server's own: `Available skills`, a pointer to `skill://index.json`, then a line per skill, `- ****: `. See [Serving a skill](https://frontmcp.dev/reference/sdk/skill#serving-a-skill). (Changed in 1.9.3: before, `server/discover` didn't have it.) | A skill adds **no tools**: `tools/list` is the same with or without it. Clients that support skills read the resources; the rest can still list and read them as resources, and their model finds the skills in the server's instructions. #### `SKILL.md` ```md --- name: triage-ticket description: Decide a new support ticket's priority and who handles it. tools: - get_ticket - name: set_priority purpose: Record the priority required: true parameters: - name: ticketId description: The ticket to triage, like T-1 required: true type: string examples: - scenario: Acme can't log in parameters: ticketId: T-1 expectedOutcome: T-1 is high priority, assigned to support --- 1. Read the ticket with get_ticket. … ``` The frontmatter has `name`, `description`, `tools`, `parameters`, `examples` and the Agent Skills fields, as you wrote them. It doesn't have `tags` or `priority`. #### `skills/search` Finds skills whose description or tags match a query. | Parameter | Type | Description | | --- | --- | --- | | `query` | `string` | Required. Words to look for. | | `tags` | `string[]` | Only skills with one of these tags. | | `tools` | `string[]` | Only skills that use these tools. | | `requireAllTools` | `boolean` | Leave out skills that use a tool the server doesn't have. | | `limit` | `number` | At most this many skills. | Returns `{ skills, total, hasMore, guidance }`. Each skill has `id`, `name`, `description`, `score` (from 0 to 1), `tags`, `tools` (each `{ name, available }`) and `source: "local"`. `guidance` is a sentence for the model: `Found 1 matching skill(s). Use skills/load with skill IDs to load full content.`, or, with no match, `No matching skills found. Try different search terms or list all skills with skills/list.` Skills hidden with `hideFromDiscovery` or `visibility: "http"` aren't searched. #### `skills/list` Lists skills, sorted by name. | Parameter | Type | Description | | --- | --- | --- | | `tags` | `string[]` | Only skills with one of these tags. | | `sortBy` | `"name" \| "priority" \| "createdAt"` | The order. | | `sortOrder` | `"asc" \| "desc"` | | | `offset`, `limit` | `number` | A page of the list. | | `includeHidden` | `boolean` | Also list skills with `hideFromDiscovery`. | Returns `{ skills, total, hasMore }`, each skill with `id`, `name`, `description`, `tags` and `priority`. #### `skills/load` Loads skills by id or name, with everything the model needs to follow them. | Parameter | Type | Description | | --- | --- | --- | | `skillIds` | `string[]` | Required. The skills to load. | | `format` | `"full" \| "instructions-only"` | `"instructions-only"` leaves the tools' input schemas out of `tools`. Default `"full"`. | Returns `{ skills, summary, nextSteps }`. Each skill has `id`, `name`, `description`, `instructions`, `tools` (each `{ name, purpose?, available, inputSchema? }`), `parameters`, `availableTools`, `missingTools`, `isComplete`, and `formattedContent`: the whole skill as one Markdown text for the model, with a `[✓]` or `[✗]` for each tool and its input schema. `summary` has `totalSkills`, `totalTools`, `allToolsAvailable` and `combinedWarnings`, which names missing tools and ids that weren't found. An id that isn't found isn't an error: it's left out, with a warning. #### Server options: `skillsConfig` The server's `skillsConfig` option controls how skills are served beyond MCP. You don't need it to use skills. | Option | Type | Default | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | `false` | Adds HTTP endpoints for skills: `/llm.txt`, `/llm_full.txt` and `/skills`. See [Serving skills over HTTP](https://frontmcp.dev/reference/sdk/skill#serving-skills-over-http). | | `externalProvider` | `ExternalSkillProviderBase` | none | Initializes and installs an external provider at startup. It must implement the full provider interface. See [External skill storage](https://frontmcp.dev/reference/sdk/skill#external-skill-storage). | | `mcpResources` | `boolean` | `true` | `false` removes every `skill://` resource and template. `skills/search`, `skills/list` and `skills/load` still work. | | `failOnInvalidSkills` | `boolean` | `true` | `false` lets the server start when a skill with `toolValidation: "strict"` names a tool it doesn't have: the server logs an error instead, and serves the skill with the tool in `missingTools`. See [Troubleshooting](https://frontmcp.dev/reference/sdk/skill#skill--failed-tool-validation-missing-tools-). (New in 1.9.2: before, it couldn't be set.) | `skillsConfig` also has options for the HTTP endpoints (`prefix`, `auth`, `apiKeys`, `jwt`, `llmTxt`, `llmFullTxt`, `api`, `cache`), and `injectInstructions`, `sep2640InInstructions`, `scoring` and `audit`, which this page doesn't cover. [Skills over HTTP](https://frontmcp.dev/reference/auth/authorities#skills-over-http) covers who can reach the endpoints. With the default `skillsConfig.auth`, `"inherit"`, the endpoints use the server's `auth`: a server that requires a key or a token for MCP requires the same one for `/skills`, `/llm.txt` and `/llm_full.txt`, and answers `401` without it. A server without `auth`, or in `public` mode, serves them to anyone. A skill with [`authorities`](https://frontmcp.dev/reference/auth/authorities) is left out for a caller its rule refuses, and `/skills/` answers `404` for it. See [Serving skills over HTTP](https://frontmcp.dev/reference/sdk/skill#serving-skills-over-http). Changed in 1.8.3: before, `"inherit"` let anyone read the endpoints, even on a server that required a key for MCP, and `/llm.txt` and `/llm_full.txt` listed skills with `authorities`, instructions and all. To keep the endpoints open on a server with `auth`, set `skillsConfig.auth: "public"`. ### `skill(options)` The function form takes the same options and returns a skill to list in `skills`, like a class: ```ts import { skill } from "@frontmcp/sdk"; export const refundPolicy = skill({ name: "refund-policy", description: "How to answer a customer who asks for a refund.", instructions: "Refunds within 30 days of the charge are automatic. After 30 days, assign the ticket to billing.", }); ``` #### Caveats - `tools` takes tool names or tool classes. A class that isn't a tool stops the server from starting. - A tool is `available` only if it's in the server's `tools/list`. A tool that only an [agent](https://frontmcp.dev/reference/sdk/agent) has counts as missing. - A skill is instructions, not code. Nothing makes the client's model follow them, or stops it calling tools the skill doesn't list. - To serve a REST API as skills, generated from its OpenAPI description, see [Skilled OpenAPI](https://frontmcp.dev/reference/plugins/skilled-openapi). - The class doesn't need to extend anything. Extend `SkillContext` to override `loadInstructions()` or `build()`, which FrontMCP calls once, when the server starts: see [Building a skill in code](https://frontmcp.dev/reference/sdk/skill#building-a-skill-in-code). - To add or remove a skill while the server runs, use `this.scope.skills.registerSkillContent()`: see [Adding a skill at runtime](https://frontmcp.dev/reference/sdk/scope#adding-a-skill-at-runtime). ### `skillDir(dir)` ```ts const triage = await skillDir("/srv/help-desk/skills/triage-ticket"); ``` Reads a skill written as a folder in the [Agent Skills](https://agentskills.io/specification) format: a `SKILL.md`, with YAML frontmatter and then the procedure, and any of the folders `references/`, `examples/`, `scripts/` and `assets/`. It returns a skill to list in an app's `skills`, like a class. `loadSkillDirectory(dir)` is the same function. | Parameter | Type | Description | | --- | --- | --- | | `dir` | `string` | An absolute path, or a relative path resolved against the calling file, then the working directory. Changed in 1.9.5: before, relative paths were resolved from the filesystem root. | | `logger` | `FrontMcpLogger` | Optional. A logger for the [name warning](https://frontmcp.dev/reference/sdk/skill#skill-name--does-not-match-directory-name-), which otherwise goes to the console. | It returns a promise of the skill, a record whose `metadata` holds the options read from `SKILL.md`. Each folder it finds becomes an entry of [`resources`](https://frontmcp.dev/reference/sdk/skill#options). #### How `SKILL.md` maps to options | In `SKILL.md` | Becomes | | --- | --- | | The Markdown after the frontmatter | `instructions`. **Required**: a `SKILL.md` with nothing after its frontmatter is refused. | | `name`, `description` | The options of the same name. **Required.** | | `id`, `license`, `compatibility`, `tags`, `tools`, `parameters`, `examples`, `priority`, `skillPath`, `visibility`, `category`, `rating` | The options of the same name. | | `allowed-tools` | `allowedTools`. | | `metadata` | `specMetadata`. A value that isn't a string is kept as JSON text: `{ max: 5 }` becomes the string `{"max":5}`. | | `hide-from-discovery`, `tool-validation`, `available-when`, and an example's `expected-outcome` | `hideFromDiscovery`, `toolValidation`, `availableWhen` and `expectedOutcome`. The camelCase keys work too. | | Any other key, like `user-invocable: true` | Added to `specMetadata`, as a string, so the served `SKILL.md` has it under `metadata`. | #### What clients read | In the folder | Clients read it at | As | | --- | --- | --- | | `SKILL.md` | `skill:///SKILL.md`, listed in `resources/list` | Rebuilt from the options, like a declared skill's: the frontmatter of [`SKILL.md`](https://frontmcp.dev/reference/sdk/skill#skillmd), without `tags`, then the instructions, then a `## References` table with the `name` and `description` of each file in `references/`. `skills/load` returns the same instructions and table. | | `references/priorities.md` | `skill:///references/priorities.md` | Text, without its frontmatter. | | `examples/acme-login.md` | `skill:///examples/acme-login.md` | Text, without its frontmatter. | | `scripts/check-sla.sh` | `skill:///scripts/check-sla.sh` | Text, as written, typed `application/x-sh`. | | `assets/teams.csv` | `skill:///assets/teams.csv` | A `blob`, base64, typed `application/octet-stream`. | Only `SKILL.md` is in `resources/list`. The other files are read through the `skill://{+skillPath}/{+filePath}` template: a client finds the references in the table, and the rest where the instructions name them. A file that isn't there, or a path with `..`, fails with `Resource not found: skill://…`. `scanSkillResources(dir)` returns what `skillDir()` looks for, without reading `SKILL.md`: `{ resources, hasSkillMd }`, with the path of each of the four folders that exists. It takes an absolute path too. #### Caveats - **`skillDir()` is asynchronous**, and an app's `skills` takes the skill, not a promise. Load the folders before you build the app: see [Usage](https://frontmcp.dev/reference/sdk/skill#loading-a-skill-from-a-skillmd-folder). - **The name is the skill's.** A `name` that isn't the folder's name logs [a warning](https://frontmcp.dev/reference/sdk/skill#skill-name--does-not-match-directory-name-), and the skill is served under its `name`. - **A skill folder can't hold another skill.** A `SKILL.md` in any folder below it is refused: see [Troubleshooting](https://frontmcp.dev/reference/sdk/skill#skill-directory-contains-nested-skillmd-files). - **The files are read from disk while the server runs**, so ship the folders with the server, at the paths `resources` holds. ### External skill storage ```ts class CatalogSkills extends ExternalSkillProviderBase { /* seven methods */ } this.scope.skills.setExternalProvider(new CatalogSkills({ mode: "persistent", syncStateStore })); await this.scope.skills.syncToExternal(); ``` `ExternalSkillProviderBase` connects the server's skills to storage outside it, like a database several servers share. You write the class that talks to the storage; FrontMCP calls it. Set `skillsConfig.externalProvider` on the server to initialize and install it at startup, or call `this.scope.skills.setExternalProvider(provider)` while the server runs. The provider must implement the whole `ExternalSkillProviderBase` interface. | Method to write | Called for | | --- | --- | | `fetchSkill(id)` | `skills/load` of a skill that isn't the server's, in `read-only` mode. Return the skill, or `null`. | | `fetchSkills(options)` | `skills/list`, in `read-only` mode. | | `searchExternal(query, options)` | `skills/search`, in `read-only` mode. Return results with `metadata`, `score`, `availableTools`, `missingTools` and `source: "external"`. | | `upsertSkill(skill)`, `deleteSkill(id)` | `syncToExternal()`, in `persistent` mode, for each skill that's new or changed since the last sync, and each that's gone. Skills added with `registerSkillContent()` are copied too. | | `countExternal(options)`, `existsExternal(id)` | Counting and checking skills in the storage. | Each skill is a `SkillContent`, the same shape [`registerSkillContent()`](https://frontmcp.dev/reference/sdk/scope#skills) takes. | Constructor option | Type | Description | | --- | --- | --- | | `mode` | `"read-only" \| "persistent"` | **Required.** See below. | | `syncStateStore` | `{ load(), save(state), clear() }` | Where `persistent` mode keeps what it copied, with a hash of each skill, so the next sync copies only what changed. Without one, it's kept in the provider: a new provider, as after a restart, copies every skill again. | | `logger`, `defaultTopK`, `defaultMinScore` | | A logger, and the `topK` and `minScore` passed to `searchExternal()`: `10`, unless the request's `limit` says otherwise, and `0.1`. | | Mode | `skills/list`, `skills/search` | `skills/load` | `syncToExternal()` | | --- | --- | --- | --- | | `read-only` | Answered by the storage alone: the server's own skills aren't listed or found. | The server's own skills by id, the others from `fetchSkill()`, with every tool marked available. | Does nothing, and returns `null`. | | `persistent` | The server's own skills, as without storage. | The server's own skills. | Copies the server's skills to the storage, and returns `{ added, updated, unchanged, removed, failed, durationMs }`: lists of ids, `failed` as `{ skillId, error }`, and the time it took. | A skill that comes from the storage has no `SKILL.md` resource, and isn't in `skill://index.json`. [Keeping skills in external storage](https://frontmcp.dev/reference/sdk/skill#keeping-skills-in-external-storage) shows both modes. --- ## Usage ### Serving a skill Register the skill next to the tools it uses. Open the **Tests** tab: Initial call configuration: ```js { resource: "skill://triage-ticket/SKILL.md" } ``` ```ts triage-ticket.skill.ts active import { Skill } from "@frontmcp/sdk"; @Skill({ name: "triage-ticket", description: "Decide a new support ticket's priority.", instructions: `1. Read the ticket with get_ticket. 2. Set its priority with set_priority: high if the customer can't work, normal otherwise.`, tools: ["get_ticket", { name: "set_priority", purpose: "Record the priority", required: true }], parameters: [{ name: "ticketId", description: "The ticket to triage, like T-1", required: true }], }) export class TriageTicket {} ``` ```ts ticket.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in since this morning" }; } } @Tool({ name: "set_priority", description: "Set a ticket's priority.", inputSchema: { id: z.string(), priority: z.enum(["high", "normal"]) }, }) export class SetPriority extends ToolContext { async execute({ id, priority }: { id: string; priority: string }) { return { id, priority }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket, SetPriority } from "./ticket.tools"; import { TriageTicket } from "./triage-ticket.skill"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, SetPriority], skills: [TriageTicket] }) export class HelpDeskApp {} ``` ```ts skill.test.ts import { test, expect } from "@frontmcp/testing"; test("the skill adds no tools", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["get_ticket", "set_priority"]); }); test("it adds the index and its SKILL.md as resources", async ({ mcp }) => { const uris = (await mcp.resources.list()).map((r) => r.uri); expect(uris.sort()).toEqual(["skill://index.json", "skill://triage-ticket/SKILL.md"]); const index = (await mcp.resources.read("skill://index.json")).json(); expect(index.skills[0]).toEqual({ type: "skill-md", name: "triage-ticket", description: "Decide a new support ticket's priority.", url: "skill://triage-ticket/SKILL.md", }); }); test("SKILL.md is frontmatter, then the instructions", async ({ mcp }) => { const text = (await mcp.resources.read("skill://triage-ticket/SKILL.md")).text(); expect(text).toMatch(/^---\nname: triage-ticket\ndescription: Decide a new support ticket's priority\.\ntools:\n/); expect(text).toContain(" - name: set_priority\n purpose: Record the priority\n required: true\n"); expect(text).toMatch(/---\n\n1\. Read the ticket with get_ticket\.\n2\. Set its priority/); }); test("the server's instructions list it", async ({ mcp }) => { const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" })) as any; expect(result.instructions).toBe( "Available skills (read the `skill://index.json` resource for each skill's `SKILL.md` URI):\n\n- **triage-ticket**: Decide a new support ticket's priority.", ); }); ``` The Call tab shows `SKILL.md`, the way a client reads it. A client that supports skills reads `skill://index.json` to see which skills there are, and the `SKILL.md` of one that fits the task. [Teaching the Model Skills](https://frontmcp.dev/learn/teaching-the-model-skills) follows a client through it. A client that doesn't still gives its model the server's `instructions`, which list each skill by name and description, as the last test shows; a server with `instructions` of its own gets the list after them. (Changed in 1.9.3: before, `server/discover` left the list out.) ### Searching and loading skills `skills/search` and `skills/load` are FrontMCP's own requests, for clients that know them: search by words, then load the skills that fit. A test sends them with `mcp.raw.request()`: ```ts help-desk.app.ts active import { App, Skill, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in since this morning" }; } } @Skill({ name: "triage-ticket", description: "Decide a new support ticket's priority and who handles it.", instructions: "1. Read the ticket with get_ticket.\n2. Set its priority with set_priority.\n3. Assign it with assign_ticket.", tools: ["get_ticket", "set_priority", "assign_ticket"], tags: ["support"], }) export class TriageTicket {} @Skill({ name: "refund-duplicate-charge", description: "Refund a customer who was charged twice for the same invoice.", instructions: "1. Read the invoice.\n2. Refund every charge after the first.", tags: ["billing"], }) export class RefundDuplicateCharge {} @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], skills: [TriageTicket, RefundDuplicateCharge] }) export class HelpDeskApp {} ``` ```ts skills.test.ts active import { test, expect } from "@frontmcp/testing"; import { App, Skill, Tool, ToolContext, connect } from "@frontmcp/sdk"; import { GetTicket } from "./help-desk.app"; test("`skills/search` finds skills by their description", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/search", params: { query: "who handles a ticket" } }); expect(response.result).toMatchObject({ skills: [ { id: "triage-ticket", score: expect.any(Number), tags: ["support"], tools: [ { name: "get_ticket", available: true }, { name: "set_priority", available: false }, { name: "assign_ticket", available: false }, ], }, ], total: 1, guidance: "Found 1 matching skill(s). Use skills/load with skill IDs to load full content.", }); }); test("`tags` narrows the search", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/search", params: { query: "customer ticket invoice", tags: ["billing"] } }); expect(response.result.skills.map((s: { id: string }) => s.id)).toEqual(["refund-duplicate-charge"]); }); test("`skills/load` returns the instructions, and which tools are missing", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/load", params: { skillIds: ["triage-ticket"] } }); const [skill] = response.result.skills; expect(skill.instructions).toBe("1. Read the ticket with get_ticket.\n2. Set its priority with set_priority.\n3. Assign it with assign_ticket."); expect(skill).toMatchObject({ availableTools: ["get_ticket"], missingTools: ["set_priority", "assign_ticket"], isComplete: false }); expect(skill.formattedContent).toMatch(/^# Skill: triage-ticket\n/); expect(skill.formattedContent).toContain("### [✓] get_ticket"); expect(skill.formattedContent).toContain("### [✗] set_priority"); expect(response.result.summary.combinedWarnings).toEqual([ 'Skill "triage-ticket" references missing tools: set_priority, assign_ticket. Some functionality may be limited.', ]); }); test("a tool that `surface` keeps from the caller counts as missing", async () => { @Tool({ name: "merge_tickets", description: "Merge duplicate tickets", inputSchema: {}, availableWhen: { surface: ["agent"] } }) class MergeTickets extends ToolContext { async execute() { return { merged: 2 }; } } @Skill({ name: "clean-up-duplicates", description: "Find duplicate tickets and merge them.", instructions: "1. Merge them with merge_tickets.", tools: ["get_ticket", "merge_tickets"] }) class CleanUpDuplicates {} @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, MergeTickets], skills: [CleanUpDuplicates] }) class Desk {} const client = await connect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }); const { skills } = await client.loadSkills(["clean-up-duplicates"]); await client.close(); expect(skills[0]).toMatchObject({ availableTools: ["get_ticket"], missingTools: ["merge_tickets"], isComplete: false }); }); test("a tool class in `tools` stands for the tool's name", async () => { @Tool({ name: "set_priority", description: "Set a ticket's priority", inputSchema: {} }) class SetPriority extends ToolContext { async execute() { return { priority: "high" }; } } @Skill({ name: "triage", description: "Decide a ticket's priority.", instructions: "1. Read the ticket. 2. Set its priority.", tools: [GetTicket, { tool: SetPriority, purpose: "Record the priority" }] }) class Triage {} @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, SetPriority], skills: [Triage] }) class Desk {} const client = await connect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }); const { skills } = await client.loadSkills(["triage"]); await client.close(); expect(skills[0]).toMatchObject({ availableTools: ["get_ticket", "set_priority"], isComplete: true }); }); test("a skill's `SKILL.md` is at its name, and at its id", async () => { @Skill({ name: "triage-ticket", id: "triage", description: "Decide a ticket's priority.", instructions: "1. Read the ticket." }) class Triage {} @App({ id: "help-desk", name: "Help Desk", skills: [Triage] }) class Desk {} const client = await connect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }); const byName = await client.readResource("skill://triage-ticket/SKILL.md"); const byId = await client.readResource("skill://triage/SKILL.md"); await client.close(); expect((byId.contents[0] as { text: string }).text).toBe((byName.contents[0] as { text: string }).text); }); test("an unknown id is a warning, not an error", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/load", params: { skillIds: ["close-ticket"] } }); expect(response.result).toMatchObject({ skills: [], summary: { combinedWarnings: ['Skill "close-ticket" not found'] }, nextSteps: "No skills were loaded. Check the skill IDs and try again.", }); }); ``` `set_priority` and `assign_ticket` aren't tools of this server, so the skill is incomplete: the model is told which steps it can't take. Search results say the same, with `available: false`, so a client can prefer skills it can finish. A tool the caller can't reach because of its `surface` counts as missing the same way, and a skill's `SKILL.md` can be read at its `id` as well as its `name`. Both changed in 1.8.5: before, such a tool was marked available, and only the name worked. `tools` also takes a tool's class, alone or as `{ tool, purpose, required }`, and a class can't fall behind when the tool is renamed. (Changed in 1.9: before, a tool class stopped the server from starting.) ### Hiding a skill `hideFromDiscovery` keeps a skill out of every list, for a skill a client should only load when it already knows the id, such as one that another skill's instructions name. `visibility: "http"` keeps it out of MCP discovery, for the [HTTP endpoints](https://frontmcp.dev/reference/sdk/skill#server-options-skillsconfig): ```ts help-desk.app.ts active import { App, Skill } from "@frontmcp/sdk"; @Skill({ name: "triage-ticket", description: "Decide a new support ticket's priority.", instructions: "1. Read the ticket." }) export class TriageTicket {} @Skill({ name: "write-internal-note", description: "Write a note on a ticket that only support agents see.", instructions: "Start the note with the date, and never paste the customer's password.", hideFromDiscovery: true, }) export class WriteInternalNote {} @Skill({ name: "weekly-report", description: "Summarize the week's tickets for the support team lead.", instructions: "Count the tickets by priority, then list the ones still open.", visibility: "http", }) export class WeeklyReport {} @App({ id: "help-desk", name: "Help Desk", skills: [TriageTicket, WriteInternalNote, WeeklyReport] }) export class HelpDeskApp {} ``` **Hidden fixture/test file (runs with the example):** ```ts hidden.test.ts import { test, expect } from "@frontmcp/testing"; const request = (method: string, params: object) => ({ jsonrpc: "2.0" as const, id: 1, method, params }); test("only the visible skill is a resource", async ({ mcp }) => { const uris = (await mcp.resources.list()).map((r) => r.uri); expect(uris.sort()).toEqual(["skill://index.json", "skill://triage-ticket/SKILL.md"]); const hidden = await mcp.raw.request(request("resources/read", { uri: "skill://write-internal-note/SKILL.md" })); expect(hidden.error).toMatchObject({ code: -32602, message: "Resource not found: skill://write-internal-note/SKILL.md" }); }); test("`skills/list` shows the http skill, and the hidden one only on request", async ({ mcp }) => { const ids = async (params: object) => (await mcp.raw.request(request("skills/list", params))).result.skills.map((s: { id: string }) => s.id); expect(await ids({})).toEqual(["triage-ticket", "weekly-report"]); expect(await ids({ includeHidden: true })).toEqual(["triage-ticket", "weekly-report", "write-internal-note"]); }); test("both still load by id", async ({ mcp }) => { const response = await mcp.raw.request(request("skills/load", { skillIds: ["write-internal-note", "weekly-report"] })); expect(response.result.skills.map((s: { id: string }) => s.id)).toEqual(["write-internal-note", "weekly-report"]); }); ``` Hiding a skill isn't access control: anyone who knows the id can load it. ### Serving skills over HTTP With `skillsConfig: { enabled: true }`, FrontMCP also serves skills at `/skills`, `/llm.txt` and `/llm_full.txt`, for programs that don't speak MCP. By default they take the same credential as the MCP endpoint, and leave out skills the caller's `authorities` refuse. The tests send HTTP requests through [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), the way a client would: Initial call configuration: ```js { resource: "skill://index.json" } ``` ```ts main.ts active import { App, FrontMcp, Skill } from "@frontmcp/sdk"; @Skill({ name: "triage-ticket", description: "Decide a new support ticket's priority.", instructions: "1. Read the ticket. 2. Set its priority." }) export class TriageTicket {} @Skill({ name: "refund-runbook", description: "Refund a customer.", instructions: "1. Check the order. 2. Refund up to 500 EUR.", authorities: "admin", }) export class RefundRunbook {} @App({ id: "help-desk", name: "Help Desk", skills: [TriageTicket, RefundRunbook] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], authorities: { profiles: { admin: { roles: { any: ["admin"] } } } }, skillsConfig: { enabled: true }, }; @FrontMcp(config) export default class Server {} ``` ```ts http.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; // The same server, with a key required for MCP const withKey = { ...config, auth: { mode: "static" as const, tokens: ["desk-key-1"] } }; async function get(server: typeof config, path: string, headers: Record = {}) { const handler = await FrontMcpInstance.createFetchHandler(server); return handler(new Request(`https://desk.example.com${path}`, { headers })); } test("a server without `auth` serves them to anyone", async () => { expect(await (await get(config, "/llm.txt")).text()).toBe("# triage-ticket\nDecide a new support ticket's priority."); }); test("with `auth`, they need the same key as MCP", async () => { const refused = await get(withKey, "/skills"); expect(refused.status).toBe(401); expect(refused.headers.get("www-authenticate")).toBe('Bearer realm="mcp"'); expect((await get(withKey, "/skills", { authorization: "Bearer desk-key-1" })).status).toBe(200); }); test("a skill the caller's `authorities` refuse is left out", async () => { const key = { authorization: "Bearer desk-key-1" }; expect(await (await get(withKey, "/llm_full.txt", key)).text()).not.toContain("Refund up to 500 EUR."); expect((await get(withKey, "/skills/refund-runbook", key)).status).toBe(404); }); test("`auth: \"public\"` opens them on a server with `auth`", async () => { const open = { ...withKey, skillsConfig: { enabled: true, auth: "public" as const } }; expect((await get(open, "/skills")).status).toBe(200); }); ``` A static key carries no roles, so the `admin` skill is left out for it, over HTTP as over MCP. `"api-key"` and `"bearer"` give the endpoints credentials of their own, apart from the server's: [Skills over HTTP](https://frontmcp.dev/reference/auth/authorities#skills-over-http) has every option. ### Giving a skill and an agent the same procedure A skill has the client's model follow a procedure; an agent runs it on your server's model. You can offer both: keep the text in one constant, and give it to the skill as `instructions` and to the agent as `systemInstructions`. The tools go in the app, so the client's model can call them, and in the agent, so its model can: ```ts triage.ts active import { Agent, AgentContext, Skill, z } from "@frontmcp/sdk"; import { model } from "./model.example"; import { GetTicket, SetPriority } from "./ticket.tools"; export const TRIAGE_STEPS = `1. Read the ticket with get_ticket. 2. Set its priority with set_priority: high if the customer can't work, normal otherwise.`; // For clients whose own model should do it @Skill({ name: "triage-ticket", description: "Decide a new support ticket's priority.", instructions: TRIAGE_STEPS, tools: ["get_ticket", "set_priority"], }) export class TriageSkill {} // For clients that would rather hand it off @Agent({ name: "triage", description: "Decide a new support ticket's priority. Pass the ticket's id.", systemInstructions: TRIAGE_STEPS, inputSchema: { ticketId: z.string() }, tools: [GetTicket, SetPriority], llm: { adapter: model }, }) export class TriageAgent extends AgentContext {} ``` ```ts ticket.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in since this morning" }; } } @Tool({ name: "set_priority", description: "Set a ticket's priority.", inputSchema: { id: z.string(), priority: z.enum(["high", "normal"]) }, }) export class SetPriority extends ToolContext { async execute({ id, priority }: { id: string; priority: string }) { return { id, priority }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket, SetPriority } from "./ticket.tools"; import { TriageAgent, TriageSkill } from "./triage"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, SetPriority], skills: [TriageSkill], agents: [TriageAgent], }) export class HelpDeskApp {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. It keeps the // instructions it's given and answers at once. A real server doesn't need this file. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const systems: (string | undefined)[] = []; export const model: AgentLlmAdapter = { async completion(prompt) { systems.push(prompt.system); return { content: "T-1 is high priority.", finishReason: "stop" }; }, }; ``` ```ts both.test.ts import { test, expect } from "@frontmcp/testing"; import { systems } from "./model.example"; import { TRIAGE_STEPS } from "./triage"; test("the client gets the tools, the agent and the skill", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["get_ticket", "invoke_triage", "set_priority"]); const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/load", params: { skillIds: ["triage-ticket"] } }); expect(response.result.skills[0]).toMatchObject({ instructions: TRIAGE_STEPS, isComplete: true }); }); test("the agent's model follows the same steps", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(systems).toEqual([TRIAGE_STEPS]); }); ``` The skill is complete because both tools are in the app. With them only in the agent's `tools`, the client's model would be told they're missing. [Teaching the Model Skills](https://frontmcp.dev/learn/teaching-the-model-skills#skill-agent-or-prompt) compares skills, agents and prompts. ### Writing a skill as a function `skill()` builds the same skill without a class. List what it returns in an app's `skills`: Initial call configuration: ```js { resource: "skill://refund-policy/SKILL.md" } ``` ```ts help-desk.app.ts active import { App, skill } from "@frontmcp/sdk"; export const refundPolicy = skill({ name: "refund-policy", description: "How to answer a customer who asks for a refund.", instructions: "Refunds within 30 days of the charge are automatic. After 30 days, assign the ticket to billing.", }); @App({ id: "help-desk", name: "Help Desk", skills: [refundPolicy] }) export class HelpDeskApp {} ``` ```ts refund.test.ts import { test, expect } from "@frontmcp/testing"; test("it's served like a class", async ({ mcp }) => { const text = (await mcp.resources.read("skill://refund-policy/SKILL.md")).text(); expect(text).toBe( "---\nname: refund-policy\ndescription: How to answer a customer who asks for a refund.\n---\n\n" + "Refunds within 30 days of the charge are automatic. After 30 days, assign the ticket to billing.\n", ); }); ``` ### Building a skill in code A class that extends `SkillContext` can override two methods. `loadInstructions()` returns the instructions, and `super.loadInstructions()` reads the ones `instructions` names. `build()` returns the whole skill, `SkillContent`, and `super.build()` builds it from the options. Both have `this.get()` for the app's providers: Initial call configuration: ```js { resource: "skill://refund-duplicate-charge/SKILL.md" } ``` ```ts refund.skill.ts active import { Skill, SkillContext } from "@frontmcp/sdk"; import type { SkillContent } from "@frontmcp/sdk"; import { RefundPolicy } from "./refund-policy"; export const calls: string[] = []; @Skill({ name: "refund-duplicate-charge", description: "Refund a customer who was charged more than once for the same invoice.", instructions: "1. Read the invoice with get_invoice.\n2. Refund every charge after the first with refund_charge.", tools: ["get_invoice", "refund_charge"], }) export class RefundDuplicateCharge extends SkillContext { async loadInstructions() { calls.push("loadInstructions"); const steps = await super.loadInstructions(); return `${steps}\n3. Refunds take ${this.get(RefundPolicy).businessDays} business days: tell the customer.`; } } @Skill({ name: "escalate-outage", description: "Escalate a ticket that reports an outage.", instructions: "Page the on-call engineer with page_on_call.", }) export class EscalateOutage extends SkillContext { async build(): Promise { calls.push("build"); const content = await super.build(); return { ...content, description: `${content.description} Only for enterprise customers.` }; } } ``` ```ts refund-policy.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "RefundPolicy" }) export class RefundPolicy { businessDays = "3 to 5"; } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { EscalateOutage, RefundDuplicateCharge } from "./refund.skill"; import { RefundPolicy } from "./refund-policy"; @App({ id: "help-desk", name: "Help Desk", skills: [RefundDuplicateCharge, EscalateOutage], providers: [RefundPolicy] }) export class HelpDeskApp {} ``` ```ts build.test.ts import { test, expect } from "@frontmcp/testing"; import { calls } from "./refund.skill"; test("`loadInstructions()` adds a step from a provider", async ({ mcp }) => { const text = (await mcp.resources.read("skill://refund-duplicate-charge/SKILL.md")).text(); expect(text).toContain("2. Refund every charge after the first with refund_charge.\n3. Refunds take 3 to 5 business days: tell the customer.\n"); }); const built = "Escalate a ticket that reports an outage. Only for enterprise customers."; test("`build()` changes what `SKILL.md` and `skills/load` say", async ({ mcp }) => { const text = (await mcp.resources.read("skill://escalate-outage/SKILL.md")).text(); expect(text).toContain(`description: ${built}\n`); const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/load", params: { skillIds: ["escalate-outage"] } })) as any; expect(result.skills[0].description).toBe(built); }); test("and every list of skills: the index, `resources/list` and the server's instructions", async ({ mcp }) => { const index = (await mcp.resources.read("skill://index.json")).json(); expect(index.skills.find((s: { name: string }) => s.name === "escalate-outage").description).toBe(built); const resources = await mcp.resources.list(); expect(resources.find((r: { uri: string }) => r.uri === "skill://escalate-outage/SKILL.md")?.description).toBe(built); const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" })) as any; expect(result.instructions).toContain(`- **escalate-outage**: ${built}`); }); test("each runs once, when the server starts", async ({ mcp }) => { expect(calls).toEqual(["loadInstructions", "build"]); await mcp.resources.read("skill://refund-duplicate-charge/SKILL.md"); await mcp.resources.read("skill://escalate-outage/SKILL.md"); expect(calls).toEqual(["loadInstructions", "build"]); }); ``` FrontMCP creates the class once, calls the methods when the server starts, and keeps what they return, as it keeps instructions read from a file or a URL. So `this.get()` reaches the app's and the server's providers, but there's no request, and nothing about the caller: every client reads the same skill. (Changed in 1.9.2: before, FrontMCP never created the class, and overrides had no effect.) A `description` that `build()` returns is the skill's description everywhere: in `SKILL.md`, `skills/load`, `skills/search`, `skill://index.json`, `resources/list` and the skill list in the server's instructions. (Changed in 1.9.3: before, `skill://index.json`, `resources/list` and the skill list in `initialize`'s instructions kept the option's description.) ### Loading a skill from a SKILL.md folder A skill written for other agents is often a folder: a `SKILL.md`, and the files its steps refer to. `skillDir()` serves it as it is. Here `skills/triage-ticket/` holds a `SKILL.md`, one file in `references/`, one in `examples/`, a script and a spreadsheet: ```md skills/triage-ticket/SKILL.md --- name: triage-ticket description: Decide a new support ticket's priority and who handles it. license: Apache-2.0 allowed-tools: get_ticket set_priority metadata: owner: support-team tags: - support tools: - get_ticket - name: set_priority purpose: Record the priority parameters: - name: ticketId description: The ticket to triage, like T-1 required: true --- 1. Read the ticket with get_ticket. 2. Pick the priority as references/priorities.md says. 3. Set it with set_priority. ``` ```md skills/triage-ticket/references/priorities.md --- name: priorities description: How to pick a ticket's priority --- - high: the customer can't work. - normal: everything else. ``` `skillDir()` returns a promise, so load the folder before you build the app, and give it a path to the folder: ```ts main.ts import "reflect-metadata"; import { resolve } from "node:path"; import { App, FrontMcpInstance, skillDir } from "@frontmcp/sdk"; import { GetTicket, SetPriority } from "./ticket.tools"; async function main() { // Resolve from the working directory explicitly const triage = await skillDir(resolve("skills/triage-ticket")); @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, SetPriority], skills: [triage] }) class HelpDeskApp {} await FrontMcpInstance.bootstrap({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); } main(); ``` `resources/list` has `skill://index.json` and `skill://triage-ticket/SKILL.md`, as for a declared skill. A client that reads that `SKILL.md` gets the frontmatter rebuilt from the options, without `tags`, the steps, and a table of the references: ```md --- name: triage-ticket description: Decide a new support ticket's priority and who handles it. license: Apache-2.0 allowed-tools: get_ticket set_priority metadata: owner: support-team tools: - get_ticket - name: set_priority purpose: Record the priority parameters: - name: ticketId description: The ticket to triage, like T-1 required: true type: string --- 1. Read the ticket with get_ticket. 2. Pick the priority as references/priorities.md says. 3. Set it with set_priority. ## References | Reference | Description | | --------- | ----------- | | `priorities` | How to pick a ticket's priority | ``` Then it reads `skill://triage-ticket/references/priorities.md`, and gets the two lines without their frontmatter. `skill://triage-ticket/scripts/check-sla.sh` comes back as text, `skill://triage-ticket/assets/teams.csv` as a base64 `blob`, and `skill://triage-ticket/references/escalation.md`, which isn't in the folder, fails with `Resource not found`. `skills/load` returns the same steps and table, with both tools available. The Playground has no file system, so this was run in Node, with a client sending each request over HTTP. ### Keeping skills in external storage A help desk run as several servers can keep its skills in one catalog that each server reads, or copy each server's skills to it. Here the catalog is a `Map`, standing in for a database, and `sync_skill_catalog` copies the server's skills to it. The tests also run the other mode, where the catalog answers the skills requests: Initial call configuration: ```js { tool: "sync_skill_catalog", arguments: {} } ``` ```ts sync-skill-catalog.tool.ts active import { SkillRegistry, Tool, ToolContext } from "@frontmcp/sdk"; import { CatalogSkills, syncState } from "./skill-catalog"; @Tool({ name: "sync_skill_catalog", description: "Copy this server's skills to the team's shared skill catalog", inputSchema: {} }) export class SyncSkillCatalog extends ToolContext { async execute() { const skills = this.scope.skills as SkillRegistry; if (!skills.hasExternalProvider()) { skills.setExternalProvider(new CatalogSkills({ mode: "persistent", syncStateStore: syncState })); } const result = await skills.syncToExternal(); return { added: result?.added, updated: result?.updated, unchanged: result?.unchanged, removed: result?.removed }; } } ``` ```ts skill-catalog.ts import { ExternalSkillProviderBase } from "@frontmcp/sdk"; import type { SkillContent, SkillSearchResult, SkillSyncState, SkillSyncStateStore } from "@frontmcp/sdk"; // Stands in for a database or a service that several servers share export const catalog = new Map([ [ "refund-duplicate-charge", { id: "refund-duplicate-charge", name: "refund-duplicate-charge", description: "Refund a customer who was charged twice for the same invoice.", instructions: "1. Read the invoice with get_invoice.\n2. Refund every charge after the first.", tools: [{ name: "get_invoice" }], }, ], ]); export class CatalogSkills extends ExternalSkillProviderBase { protected async fetchSkill(id: string) { return catalog.get(id) ?? null; } protected async fetchSkills() { return [...catalog.values()]; } protected async searchExternal(query: string): Promise { const words = query.toLowerCase().split(" "); return [...catalog.values()] .filter((skill) => words.some((word) => skill.description.toLowerCase().includes(word))) .map((skill) => ({ metadata: { id: skill.id, name: skill.name, description: skill.description, instructions: skill.instructions }, score: 1, availableTools: skill.tools.map((tool) => tool.name), missingTools: [], source: "external" as const, })); } protected async upsertSkill(skill: SkillContent) { catalog.set(skill.id, skill); } protected async deleteSkill(id: string) { catalog.delete(id); } protected async countExternal() { return catalog.size; } protected async existsExternal(id: string) { return catalog.has(id); } } // Remembers what was copied, and each skill's hash, between syncs let saved: SkillSyncState | null = null; export const syncState: SkillSyncStateStore = { load: async () => saved, save: async (state) => { saved = state; }, clear: async () => { saved = null; }, }; ``` ```ts help-desk.app.ts import { App, Skill, Tool, ToolContext, z } from "@frontmcp/sdk"; import { SyncSkillCatalog } from "./sync-skill-catalog.tool"; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in since this morning" }; } } @Skill({ name: "triage-ticket", description: "Decide a new support ticket's priority.", instructions: "1. Read the ticket with get_ticket.", tools: ["get_ticket"] }) export class TriageTicket {} @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, SyncSkillCatalog], skills: [TriageTicket] }) export class HelpDeskApp {} ``` ```ts catalog.test.ts import { test, expect } from "@frontmcp/testing"; import { SkillRegistry, Tool, ToolContext, create } from "@frontmcp/sdk"; import { catalog, CatalogSkills } from "./skill-catalog"; import { GetTicket, TriageTicket } from "./help-desk.app"; const request = (method: string, params: object) => ({ jsonrpc: "2.0" as const, id: 1, method, params }); test("read-only: the catalog answers skills/list and skills/search", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [GetTicket], skills: [TriageTicket], skillsConfig: { externalProvider: new CatalogSkills({ mode: "read-only" }) }, }); const client = await server.connect(); try { expect((await client.listSkills()).skills.map((s: { id: string }) => s.id)).toEqual(["refund-duplicate-charge"]); expect((await client.searchSkills("refund")).skills.map((s: { id: string; source: string }) => [s.id, s.source])).toEqual([["refund-duplicate-charge", "external"]]); const { skills } = await client.loadSkills(["refund-duplicate-charge", "triage-ticket"]); expect(skills.map((s: { id: string; availableTools: string[] }) => [s.id, s.availableTools])).toEqual([ ["refund-duplicate-charge", ["get_invoice"]], ["triage-ticket", ["get_ticket"]], ]); expect(JSON.stringify(await client.readResource("skill://refund-duplicate-charge/SKILL.md"))).toContain("refund-duplicate-charge"); } finally { await client.close(); await server.dispose(); } }); test("the first sync copies the skill; the next finds it unchanged", async ({ mcp }) => { expect((await mcp.tools.call("sync_skill_catalog", {})).json()).toEqual({ added: ["triage-ticket"], updated: [], unchanged: [], removed: [] }); expect((await mcp.tools.call("sync_skill_catalog", {})).json()).toEqual({ added: [], updated: [], unchanged: ["triage-ticket"], removed: [] }); expect(catalog.get("triage-ticket")?.instructions).toBe("1. Read the ticket with get_ticket."); }); test("persistent: clients still get the server's own skills", async ({ mcp }) => { const { result } = await mcp.raw.request(request("skills/list", {})); expect(result.skills.map((s: { id: string }) => s.id)).toEqual(["triage-ticket"]); }); ``` `persistent` mode leaves the server's skills as they were and copies them out: the first sync adds `triage-ticket`, the next finds it unchanged, because the sync state holds a hash of each skill. In `read-only` mode the catalog takes over `skills/list` and `skills/search`, so `triage-ticket` isn't listed, though it still loads by id. A skill from the catalog loads with every tool marked available, even `get_invoice`, which this server doesn't have, and has a readable `SKILL.md`, also linked from `skill://index.json`. Changed in 1.9.5: before, external read-only skills had neither. ### Connecting for skills only A planner agent that only chooses which skills to run doesn't need the tools in its context. A client that opens its session with `?mode=skills_only` on the URL of its `initialize` request gets an empty `tools/list`: | Request, in skills-only mode | Answer | | --- | --- | | `tools/list` | `{ "tools": [] }` | | `tools/call` | Refused with `Tool "" not found`, including Skilled OpenAPI meta-tools. | | `resources/list` | `skill://index.json` and each skill's `SKILL.md`, as without the mode. | | `skills/search`, `skills/list`, `skills/load` | As without the mode. `skills/load` still marks the tools available. | The mode works on every transport and protocol revision, for anonymous callers too. A client before MCP 2026-07-28 sets it on the URL of its `initialize` request, and the session keeps it. Under 2026-07-28, which has no sessions, each request sets it on its own URL, as the tests below do with [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), which runs this Playground. A tool's own [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool) still runs other tools. Changed in 1.9.5: before, the mode only hid `tools/list`, only in a session whose caller had a JWT the server verified, and `tools/call` still ran tools. Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts help-desk.app.ts active import { App, Skill, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Read a support ticket", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in" }; } } @Skill({ name: "triage-ticket", description: "Decide a new support ticket's priority.", instructions: "1. Read the ticket with get_ticket.\n2. Say whether the customer can work.", tools: ["get_ticket"], }) export class TriageTicket {} @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], skills: [TriageTicket] }) export class HelpDeskApp {} ``` ```ts skills-only.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; const server = FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); /** A 2026-07-28 request, which names the mode on its own URL. */ async function send(url: string, method: string, params: Record = {}) { const handler = await server; const response = await handler( new Request(url, { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(typeof params.name === "string" ? { "mcp-name": params.name } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }), }), ); return (await response.json()).result; } const skillsOnly = "https://desk.example.com/?mode=skills_only"; test("the request lists no tools, and still lists the skills", async () => { expect((await send(skillsOnly, "tools/list")).tools).toEqual([]); expect((await send(skillsOnly, "skills/list")).skills.map((s: { id: string }) => s.id)).toEqual(["triage-ticket"]); expect((await send("https://desk.example.com/", "tools/list")).tools.map((t: { name: string }) => t.name)).toEqual(["get_ticket"]); }); test("a call is answered as for an unknown tool", async () => { const result = await send(skillsOnly, "tools/call", { name: "get_ticket", arguments: { id: "T-1" } }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe('Tool "get_ticket" not found'); }); ``` --- ## Troubleshooting ### `Skill '…' failed tool validation: missing tools […]` The server doesn't start, because a skill with `toolValidation: "strict"` names a tool that isn't in `tools/list`. Add the tool to an app's `tools`, fix the name, or leave `toolValidation` at `"warn"`, which logs a warning and serves the skill with the tool marked `available: false`. To let the server start while keeping `"strict"` on the skill, set `skillsConfig: { failOnInvalidSkills: false }` on the server: it logs an error instead. Changed in 1.9: before, `"strict"` didn't stop the server. ```ts help-desk.app.ts active import { App, Skill, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDeskApp {} ``` ```ts strict.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance, Skill, SkillValidationError } from "@frontmcp/sdk"; import { GetTicket } from "./help-desk.app"; const start = (toolValidation: "strict" | "warn", skillsConfig = {}) => { @Skill({ name: "audit-tickets", description: "Check a day's tickets.", instructions: "1. Read each ticket. 2. Close the stale ones.", tools: ["get_ticket", "close_ticket"], // the server has no close_ticket toolValidation, }) class AuditTickets {} @App({ id: "desk", name: "Desk", tools: [GetTicket], skills: [AuditTickets] }) class Desk {} return FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk], skillsConfig }); }; test('`toolValidation: "strict"` stops a server that lacks a tool', async () => { const failure = start("strict"); await expect(failure).rejects.toThrow("Skill 'audit-tickets' failed tool validation: missing tools [close_ticket]"); await expect(failure).rejects.toBeInstanceOf(SkillValidationError); }); test('`"warn"` starts it', async () => { const server = await start("warn"); await server.dispose(); }); test("so does `failOnInvalidSkills: false`, with the tool missing", async () => { const server = await start("strict", { failOnInvalidSkills: false }); try { const { contents } = await server.readResource("skill://audit-tickets/SKILL.md"); expect(contents[0].text).toContain(" - close_ticket\n"); } finally { await server.dispose(); } }); ``` ### `Invalid tool class '…'. Tool class must be decorated with @Tool and have a name property.` The server doesn't start, because `tools` lists a class that isn't a tool: it has no `@Tool`. List the tool's class, or its name. Before 1.9, every class failed this way, even a tool's, with an empty name: `Invalid tool class ''`. ### `Skill name must be kebab-case` The server doesn't start, because a skill's `name` has uppercase letters, underscores or spaces, or starts or ends with a hyphen. Use lowercase words joined by hyphens, like `triage-ticket`. ### `skills/load` fails with `ENOENT` or `fetch failed` The skill's instructions come from a file or a URL that couldn't be read. FrontMCP reads them when the server starts, but a failure doesn't stop it: the skill is left out of `skills/list`, and each request that needs it tries again, so `skills/load` fails with JSON-RPC error `-32603` and `ENOENT: no such file or directory, open '…'`, `fetch failed`, or, for a URL that answers with an error, `Failed to fetch skill instructions from "…": 404`, and reading its `SKILL.md` fails too. A relative `file` path is relative to the file that declares the skill, not to where the server runs. Check the path, or that the server can reach the URL. The Playground can do neither. ### `Resource not found: skill://…/SKILL.md` The client read a `SKILL.md` that isn't served. Check, in order: 1. The skill is in an app's `skills`, and the app is in `@FrontMcp({ apps })`. 2. The URI uses the skill's `name`, or its `skillPath` joined with `/`, not its `id`. 3. The skill doesn't set `hideFromDiscovery: true` or `visibility: "http"`, and its `availableWhen` matches where the server runs. Such skills have no resource. See [Hiding a skill](https://frontmcp.dev/reference/sdk/skill#hiding-a-skill). 4. The server doesn't set `skillsConfig: { mcpResources: false }`. ### A tool shows as `available: false` `skills/load` marks a tool available only if it's in the server's `tools/list`. Check the name as the tool spells it, and that the tool is in an app's `tools`: a tool that only an agent has doesn't count. A missing tool doesn't stop the server unless the skill sets `toolValidation: "strict"`: see [`Skill '…' failed tool validation`](https://frontmcp.dev/reference/sdk/skill#skill--failed-tool-validation-missing-tools-). ### A skill isn't listed, and can't be loaded The skill has an `availableWhen` that doesn't match where the server runs, or a `surface` that leaves out `"mcp"`. Either way, FrontMCP doesn't serve it to MCP clients: it's missing from `skills/list`, `skills/search` and `resources/list`, `skills/load` warns `Skill "…" not found`, and reading its `SKILL.md` fails with `-32602`. ```ts help-desk.app.ts active import { App, Skill } from "@frontmcp/sdk"; @Skill({ name: "triage-ticket", description: "Decide a new support ticket's priority.", instructions: "1. Read the ticket." }) export class TriageTicket {} // 🚩 This server doesn't run on Deno @Skill({ name: "restart-importer", description: "Restart the stuck ticket importer.", instructions: "1. Stop the importer. 2. Start it again.", availableWhen: { runtime: ["deno"] }, }) export class RestartImporter {} // For the server's own agents, not for MCP clients @Skill({ name: "merge-duplicates", description: "Merge duplicate tickets into one.", instructions: "1. Keep the oldest ticket. 2. Move the replies. 3. Close the others.", availableWhen: { surface: ["agent"] }, }) export class MergeDuplicates {} @App({ id: "help-desk", name: "Help Desk", skills: [TriageTicket, RestartImporter, MergeDuplicates] }) export class HelpDeskApp {} ``` ```ts unavailable.test.ts import { test, expect } from "@frontmcp/testing"; const request = (method: string, params: object) => ({ jsonrpc: "2.0" as const, id: 1, method, params }); const ids = (response: { result: { skills: { id: string }[] } }) => response.result.skills.map((s) => s.id); test("only the skill without `availableWhen` is listed", async ({ mcp }) => { expect(ids(await mcp.raw.request(request("skills/list", {})))).toEqual(["triage-ticket"]); const uris = (await mcp.raw.request(request("resources/list", {}))).result.resources.map((r: { uri: string }) => r.uri); expect(uris).toEqual(["skill://index.json", "skill://triage-ticket/SKILL.md"]); }); test("🚩 the Deno skill isn't found, loaded or read", async ({ mcp }) => { expect(ids(await mcp.raw.request(request("skills/search", { query: "restart the importer" })))).toEqual([]); const loaded = await mcp.raw.request(request("skills/load", { skillIds: ["restart-importer"] })); expect(loaded.result.summary.combinedWarnings).toEqual(['Skill "restart-importer" not found']); const read = await mcp.raw.request(request("resources/read", { uri: "skill://restart-importer/SKILL.md" })); expect(read.error).toMatchObject({ code: -32602 }); }); test("the agent-only skill isn't either", async ({ mcp }) => { expect(ids(await mcp.raw.request(request("skills/search", { query: "merge duplicate tickets" })))).toEqual([]); const loaded = await mcp.raw.request(request("skills/load", { skillIds: ["merge-duplicates"] })); expect(loaded.result.summary.combinedWarnings).toEqual(['Skill "merge-duplicates" not found']); const read = await mcp.raw.request(request("resources/read", { uri: "skill://merge-duplicates/SKILL.md" })); expect(read.error).toMatchObject({ code: -32602 }); }); ``` Remove `availableWhen`, or change it to match where the server runs, to offer the skill everywhere. Changed in 1.8.5: before, a skill excluded by where the server runs was still in `skills/list` and `skills/search`, and one hidden by `surface` still had its `SKILL.md` in `resources/list`. ### `Invalid skill "…": SKILL.md not found in directory` `skillDir()` found no `SKILL.md` in the folder it was given. Relative paths resolve against the calling file, then the working directory. Check that the folder has a `SKILL.md`. Changed in 1.9.5: before, relative paths were looked up from the filesystem root. The other errors `skillDir()` throws name what's missing, like `Invalid skill "triage-ticket": SKILL.md is missing required 'description' field in frontmatter`, or `SKILL.md has no body content for instructions` for a file with nothing after its frontmatter. ### `Skill name "…" does not match directory name "…"` A warning from `skillDir()`: the `name` in `SKILL.md` isn't the folder's name. The skill loads anyway, under its `name`: a folder `refund-policy/` whose `SKILL.md` says `name: refunds` is served at `skill://refunds/SKILL.md`. Rename one of them, as the Agent Skills format expects them to match. ### Skill directory contains nested SKILL.md files `skillDir()` refused the folder because a folder inside it holds a `SKILL.md` too: `Invalid skill "triage-ticket": Skill directory contains nested SKILL.md files (forbidden by SEP-2640 §Resource Mapping): references/escalation/SKILL.md`. A skill's files are served under its `skill://` path, so a skill inside another would make the same URI mean two things. Move the inner skill to a folder of its own, beside the other, and load each with `skillDir()`. ### `?mode=skills_only` still lists every tool For session protocols, put `?mode=skills_only` on the `initialize` URL. Under MCP 2026-07-28, put it on each request. Changed in 1.9.5: the mode works with every auth mode and with `createFetchHandler()`; before, those cases could still list tools. See [Connecting for skills only](https://frontmcp.dev/reference/sdk/skill#connecting-for-skills-only). --- # @Plugin > Package providers, tools, resources, prompts, skills and hooks into a plugin that an app or a whole server turns on with one line. Source: https://frontmcp.dev/reference/sdk/plugin [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `@Plugin` declares a plugin: behaviour that belongs around every tool rather than in one, packaged with the providers, tools, resources, prompts and skills it needs. Its class holds [hooks](https://frontmcp.dev/reference/sdk/hooks), methods that run at points in every call. An app turns it on by listing it in `plugins`, and a server does the same for every app. [Extending with Plugins](https://frontmcp.dev/learn/extending-with-plugins) builds one step by step. ```ts @Plugin(options) class MyPlugin extends DynamicPlugin { @ToolHook.Did("execute") async afterEveryTool(ctx: FlowCtxOf<"tools:call-tool">) { /* ... */ } } ``` --- ## Reference ### `@Plugin(options)` Apply `@Plugin` to a class, usually one that extends `DynamicPlugin`, and list the class in the `plugins` array of an `@App` or of `@FrontMcp`. ```ts usage.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; import { UsageLog } from "./usage-log"; import { UsageReport } from "./usage-report.tool"; @Plugin({ name: "usage", description: "Counts calls to every tool", providers: [UsageLog], exports: [UsageLog], tools: [UsageReport], }) export class UsagePlugin extends DynamicPlugin { @ToolHook.Did("execute") async count(ctx: FlowCtxOf<"tools:call-tool">) { const name = ctx.state.tool?.metadata.name; if (name) this.get(UsageLog).add(name); } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { UsagePlugin } from "./usage.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, CloseTicket], plugins: [UsagePlugin] }) export class HelpDeskApp {} ``` [See more examples below.](https://frontmcp.dev/reference/sdk/plugin#usage) #### Options Required: | Option | Type | Description | | --- | --- | --- | | `name` | `string` | Names the plugin in logs and error messages, like the default message of a [context extension](https://frontmcp.dev/reference/sdk/plugin#contextextensions). | Optional: | Option | Type | Description | | --- | --- | --- | | `providers` | `ProviderType[]` | [Providers](https://frontmcp.dev/reference/sdk/provider) for the plugin's own use: its hooks and its tools, resources and prompts. The app's tools can't get them unless they're in `exports`. | | `exports` | `ProviderType[]` | Which of `providers` the app's own tools may get too, with `this.get()`. On a server-level plugin, every app's tools. See [sharing a provider](https://frontmcp.dev/reference/sdk/plugin#sharing-a-provider-with-the-apps-tools). | | `tools`, `resources`, `prompts`, `skills` | as on [`@App`](https://frontmcp.dev/reference/sdk/app) | Added to the app, and listed to clients like the app's own. A plugin on `@FrontMcp` adds them once for the server. See [adding tools, resources, prompts and skills](https://frontmcp.dev/reference/sdk/plugin#adding-tools-resources-prompts-and-skills). | | `contextExtensions` | `ContextExtension[]` | Properties added to `this` in every tool, resource and prompt, like `this.usageLog`. See [`contextExtensions`](https://frontmcp.dev/reference/sdk/plugin#contextextensions). | | `plugins` | `PluginType[]` | Other plugins this one brings along. See [bringing other plugins along](https://frontmcp.dev/reference/sdk/plugin#bringing-other-plugins-along). | | `adapters` | `AdapterType[]` | Adapters the plugin brings along, which generate tools from another source, such as an OpenAPI description. See the [OpenAPI adapter](https://frontmcp.dev/reference/adapters/openapi). | | `description` | `string` | For people reading the code. Clients never see it. | | `enforcesMetadata` | `string[]` | Options this plugin's hooks enforce on tools, resources, prompts, agents and skills, like `["approval"]`. A server where an entry sets one of them and no hook of such a plugin covers the entry doesn't start. See [enforcing an option of your own](https://frontmcp.dev/reference/sdk/plugin#enforcing-an-option-of-your-own). | | `dynamicSkills` | `boolean` | The plugin adds skills after the server starts, as [Skilled OpenAPI](https://frontmcp.dev/reference/plugins/skilled-openapi) does from the bundle it loads. The server then answers `skills/list`, `skills/search` and `skills/load`, and lists `skill://index.json`, from startup, with the skills added so far. Without it, a server with no other skills answers `skills/list` with `-32601` until then. New in 1.8.5. | | `scope` | `"app" \| "server"` | `"server"` runs an app plugin's hooks for every app's entries. It fails in a standalone app. See [`scope`](https://frontmcp.dev/reference/sdk/plugin#scope). | | `id` | `string` | Accepted and unused. | Keys `@Plugin` doesn't know are kept without an error, so check the spelling of an option that seems to do nothing. #### The plugin class The class body holds the plugin's [hooks](https://frontmcp.dev/reference/sdk/hooks): methods marked with a hook decorator, like `@ToolHook.Did("execute")`, that FrontMCP calls at a stage of a request. Inside a hook, `this` is the plugin instance. Hook methods on the plugin's `providers` run too, like hooks on the class itself, as long as the provider has the default `GLOBAL` scope. A plugin class doesn't have to extend anything: `@Plugin` on a plain class works, and its hooks run. Extending `DynamicPlugin` adds a typed `this.get()` and a typed `init()`. #### `DynamicPlugin` The base class for plugins, exported by `@frontmcp/sdk`. - `Options` is the type the plugin works with. Use `object` for a plugin without options: `DynamicPlugin` needs at least one type argument. - `Input` is what `init()` accepts, and defaults to `Options`. Give it optional fields when your constructor fills in defaults. FrontMCP doesn't convert one to the other: the constructor receives what was passed to `init()`. | Member | Description | | --- | --- | | `static init(options?)` | Returns a plugin record to put in `plugins` instead of the class: `plugins: [UsagePlugin.init({ ignore: ["usage_report"] })]`. Calls the constructor with `options` right away, when your module is imported. Without an argument, the options are `{}`. | | `static init({ inject, useFactory })` | Builds the options when the server starts: `useFactory` gets the providers `inject` returns, from the app, and returns the options, or a promise of them (since 1.9.1). FrontMCP then calls the constructor with them. See [options from a provider](https://frontmcp.dev/reference/sdk/plugin#taking-options). | | `init({ ..., providers })` | Either form also takes `providers`: more providers for the plugin, added next to the ones `dynamicProviders()` returns. They reach the constructor inside `options` too. | | `static dynamicProviders(options)` | Optional. Define it to add providers built from the options, like a client configured with a URL. Called by `init()`, and with `{}` for a plugin listed as its class (since 1.9.4). See [providers built from options](https://frontmcp.dev/reference/sdk/plugin#providers-built-from-options). | | `static dynamicTools(options)` | Optional. Define it to add tools that depend on the options, like a set that's only there when an option turns it on, or named under a prefix. Called by `init()` with the options, from an object or from `useFactory`, and with `{}` for a plugin listed as its class. See [an option named like a list](https://frontmcp.dev/reference/sdk/plugin#taking-options). New in 1.8.7. (Changed in 1.9: before, the `useFactory` form didn't call it. Changed in 1.9.4: before, the class form didn't.) | | `this.get(token)` | Gets a provider: the plugin's own, an export of a plugin nested in it, or one of the app's. It works once the server has started, in hooks: in the constructor it throws `DynamicPlugin.get() is not implemented`. | `plugins: [UsagePlugin]` still works for a plugin that extends `DynamicPlugin`: FrontMCP builds it as `UsagePlugin.init()` does, so the constructor, `dynamicProviders()` and `dynamicTools()` each get `{}`. Fill in an option's default from that object, not with a default parameter, which `{}` doesn't trigger. (Changed in 1.9.4: before, the constructor got no argument, and `dynamicProviders()` and `dynamicTools()` weren't called, so a plugin whose tools or providers come from them had none.) #### Where you register a plugin | Registered in | Its tools, resources, prompts, skills | Its `exports` | Its hooks on `tools/call`, `resources/read`, `prompts/get` and [job attempts](https://frontmcp.dev/reference/sdk/hooks#hooking-a-jobs-attempts) | Its hooks on list requests and `HttpHook` | | --- | --- | --- | --- | --- | | `@App({ plugins })` | Added to that app | That app's tools | Only for that app's entries and jobs, including the ones the plugin adds, and for calls to tool names no app has. Every app's, with [`scope: "server"`](https://frontmcp.dev/reference/sdk/plugin#scope) | Every request, whichever app it's about | | `@FrontMcp({ plugins })` | Added once, for the server | Every app's tools | Every app's entries | Every request | | `@Plugin({ plugins })` | Added to the app the outer plugin is on | The outer plugin only | As for the outer plugin | Every request | | `@Agent({ plugins })` | Its tools join the agent's, for the agent's model only | The agent's tools | Only for the tools the agent's model calls, and its reads of the agent's resources and prompts (since 1.9.4) | Only for the lists the agent's model reads, not clients' | A hook with [`appliesTo: "uncovered-apps"`](https://frontmcp.dev/reference/sdk/hooks#options) on an app's plugin also runs for the entries of apps that have no instance of that hook: see [enforcing an option of your own](https://frontmcp.dev/reference/sdk/plugin#enforcing-an-option-of-your-own). [Putting a plugin on an agent](https://frontmcp.dev/reference/sdk/plugin#putting-a-plugin-on-an-agent) shows the last row. #### `contextExtensions` Each entry adds a property to `this` in every tool, resource and prompt, so tools write `this.usageLog` instead of `this.get(USAGE_LOG)`. | Field | Type | Description | | --- | --- | --- | | `property` | `string` | **Required.** The property name, like `"usageLog"`. | | `token` | `Token` | **Required.** The provider to return. It must be in the plugin's `exports`, or reading the property fails even in the plugin's own app. | | `errorMessage` | `string` | The message when the property can't be resolved. Defaults to ` is not installed or '' is not configured.` | - TypeScript doesn't know about the property until you declare it, with `declare module "@frontmcp/sdk" { interface ExecutionContextBase { readonly usageLog: UsageLog } }`. - FrontMCP installs the property once per process, on the base class of every context, and the first plugin to claim a name keeps it. So the property exists in apps that don't have the plugin as well, and reading it there throws `ContextExtensionNotAvailableError` (code `CONTEXT_EXTENSION_NOT_AVAILABLE`) with `errorMessage`. #### `scope` `scope: "server"` registers the hooks of a plugin on one app at server level, for every app: - On an app served on the main endpoint (the default), its `tools/call`, `resources/read` and `prompts/get` hooks run for every app's entries, as if the plugin were on `@FrontMcp`. [Registering a plugin on every app](https://frontmcp.dev/reference/sdk/plugin#registering-a-plugin-on-every-app) shows it. - On a `standalone: true` app, the server doesn't start: `Plugin "…" has scope='server' but is used in a standalone app. Server-scoped plugins can only be used in non-standalone apps.` Changed in 1.9.1: before, `scope: "server"` changed nothing, and the hooks ran only for that app's entries. To run a plugin's hooks for every app, registering it in `@FrontMcp({ plugins })` works too. For a hook that gates entries by an option they set, [`appliesTo: "uncovered-apps"`](https://frontmcp.dev/reference/sdk/plugin#enforcing-an-option-of-your-own) extends it to the apps that don't have the plugin. #### Caveats - A plugin's providers are **private** to it, unless they're in `exports`. - Providers from `init({ providers })` and from `dynamicProviders()` are the exception: the tools of the app the plugin is on can get them without `exports`. Other apps' tools can't (since 1.9; before, every app's could). - Hooks on a plugin in `@App({ plugins })` that run on list requests (`tools/list` and the rest) or on HTTP requests **see every app**: an app's `ListToolsHook` sees and can filter the other apps' tools too. To filter only the entries its own call hooks judge, check each one with `isEntryGatedBy()`: see [hiding what a plugin refuses](https://frontmcp.dev/reference/sdk/plugin#hiding-what-a-plugin-refuses). - `init(options)` **constructs the plugin when your module is imported**, not when the server starts. Use the `useFactory` form for options that need a provider or the environment at startup. - `DynamicPlugin` needs a type argument. `extends DynamicPlugin` alone fails with `TS2707` (see [Troubleshooting](https://frontmcp.dev/reference/sdk/plugin#typescript-says-generic-type-dynamicplugintoptions-tinput-requires-between-1-and-2-type-arguments)). - `id` has no effect. --- ## Usage ### Writing a plugin This plugin counts calls to every tool of the app it's registered on, and adds a `usage_report` tool that shows the counts. The app's tools don't mention it. Initial call configuration: ```js { tool: "usage_report", arguments: {} } ``` ```ts usage.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk"; @Provider({ name: "UsageLog" }) export class UsageLog { counts: Record = {}; add(tool: string) { this.counts[tool] = (this.counts[tool] ?? 0) + 1; } } @Tool({ name: "usage_report", description: "How many times each tool has been called", inputSchema: {} }) export class UsageReport extends ToolContext { async execute() { return { counts: { ...this.get(UsageLog).counts } }; } } @Plugin({ name: "usage", description: "Counts calls to every tool", providers: [UsageLog], tools: [UsageReport] }) export class UsagePlugin extends DynamicPlugin { // Runs after every tool's execute() returns @ToolHook.Did("execute") async count(ctx: FlowCtxOf<"tools:call-tool">) { const name = ctx.state.tool?.metadata.name; if (name) this.get(UsageLog).add(name); } } ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket, GetTicket } from "./tools"; import { UsagePlugin } from "./usage.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, CloseTicket], plugins: [UsagePlugin] }) export class HelpDeskApp {} ``` ```ts usage.test.ts import { test, expect } from "@frontmcp/testing"; test("the plugin's tool is listed with the app's", async ({ mcp }) => { const tools = await mcp.tools.list(); expect(tools).toContainTool("usage_report"); expect(tools).toContainTool("get_ticket"); }); test("every call is counted, including calls to the plugin's own tool", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-1" }); await mcp.tools.call("get_ticket", { id: "T-2" }); await mcp.tools.call("close_ticket", { id: "T-1" }); await mcp.tools.call("usage_report", {}); expect((await mcp.tools.call("usage_report", {})).json().counts).toEqual({ get_ticket: 2, close_ticket: 1, usage_report: 1, }); }); ``` The hook runs for `usage_report` too: the plugin's tools belong to the app, like the app's own. ### Sharing a provider with the app's tools A plugin's providers are private to it. List one in `exports` and the app's tools can get it too; the ones you leave out stay private: Initial call configuration: ```js { tool: "busiest_tool", arguments: {} } ``` ```ts usage.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, Provider, ToolHook } from "@frontmcp/sdk"; @Provider({ name: "UsageLog" }) export class UsageLog { counts: Record = {}; } @Provider({ name: "UsageSettings" }) export class UsageSettings { ignore = ["busiest_tool"]; } @Plugin({ name: "usage", providers: [UsageLog, UsageSettings], exports: [UsageLog], // ✅ the app's tools can get UsageLog; UsageSettings stays private }) export class UsagePlugin extends DynamicPlugin { @ToolHook.Did("execute") async count(ctx: FlowCtxOf<"tools:call-tool">) { const name = ctx.state.tool?.metadata.name; if (!name || this.get(UsageSettings).ignore.includes(name)) return; const { counts } = this.get(UsageLog); counts[name] = (counts[name] ?? 0) + 1; } } ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { UsageLog, UsageSettings } from "./usage.plugin"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Tool({ name: "busiest_tool", description: "Which tool has been called most", inputSchema: {} }) export class BusiestTool extends ToolContext { async execute() { const counts = Object.entries(this.get(UsageLog).counts).sort((a, b) => b[1] - a[1]); return { busiest: counts[0]?.[0] ?? null, canSeeSettings: this.tryGet(UsageSettings) !== undefined, }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { BusiestTool, GetTicket } from "./tools"; import { UsagePlugin } from "./usage.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, BusiestTool], plugins: [UsagePlugin] }) export class HelpDeskApp {} ``` ```ts exports.test.ts import { test, expect } from "@frontmcp/testing"; test("the app's tool reads the plugin's exported UsageLog", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-1" }); expect((await mcp.tools.call("busiest_tool", {})).json().busiest).toBe("get_ticket"); }); test("a provider that isn't exported stays private", async ({ mcp }) => { expect((await mcp.tools.call("busiest_tool", {})).json().canSeeSettings).toBe(false); }); ``` `this.get(UsageSettings)` in the tool would fail with `Provider "UsageSettings" is not available`. Registering `UsageLog` in the app's `providers` as well wouldn't help: the app would get a second, empty `UsageLog`, and the tool would read that one. ### Adding a property to every tool's `this` `contextExtensions` puts an exported provider on `this`, as the official [Remember plugin](https://frontmcp.dev/reference/plugins/remember) does with `this.remember`. The token must be in `exports`, and a `declare module` block tells TypeScript about the property: Initial call configuration: ```js { tool: "busiest_tool", arguments: {} } ``` ```ts usage.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; export class UsageLog { counts: Record = {}; } export const USAGE_LOG = Symbol.for("frontmcp.dev/reference/plugin/usage-log"); const usageLog = { provide: USAGE_LOG, name: "UsageLog", inject: () => [] as const, useFactory: () => new UsageLog() }; // Tells TypeScript that tools, resources and prompts have this.usageLog declare module "@frontmcp/sdk" { interface ExecutionContextBase { readonly usageLog: UsageLog; } } @Plugin({ name: "usage", providers: [usageLog], exports: [usageLog], contextExtensions: [ { property: "usageLog", token: USAGE_LOG, errorMessage: "this.usageLog needs UsagePlugin in this app's plugins." }, ], }) export class UsagePlugin extends DynamicPlugin { @ToolHook.Did("execute") async count(ctx: FlowCtxOf<"tools:call-tool">) { const name = ctx.state.tool?.metadata.name; if (!name) return; const { counts } = this.get(USAGE_LOG); counts[name] = (counts[name] ?? 0) + 1; } } ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Tool({ name: "busiest_tool", description: "Which tool has been called most", inputSchema: {} }) export class BusiestTool extends ToolContext { async execute() { const counts = Object.entries(this.usageLog.counts).sort((a, b) => b[1] - a[1]); return { busiest: counts[0]?.[0] ?? null }; } } @Tool({ name: "invoice_usage", description: "Tool usage, seen from the billing app", inputSchema: {} }) export class InvoiceUsage extends ToolContext { async execute() { return { counts: this.usageLog.counts }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { BusiestTool, GetTicket, InvoiceUsage } from "./tools"; import { UsagePlugin } from "./usage.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, BusiestTool], plugins: [UsagePlugin] }) export class HelpDeskApp {} // No UsagePlugin here @App({ id: "billing", name: "Billing", tools: [InvoiceUsage] }) export class BillingApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp] }) export default class Server {} ``` ```ts extension.test.ts import { test, expect } from "@frontmcp/testing"; test("tools read the log through this.usageLog", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-1" }); expect((await mcp.tools.call("busiest_tool", {})).json().busiest).toBe("get_ticket"); }); test("in an app without the plugin, this.usageLog fails with errorMessage", async ({ mcp }) => { const result = await mcp.tools.call("invoice_usage", {}); expect(result).toBeError(); expect(result).toHaveTextContent("this.usageLog needs UsagePlugin in this app's plugins."); }); ``` The billing app's tool compiles, because the `declare module` block applies everywhere, and the property exists at run time, because FrontMCP installs it on every context. Only the plugin's own app can resolve it. The example uses a `Symbol.for()` key because the Playground rebuilds your classes on every run but keeps one FrontMCP, which remembers the first token for `usageLog`. ### Taking options A plugin takes options through its constructor. `init()` passes them in, and goes in `plugins` where the class went: ### Passing options ### Example: An options object Initial call configuration: ```js { tool: "usage_report", arguments: {} } ``` ```ts usage.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk"; export type UsageOptions = { ignore?: string[] }; @Provider({ name: "UsageLog" }) export class UsageLog { counts: Record = {}; } @Tool({ name: "usage_report", description: "How many times each tool has been called", inputSchema: {} }) export class UsageReport extends ToolContext { async execute() { return { counts: { ...this.get(UsageLog).counts } }; } } @Plugin({ name: "usage", providers: [UsageLog], tools: [UsageReport] }) export class UsagePlugin extends DynamicPlugin { constructor(private readonly options: UsageOptions = {}) { super(); } @ToolHook.Did("execute") async count(ctx: FlowCtxOf<"tools:call-tool">) { const name = ctx.state.tool?.metadata.name; if (!name || this.options.ignore?.includes(name)) return; const { counts } = this.get(UsageLog); counts[name] = (counts[name] ?? 0) + 1; } } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { UsagePlugin } from "./usage.plugin"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [UsagePlugin.init({ ignore: ["usage_report"] })], }) export class HelpDeskApp {} ``` ```ts options.test.ts import { test, expect } from "@frontmcp/testing"; test("tools in `ignore` aren't counted", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-1" }); await mcp.tools.call("usage_report", {}); expect((await mcp.tools.call("usage_report", {})).json().counts).toEqual({ get_ticket: 1 }); }); ``` ### Example: Options from a provider `useFactory` builds the options when the server starts, from the providers `inject` returns. Here the ignore list comes from the app's `DeskSettings`: Initial call configuration: ```js { tool: "usage_report", arguments: {} } ``` ```ts help-desk.app.ts active import { App, Provider, Tool, ToolContext, z } from "@frontmcp/sdk"; import { UsagePlugin, type UsageOptions } from "./usage.plugin"; @Provider({ name: "DeskSettings" }) export class DeskSettings { internalTools = ["usage_report"]; } @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], providers: [DeskSettings], plugins: [ UsagePlugin.init({ inject: () => [DeskSettings] as const, useFactory: (settings: DeskSettings): UsageOptions => ({ ignore: settings.internalTools }), }), ], }) export class HelpDeskApp {} ``` ```ts usage.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk"; export type UsageOptions = { ignore?: string[] }; @Provider({ name: "UsageLog" }) export class UsageLog { counts: Record = {}; } @Tool({ name: "usage_report", description: "How many times each tool has been called", inputSchema: {} }) export class UsageReport extends ToolContext { async execute() { return { counts: { ...this.get(UsageLog).counts } }; } } @Plugin({ name: "usage", providers: [UsageLog], tools: [UsageReport] }) export class UsagePlugin extends DynamicPlugin { constructor(private readonly options: UsageOptions = {}) { super(); } @ToolHook.Did("execute") async count(ctx: FlowCtxOf<"tools:call-tool">) { const name = ctx.state.tool?.metadata.name; if (!name || this.options.ignore?.includes(name)) return; const { counts } = this.get(UsageLog); counts[name] = (counts[name] ?? 0) + 1; } } ``` ```ts factory.test.ts import { test, expect } from "@frontmcp/testing"; test("the ignore list comes from DeskSettings", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-1" }); await mcp.tools.call("usage_report", {}); expect((await mcp.tools.call("usage_report", {})).json().counts).toEqual({ get_ticket: 1 }); }); ``` ### Example: No options, on two apps `init()` needs no argument: the constructor gets `{}`. When one record is on several apps, the first uses the instance `init()` built, and each of the others builds one of its own. Here `CountCalls` keeps a count on `this`: ```ts count-calls.plugin.ts active import { DynamicPlugin, Plugin, ToolHook } from "@frontmcp/sdk"; export const instances: CountCalls[] = []; @Plugin({ name: "count-calls" }) export class CountCalls extends DynamicPlugin { calls = 0; constructor() { super(); instances.push(this); } @ToolHook.Did("execute") async count() { this.calls += 1; } } ``` ```ts apps.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { CountCalls } from "./count-calls.plugin"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "open" }; } } @Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { id: z.string() } }) export class RefundInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "refunded" }; } } const countCalls = CountCalls.init(); @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [countCalls] }) export class HelpDeskApp {} @App({ id: "billing", name: "Billing", tools: [RefundInvoice], plugins: [countCalls] }) export class BillingApp {} ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { BillingApp, HelpDeskApp } from "./apps"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp] }) export default class Server {} ``` ```ts no-options.test.ts import { test, expect } from "@frontmcp/testing"; import { instances } from "./count-calls.plugin"; test("each app counts on an instance of its own", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-1" }); await mcp.tools.call("get_ticket", { id: "T-2" }); await mcp.tools.call("refund_invoice", { id: "INV-7" }); expect(instances.map((instance) => instance.calls).sort()).toEqual([1, 2]); }); ``` ### Example: An option named like a list An option may share its name with one of `@Plugin`'s lists: `tools`, `resources`, `prompts`, `skills`, `adapters`, `plugins` or `exports`. When it isn't an array, it stays an option, and the plugin gets it in its constructor. Options named like `@Plugin`'s other fields, `name`, `id`, `description` and `scope`, are always the plugin's: `init({ scope: "server" })` doesn't change where the plugin's hooks run. (Changed in 1.9.2: before, those four renamed the plugin or changed its `scope`.) Here `tools` is an object, `{ enabled, prefix }`, and `static dynamicTools(options)` turns it into the tools the plugin adds: Initial call configuration: ```js { tool: "memo_add_note", arguments: { text: "Called the customer back." } } ``` ```ts notes.plugin.ts active import { DynamicPlugin, Plugin, Tool, ToolContext, z } from "@frontmcp/sdk"; export type NotesOptions = { tools?: { enabled?: boolean; prefix?: string } }; const notes: string[] = []; function noteTools(prefix: string) { @Tool({ name: `${prefix}add_note`, description: "Keep a note for later", inputSchema: { text: z.string() } }) class AddNote extends ToolContext { async execute({ text }: { text: string }) { notes.push(text); return { count: notes.length }; } } @Tool({ name: `${prefix}list_notes`, description: "List the notes kept so far", inputSchema: {} }) class ListNotes extends ToolContext { async execute() { return { notes: [...notes] }; } } return [AddNote, ListNotes]; } @Plugin({ name: "notes", description: "Keeps notes for later" }) export class NotesPlugin extends DynamicPlugin { constructor(readonly options: NotesOptions = {}) { super(); } static dynamicTools(options: NotesOptions) { return options.tools?.enabled ? noteTools(options.tools.prefix ?? "") : []; } } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { NotesPlugin } from "./notes.plugin"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [NotesPlugin.init({ tools: { enabled: true, prefix: "memo_" } })], }) export class HelpDeskApp {} ``` ```ts notes.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { NotesPlugin } from "./notes.plugin"; test("the plugin's tools are listed next to the app's, under the prefix", async ({ mcp }) => { expect((await mcp.tools.list()).map((tool) => tool.name).sort()).toEqual(["get_ticket", "memo_add_note", "memo_list_notes"]); }); test("they run like any tool", async ({ mcp }) => { await mcp.tools.call("memo_add_note", { text: "Called the customer back." }); expect((await mcp.tools.call("memo_list_notes", {})).json().notes).toContain("Called the customer back."); }); test("`enabled: false` adds none, and `useFactory`'s options reach `dynamicTools()` too", async () => { const serverWith = async (plugin: unknown) => { @App({ id: "desk", name: "Desk", plugins: [plugin as never] }) class Desk {} return FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }); }; const disabled = await serverWith(NotesPlugin.init({ tools: { enabled: false } })); const factory = await serverWith(NotesPlugin.init({ inject: () => [] as const, useFactory: () => ({ tools: { enabled: true } }) })); try { expect((await disabled.listTools()).tools).toEqual([]); expect((await factory.listTools()).tools.map((tool) => tool.name)).toEqual(["add_note", "list_notes"]); } finally { await disabled.dispose(); await factory.dispose(); } }); ``` New in 1.8.7: before, an option object named `tools` was read as the plugin's own list of tools, and the server didn't start; `dynamicTools()` is how the Remember plugin registers its memory tools under `tools.prefix`. - **`DynamicPlugin`** types `init()`, so it only accepts `UsageOptions`, or the `inject`/`useFactory` pair. - **`init({ ignore })`** calls the constructor at once, when the module is imported. The factory form calls it when the server starts, once the app's providers exist. - **`plugins: [UsagePlugin]`** still works, and builds the plugin as `init()` without an argument does: the constructor gets `{}`. (Changed in 1.9.4: before, it got no argument, so a default parameter applied.) - **`init()`** without an argument builds the plugin with `{}`. Before 1.8.6 it threw `Cannot read properties of undefined (reading 'providers')`. - **One `init()` record on several apps** gives each app its own instance since 1.8.6, as "No options, on two apps" shows. Before, they shared one, so state kept on `this` was shared too. Providers a plugin adds are a different matter: see [Providers built from options](https://frontmcp.dev/reference/sdk/plugin#providers-built-from-options). ### Providers built from options `static dynamicProviders(options)` adds providers that depend on the options, like a client configured with a base URL. `init()` calls it with the same options it passes to the constructor: Initial call configuration: ```js { tool: "ticket_link", arguments: { id: "T-1" } } ``` ```ts links.plugin.ts active import { DynamicPlugin, Plugin } from "@frontmcp/sdk"; export type LinkOptions = { baseUrl?: string }; export class TicketLinks { constructor(private readonly baseUrl: string) {} linkTo(id: string) { return `${this.baseUrl}/tickets/${id}`; } } export const TICKET_LINKS = Symbol.for("frontmcp.dev/reference/plugin/ticket-links"); @Plugin({ name: "links" }) export class LinksPlugin extends DynamicPlugin { static dynamicProviders({ baseUrl = "https://desk.example.com" }: LinkOptions) { return [ { provide: TICKET_LINKS, name: "TicketLinks", inject: () => [] as const, useFactory: () => new TicketLinks(baseUrl) }, ]; } constructor(readonly options: LinkOptions) { super(); } } ``` ```ts main.ts import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; import { LinksPlugin, TICKET_LINKS, type TicketLinks } from "./links.plugin"; @Tool({ name: "ticket_link", description: "Get a link to a support ticket", inputSchema: { id: z.string() } }) export class TicketLink extends ToolContext { async execute({ id }: { id: string }) { return { url: this.get(TICKET_LINKS).linkTo(id) }; } } @Tool({ name: "invoice_link", description: "Get a link to an invoice", inputSchema: { id: z.string() } }) export class InvoiceLink extends ToolContext { async execute({ id }: { id: string }) { return { hasTicketLinks: this.tryGet(TICKET_LINKS) !== undefined }; } } @App({ id: "help-desk", name: "Help Desk", tools: [TicketLink], plugins: [LinksPlugin.init({ baseUrl: "https://support.acme.com" })], }) export class HelpDeskApp {} @App({ id: "billing", name: "Billing", tools: [InvoiceLink] }) export class BillingApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp] }) export default class Server {} ``` ```ts links.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { LinksPlugin } from "./links.plugin"; import { TicketLink } from "./main"; test("the provider is built from the options, and the app's tools can get it", async ({ mcp }) => { expect((await mcp.tools.call("ticket_link", { id: "T-1" })).json()).toEqual({ url: "https://support.acme.com/tickets/T-1" }); }); test("other apps' tools can't", async ({ mcp }) => { expect((await mcp.tools.call("invoice_link", { id: "INV-7" })).json().hasTicketLinks).toBe(false); }); test("listed as its class, the plugin gets `{}`, so the default base URL applies", async () => { @App({ id: "help-desk", name: "Help Desk", tools: [TicketLink], plugins: [LinksPlugin] }) class HelpDeskApp {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); try { expect((await server.callTool("ticket_link", { id: "T-1" })).structuredContent).toEqual({ url: "https://desk.example.com/tickets/T-1" }); } finally { await server.dispose(); } }); ``` The app's tool gets `TicketLinks` without `exports`. The billing app's doesn't, because the plugin is only on the help desk app: providers from `dynamicProviders()` and from `init({ providers })` reach the app that installed the plugin, and no other. Changed in 1.9: before, they were shared with every app. `plugins: [LinksPlugin]`, without `init()`, calls `dynamicProviders({})`, as the last test shows, which is why the default is filled in there. Changed in 1.9.4: before, the class form never called `dynamicProviders()`, and `this.get(TICKET_LINKS)` failed. ### Adding tools, resources, prompts and skills Everything a plugin lists joins the app, and clients see it like the app's own. Open the **Capabilities** tab: Initial call configuration: ```js { resource: "desk://usage" } ``` ```ts usage.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, Prompt, PromptContext, Provider, Resource, ResourceContext, ToolHook, skill, type GetPromptResult, } from "@frontmcp/sdk"; @Provider({ name: "UsageLog" }) export class UsageLog { counts: Record = {}; } @Resource({ name: "usage", uri: "desk://usage", mimeType: "application/json", description: "Calls per tool so far" }) export class UsageResource extends ResourceContext { async execute() { return { counts: this.get(UsageLog).counts }; } } @Prompt({ name: "review_usage", description: "Ask for a review of which tools are used", arguments: [] }) export class ReviewUsage extends PromptContext { async execute(): Promise { const counts = JSON.stringify(this.get(UsageLog).counts); return { messages: [{ role: "user", content: { type: "text", text: `Which of these tools look unused? ${counts}` } }] }; } } export const readUsage = skill({ name: "read-usage", description: "How to find out which help desk tools are used", instructions: "Read the desk://usage resource. Each key is a tool name and each value a number of calls.", }); @Plugin({ name: "usage", providers: [UsageLog], resources: [UsageResource], prompts: [ReviewUsage], skills: [readUsage] }) export class UsagePlugin extends DynamicPlugin { @ToolHook.Did("execute") async count(ctx: FlowCtxOf<"tools:call-tool">) { const name = ctx.state.tool?.metadata.name; if (!name) return; const { counts } = this.get(UsageLog); counts[name] = (counts[name] ?? 0) + 1; } } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { UsagePlugin } from "./usage.plugin"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [UsagePlugin] }) export class HelpDeskApp {} ``` ```ts contents.test.ts import { test, expect } from "@frontmcp/testing"; import { App, Plugin, connect } from "@frontmcp/sdk"; test("the plugin's resource, prompt and skill are listed", async ({ mcp }) => { expect(await mcp.resources.list()).toContainResource("desk://usage"); expect(await mcp.resources.list()).toContainResource("skill://read-usage/SKILL.md"); expect(await mcp.prompts.list()).toContainPrompt("review_usage"); }); test("the resource reads the plugin's provider", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-1" }); expect((await mcp.resources.read("desk://usage")).json()).toEqual({ counts: { get_ticket: 1 } }); }); test("`dynamicSkills`: the skills requests work before the plugin has added a skill", async () => { @Plugin({ name: "bundle-skills", dynamicSkills: true }) class BundleSkills {} @Plugin({ name: "no-skills" }) class NoSkills {} @App({ id: "billing", name: "Billing", plugins: [BundleSkills] }) class Billing {} @App({ id: "billing", name: "Billing", plugins: [NoSkills] }) class BillingWithout {} const client = await connect({ info: { name: "billing", version: "1.0.0" }, apps: [Billing] }); expect(await client.listSkills()).toMatchObject({ skills: [], total: 0 }); await client.close(); const without = await connect({ info: { name: "billing", version: "1.0.0" }, apps: [BillingWithout] }); await expect(without.listSkills()).rejects.toThrow("Method not found"); await without.close(); }); ``` ### Registering a plugin on every app A plugin in `@FrontMcp({ plugins })` applies to every app: its hooks run for every app's tools, its tools are listed once, and every app's tools can get its exports. Initial call configuration: ```js { tool: "usage_report", arguments: {} } ``` ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { BillingApp, HelpDeskApp } from "./apps"; import { UsagePlugin } from "./usage.plugin"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp], plugins: [UsagePlugin], }) export default class Server {} ``` ```ts apps.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { id: z.string() } }) export class RefundInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "refunded" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] }) export class HelpDeskApp {} @App({ id: "billing", name: "Billing", tools: [RefundInvoice] }) export class BillingApp {} ``` ```ts usage.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk"; @Provider({ name: "UsageLog" }) export class UsageLog { counts: Record = {}; } @Tool({ name: "usage_report", description: "How many times each tool has been called", inputSchema: {} }) export class UsageReport extends ToolContext { async execute() { return { counts: { ...this.get(UsageLog).counts } }; } } @Plugin({ name: "usage", providers: [UsageLog], tools: [UsageReport] }) export class UsagePlugin extends DynamicPlugin { @ToolHook.Did("execute", { filter: (ctx) => ctx.state.tool?.metadata.name !== "usage_report" }) async count(ctx: FlowCtxOf<"tools:call-tool">) { const name = ctx.state.tool?.metadata.name; if (!name) return; const { counts } = this.get(UsageLog); counts[name] = (counts[name] ?? 0) + 1; } } ``` ```ts server.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance, Plugin } from "@frontmcp/sdk"; import { BillingApp, CloseTicket } from "./apps"; import { UsageLog, UsagePlugin, UsageReport } from "./usage.plugin"; test("calls to both apps are counted", async ({ mcp }) => { await mcp.tools.call("close_ticket", { id: "T-1" }); await mcp.tools.call("refund_invoice", { id: "INV-7" }); expect((await mcp.tools.call("usage_report", {})).json().counts).toEqual({ close_ticket: 1, refund_invoice: 1 }); }); test("the plugin's tool is listed once", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t: { name: string }) => t.name); expect(names.filter((n: string) => n === "usage_report")).toHaveLength(1); }); test("on one app, with `scope: \"server\"`, it counts every app's calls too", async () => { @Plugin({ name: "usage", providers: [UsageLog], tools: [UsageReport], scope: "server" }) class ServerUsage extends UsagePlugin {} @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket], plugins: [ServerUsage] }) class HelpDesk {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk, BillingApp] }); try { await server.callTool("close_ticket", { id: "T-1" }); await server.callTool("refund_invoice", { id: "INV-7" }); expect((await server.callTool("usage_report", {})).structuredContent).toEqual({ counts: { close_ticket: 1, refund_invoice: 1 } }); } finally { await server.dispose(); } }); ``` Move `plugins: [UsagePlugin]` into `HelpDeskApp` and `refund_invoice` stops being counted: an app's plugin hooks `tools/call` for that app's tools only, unless the plugin sets [`scope: "server"`](https://frontmcp.dev/reference/sdk/plugin#scope), as the last test does, or the hook sets [`appliesTo`](https://frontmcp.dev/reference/sdk/plugin#enforcing-an-option-of-your-own). ### Enforcing an option of your own A plugin can add an option to tools, like `approval` from the [Approval plugin](https://frontmcp.dev/reference/plugins/approval), and enforce it in a hook. Two things keep such an option from doing nothing where the plugin doesn't reach. `enforcesMetadata` names the option: a server where a tool sets it and no hook of the plugin covers that tool doesn't start. And a hook with `appliesTo: "uncovered-apps"` also runs for the entries of apps that have no instance of it, so one app's plugin covers the others: Initial call configuration: ```js { tool: "refund_invoice", arguments: { id: "INV-7" } } ``` ```ts maintenance.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, PublicMcpError, ToolHook } from "@frontmcp/sdk"; declare global { interface ExtendFrontMcpToolMetadata { /** The tool changes data, so it's refused during maintenance */ writes?: boolean; } } type Options = { until?: string }; @Plugin({ name: "maintenance", enforcesMetadata: ["writes"] }) export class MaintenancePlugin extends DynamicPlugin { constructor(readonly options: Options = {}) { super(); } // Also runs for the tools of apps that don't have this plugin @ToolHook.Will("execute", { appliesTo: "uncovered-apps" }) async refuseWrites(ctx: FlowCtxOf<"tools:call-tool">) { const tool = ctx.state.tool?.metadata; if (tool?.writes && this.options.until) { throw new PublicMcpError(`${tool.name} is unavailable during maintenance, until ${this.options.until}.`); } } } ``` ```ts apps.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { MaintenancePlugin } from "./maintenance.plugin"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() }, writes: true }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { id: z.string() }, writes: true }) export class RefundInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "refunded" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, CloseTicket], plugins: [MaintenancePlugin.init({ until: "10:00 UTC" })], }) export class HelpDeskApp {} @App({ id: "billing", name: "Billing", tools: [RefundInvoice] }) export class BillingApp {} ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { BillingApp, HelpDeskApp } from "./apps"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp] }) export default class Server {} ``` ```ts maintenance.test.ts import { test, expect } from "@frontmcp/testing"; import { Agent, AgentContext, App, FrontMcpInstance } from "@frontmcp/sdk"; import { BillingApp, CloseTicket } from "./apps"; import { MaintenancePlugin } from "./maintenance.plugin"; test("tools that write are refused, in the plugin's app", async ({ mcp }) => { expect((await mcp.tools.call("close_ticket", { id: "T-1" })).text()).toBe("close_ticket is unavailable during maintenance, until 10:00 UTC."); expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeSuccessful(); }); test("`appliesTo: \"uncovered-apps\"` reaches the billing app too", async ({ mcp }) => { expect(await mcp.tools.call("refund_invoice", { id: "INV-7" })).toBeError(); }); test("a server where no plugin enforces `writes` doesn't start", async () => { await expect(FrontMcpInstance.createForGraph({ info: { name: "billing", version: "1.0.0" }, apps: [BillingApp] })).rejects.toThrow( `Unenforced metadata: Tool "refund_invoice" declares 'writes' (enforced by plugin "maintenance")`, ); }); test("a tool inside an agent needs the plugin on the agent", async () => { const llm = { adapter: { completion: async () => ({ content: "Closed.", finishReason: "stop" as const }) } }; @Agent({ name: "closer", tools: [CloseTicket], llm }) class Closer extends AgentContext {} @App({ id: "desk", name: "Desk", agents: [Closer], plugins: [MaintenancePlugin] }) class Desk {} await expect(FrontMcpInstance.createForGraph({ info: { name: "desk", version: "1.0.0" }, apps: [Desk] })).rejects.toThrow( `Tool "closer:close_ticket" declares 'writes'`, ); }); ``` Remove `appliesTo` and the server no longer starts: nothing covers `refund_invoice`. The error is `UnenforcedMetadataError`, code `UNENFORCED_METADATA`, and it names every entry and option left uncovered. A tool inside an `@Agent` is covered only by the agent's own `plugins`, as the last test shows. `approval` and `featureFlag` are checked the same way, for their plugins. ### Hiding what a plugin refuses A plugin that refuses some calls should leave those entries out of the lists too. List hooks see every app's entries, though, even on an app's plugin, while its call hooks only judge its own app. `isEntryGatedBy(scope, entry, this)`, exported by `@frontmcp/sdk`, says whether this plugin instance's hooks run when `scope` serves `entry` (`{ tool }`, `{ resource }`, `{ prompt }` or `{ skill }`), so a list hook can filter only what its own gate would refuse. Here each app has its own instance, and only the help desk is read-only: ```ts read-only.plugin.ts active import { DynamicPlugin, FlowCtxOf, ListToolsHook, Plugin, PublicMcpError, ScopeEntry, ToolHook, isEntryGatedBy } from "@frontmcp/sdk"; type Options = { enabled: boolean }; @Plugin({ name: "read-only" }) export class ReadOnlyPlugin extends DynamicPlugin { constructor(readonly options: Options = { enabled: false }) { super(); } @ToolHook.Will("execute") async refuse(ctx: FlowCtxOf<"tools:call-tool">) { const tool = ctx.state.tool?.metadata; if (this.options.enabled && !tool?.annotations?.readOnlyHint) throw new PublicMcpError(`${tool?.name} is unavailable: the help desk is read-only.`); } @ListToolsHook.Did("findTools") async hide(ctx: FlowCtxOf<"tools:list-tools">) { if (!this.options.enabled) return; const scope = this.get(ScopeEntry); const tools = ctx.state.tools ?? []; // Keep what this instance doesn't judge: another app's tools ctx.state.set("tools", tools.filter(({ tool }) => tool.metadata.annotations?.readOnlyHint || !isEntryGatedBy(scope, { tool }, this))); } } ``` ```ts apps.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { ReadOnlyPlugin } from "./read-only.plugin"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() }, annotations: { readOnlyHint: true } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "open" }; } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { id: z.string() } }) export class RefundInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "refunded" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, CloseTicket], plugins: [ReadOnlyPlugin.init({ enabled: true })] }) export class HelpDeskApp {} @App({ id: "billing", name: "Billing", tools: [RefundInvoice], plugins: [ReadOnlyPlugin.init({ enabled: false })] }) export class BillingApp {} ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { BillingApp, HelpDeskApp } from "./apps"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp] }) export default class Server {} ``` ```ts read-only.test.ts import { test, expect } from "@frontmcp/testing"; import { App, DynamicPlugin, FlowCtxOf, FrontMcpInstance, ListToolsHook, Plugin } from "@frontmcp/sdk"; import { CloseTicket, GetTicket, RefundInvoice } from "./apps"; test("the list hides what the calls refuse, and nothing else", async ({ mcp }) => { expect((await mcp.tools.list()).map((tool) => tool.name)).toEqual(["get_ticket", "refund_invoice"]); expect(await mcp.tools.call("close_ticket", { id: "T-1" })).toBeError(); expect((await mcp.tools.call("refund_invoice", { id: "INV-7" })).json()).toEqual({ id: "INV-7", status: "refunded" }); }); test("🚩 without the check, one app's plugin hides another app's tools", async () => { @Plugin({ name: "hide-writes" }) class HideWrites extends DynamicPlugin { @ListToolsHook.Did("findTools") async hide(ctx: FlowCtxOf<"tools:list-tools">) { ctx.state.set("tools", (ctx.state.tools ?? []).filter(({ tool }) => tool.metadata.annotations?.readOnlyHint)); } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, CloseTicket], plugins: [HideWrites] }) class HelpDesk {} @App({ id: "billing", name: "Billing", tools: [RefundInvoice] }) class Billing {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk, Billing] }); expect((await server.listTools()).tools.map((tool) => tool.name)).toEqual(["get_ticket"]); expect((await server.callTool("refund_invoice", { id: "INV-7" })).structuredContent).toEqual({ id: "INV-7", status: "refunded" }); await server.dispose(); }); ``` Without the `isEntryGatedBy()` check, the help desk's plugin hides `refund_invoice` too, although nothing stops it from running by name, as the last test shows. The [Feature Flags plugin](https://frontmcp.dev/reference/plugins/feature-flags) filters its lists this way since 1.8.4. ### Bringing other plugins along A plugin's `plugins` are registered with it. Their tools join the app and their hooks run for the app's tools, like the outer plugin's, but their exports reach the outer plugin, not the app: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts plugins.ts active import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk"; @Provider({ name: "Clock" }) export class Clock { now() { return "2026-09-27T09:00:00Z"; } } @Tool({ name: "server_time", description: "The server's current time", inputSchema: {} }) export class ServerTime extends ToolContext { async execute() { return { now: this.get(Clock).now() }; } } // Brought along by AuditPlugin @Plugin({ name: "clock", providers: [Clock], exports: [Clock], tools: [ServerTime] }) export class ClockPlugin extends DynamicPlugin {} @Provider({ name: "AuditTrail" }) export class AuditTrail { lines: string[] = []; } @Plugin({ name: "audit", providers: [AuditTrail], exports: [AuditTrail], plugins: [ClockPlugin] }) export class AuditPlugin extends DynamicPlugin { @ToolHook.Did("execute") async record(ctx: FlowCtxOf<"tools:call-tool">) { // Clock is ClockPlugin's export, so the outer plugin can get it this.get(AuditTrail).lines.push(`${this.get(Clock).now()} ${ctx.state.tool?.metadata.name}`); } } ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { AuditTrail, Clock } from "./plugins"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", canSeeClock: this.tryGet(Clock) !== undefined }; } } @Tool({ name: "audit_trail", description: "Show the audit trail", inputSchema: {} }) export class ShowAuditTrail extends ToolContext { async execute() { return { lines: [...this.get(AuditTrail).lines] }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket, ShowAuditTrail } from "./tools"; import { AuditPlugin } from "./plugins"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, ShowAuditTrail], plugins: [AuditPlugin] }) export class HelpDeskApp {} ``` ```ts nested.test.ts import { test, expect } from "@frontmcp/testing"; test("the outer plugin uses the nested plugin's export", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-1" }); expect((await mcp.tools.call("audit_trail", {})).json().lines).toEqual(["2026-09-27T09:00:00Z get_ticket"]); }); test("the nested plugin's tool joins the app", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("server_time"); expect((await mcp.tools.call("server_time", {})).json()).toEqual({ now: "2026-09-27T09:00:00Z" }); }); test("the app's tools don't see the nested plugin's export", async ({ mcp }) => { expect((await mcp.tools.call("get_ticket", { id: "T-1" })).json().canSeeClock).toBe(false); }); ``` To give the app's tools a nested plugin's provider, register that plugin on the app as well, or on the server. ### Putting a plugin on an agent A plugin in `@Agent({ plugins })` applies to the tools the agent's model calls: its hooks run for them, and its tools and exports join the agent's. It doesn't apply to `invoke_` itself, nor to the app's tools, even when the agent and the app share a tool: Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, DynamicPlugin, Plugin, PublicMcpError, ToolHook, z } from "@frontmcp/sdk"; import { model } from "./model.example"; import { GetTicket, SetPriority } from "./ticket.tools"; @Plugin({ name: "freeze" }) export class FreezePlugin extends DynamicPlugin { @ToolHook.Will("execute", { filter: (ctx) => ctx.state.tool?.metadata.name === "set_priority" }) async refuse() { throw new PublicMcpError("Priorities are frozen until Monday."); } } @Agent({ name: "triage", description: "Decide a new support ticket's priority. Pass the ticket's id.", inputSchema: { ticketId: z.string() }, tools: [GetTicket, SetPriority], llm: { adapter: model }, plugins: [FreezePlugin], }) export class Triage extends AgentContext {} ``` ```ts ticket.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in since this morning" }; } } @Tool({ name: "set_priority", description: "Set a ticket's priority.", inputSchema: { id: z.string(), priority: z.enum(["high", "normal"]) }, }) export class SetPriority extends ToolContext { async execute({ id, priority }: { id: string; priority: string }) { return { id, priority }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket, SetPriority } from "./ticket.tools"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, SetPriority], agents: [Triage] }) export class HelpDeskApp {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach: it sets the priority, // then answers with what set_priority said. A real server doesn't need this file. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const toolResults: (string | null)[] = []; export const model: AgentLlmAdapter = { async completion(prompt) { const last = prompt.messages[prompt.messages.length - 1]; if (last.role === "user") { return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "set_priority", arguments: { id: "T-1", priority: "high" } }] }; } toolResults.push(last.content); return { content: `set_priority answered: ${last.content}`, finishReason: "stop" }; }, }; ``` ```ts freeze.test.ts import { test, expect } from "@frontmcp/testing"; import { toolResults } from "./model.example"; test("the agent's model gets the plugin's refusal", async ({ mcp }) => { await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(toolResults).toEqual(['{"error":"Priorities are frozen until Monday."}']); }); test("the client's own call isn't hooked", async ({ mcp }) => { expect((await mcp.tools.call("set_priority", { id: "T-1", priority: "high" })).json()).toEqual({ id: "T-1", priority: "high" }); }); ``` Changed in 1.8.3: before, a plugin on an agent made tool calls fail with `Unsupported hook owner kind: "agent"`. A tool inside an agent that sets an option like `approval` needs the enforcing plugin here, on the agent: see [enforcing an option of your own](https://frontmcp.dev/reference/sdk/plugin#enforcing-an-option-of-your-own). The plugin's `ResourceHook`, `PromptHook` and list hooks also run when the agent's model reads the agent's own resources and prompts with `read_resource`, `get_prompt` and the list tools: those reads run their flows in the agent ([Flows in one request](https://frontmcp.dev/reference/server/flows#flows-in-one-request) shows one). New in 1.9.4, when agents got those tools. ### Publishing a plugin A plugin other teams can use is an npm package that exports the plugin's class. Keep it in its own folder, with the entry, `src/index.ts`, exporting everything an app may need: the class to put in `plugins`, the type of its options, and the provider an app's tools can get through `exports`. The app imports them from the package, as `help-desk.app.ts` imports them from `./index` here: Initial call configuration: ```js { tool: "usage_report", arguments: {} } ``` ```ts index.ts active // The package's entry: what `import { … } from "help-desk-usage-plugin"` gets export { UsagePlugin } from "./usage.plugin"; export type { UsagePluginOptions } from "./usage.plugin"; export { UsageLog } from "./usage-log"; export { UsageReport } from "./usage-report.tool"; ``` ```ts usage.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; import { UsageLog } from "./usage-log"; import { UsageReport } from "./usage-report.tool"; export interface UsagePluginOptions { /** Tools not to count */ ignore?: string[]; } @Plugin({ name: "usage", description: "Counts calls to every tool", providers: [UsageLog], exports: [UsageLog], tools: [UsageReport] }) export class UsagePlugin extends DynamicPlugin { constructor(private readonly options: UsagePluginOptions = {}) { super(); } @ToolHook.Did("execute") async count(ctx: FlowCtxOf<"tools:call-tool">) { const name = ctx.state.tool?.metadata.name; if (name && !(this.options.ignore ?? []).includes(name)) this.get(UsageLog).add(name); } } ``` ```ts usage-log.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "UsageLog" }) export class UsageLog { private counts = new Map(); add(tool: string) { this.counts.set(tool, (this.counts.get(tool) ?? 0) + 1); } report() { return Object.fromEntries(this.counts); } } ``` ```ts usage-report.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; import { UsageLog } from "./usage-log"; @Tool({ name: "usage_report", description: "How many times each tool has been called", inputSchema: {} }) export class UsageReport extends ToolContext { async execute() { return { counts: this.get(UsageLog).report() }; } } ``` ```ts help-desk.app.ts // In the app's project, this import is from "help-desk-usage-plugin" import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { UsageLog, UsagePlugin } from "./index"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Tool({ name: "busiest_tool", description: "The tool called most often so far", inputSchema: {} }) export class BusiestTool extends ToolContext { async execute() { const counts = Object.entries(this.get(UsageLog).report()).sort((a, b) => b[1] - a[1]); return { name: counts[0]?.[0] ?? null }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, BusiestTool], plugins: [UsagePlugin.init({ ignore: ["usage_report", "busiest_tool"] })], }) export class HelpDeskApp {} ``` ```ts package.test.ts import { test, expect } from "@frontmcp/testing"; test("the app turns the plugin on from the package's entry", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("usage_report"); await mcp.tools.call("get_ticket", { id: "T-1" }); await mcp.tools.call("get_ticket", { id: "T-2" }); await mcp.tools.call("usage_report", {}); expect((await mcp.tools.call("usage_report", {})).json()).toEqual({ counts: { get_ticket: 2 } }); }); test("the app's own tools get the provider the package exports", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-1" }); expect((await mcp.tools.call("busiest_tool", {})).json()).toEqual({ name: "get_ticket" }); }); ``` The package compiles to JavaScript with type declarations, with the decorator settings every FrontMCP project uses, and lists `@frontmcp/sdk` as a peer dependency: ```json package.json { "name": "help-desk-usage-plugin", "version": "1.0.0", "description": "A FrontMCP plugin that counts calls to every tool and reports them with usage_report", "keywords": ["frontmcp", "frontmcp-plugin", "mcp"], "license": "MIT", "main": "dist/index.js", "types": "dist/index.d.ts", "files": ["dist"], "scripts": { "build": "tsc -p tsconfig.json", "prepublishOnly": "npm run build" }, "peerDependencies": { "@frontmcp/sdk": "^1.10.11" }, "devDependencies": { "@frontmcp/sdk": "1.10.11", "reflect-metadata": "^0.2.2", "typescript": "^5.5.3" } } ``` ```json tsconfig.json { "compilerOptions": { "target": "es2021", "module": "commonjs", "moduleResolution": "node", "experimentalDecorators": true, "emitDecoratorMetadata": true, "declaration": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist", "rootDir": "src" }, "include": ["src"] } ``` ```bash npm run build # dist/index.js, dist/index.d.ts and the rest npm pack # help-desk-usage-plugin-1.0.0.tgz, to try in an app first npm publish ``` In the app's project, `npm install help-desk-usage-plugin`, then import from it as `help-desk.app.ts` imports from `./index` above. - **`peerDependencies`:** the app's own `@frontmcp/sdk` is the one the plugin uses, and npm checks the range. An app on an older SDK gets `ERESOLVE unable to resolve dependency tree` and `Could not resolve dependency: peer @frontmcp/sdk@"^1.10.11" from help-desk-usage-plugin@1.0.0`, before anything is installed. A plugin that lists the SDK in `dependencies` instead, at another version, gets a second copy under its own folder. The server still ran with two copies, but the app no longer type-checks: see [the error](https://frontmcp.dev/reference/sdk/plugin#type-pluginreturn-is-not-assignable-to-type-plugintype). The range starts at the version you built and tested with. - **`devDependencies`:** the SDK again, to compile and test the plugin, with `reflect-metadata` and TypeScript. - **`keywords`:** `frontmcp-plugin` lets people find it with `npm search keywords:frontmcp-plugin`. Nothing in FrontMCP reads it. - **`files`:** only `dist`, the compiled code and its `.d.ts` files. Changed in 1.9.7: seven official plugins and `@frontmcp/plugins` use an exact SDK peer so they share the app's SDK. `@frontmcp/plugin-cache` keeps its SDK dependency. Install the SDK alongside the plugins at the same version: see [Packages](https://frontmcp.dev/reference/plugins#packages). The package and the app were checked with FrontMCP 1.9.4 and a local npm registry (Verdaccio), not the public one: the app installed the package, listed `usage_report`, counted two `get_ticket` calls, and type-checked. --- ## Troubleshooting Several of these errors stop the server from starting. The Playground can't show those: an example that doesn't start has nothing to run. ### `Provider "…" is not available` in one of the app's tools The provider belongs to a plugin and isn't in its `exports`. Add it to `exports` ([Sharing a provider with the app's tools](https://frontmcp.dev/reference/sdk/plugin#sharing-a-provider-with-the-apps-tools)). For a plugin nested in another plugin, exports reach the outer plugin only: register the inner plugin on the app too. ### `ContextExtensionNotAvailableError: … is not installed or '…' is not configured.` A tool read a [context extension](https://frontmcp.dev/reference/sdk/plugin#contextextensions) the plugin couldn't resolve. Either the tool's app doesn't have the plugin (the property exists on every context once any app installs it), or the extension's `token` isn't in the plugin's `exports`. The text is the extension's `errorMessage`, or the default, ` is not installed or '' is not configured.` ### `@App invalid metadata for "plugins"`: `plugins items must be annotated with @Plugin()` Something in `plugins` isn't a plugin: a class without `@Plugin`, or a provider or tool listed in the wrong array. `MyPlugin.init({ ... })` results are fine. ### `Invalid input: expected string, received undefined` with `"path": ["name"]` `@Plugin({ ... })` has no `name`. It's the one required option. ### `Plugin "…" has scope='server' but is used in a standalone app` The plugin sets `scope: "server"` and is registered on a `standalone: true` app, which is served on its own and has no other apps to reach. Remove `scope` there. To apply a plugin to every app, list it in `@FrontMcp({ plugins })`, or set `scope: "server"` on an app that isn't standalone. ### My plugin's hook doesn't run for another app's tools A plugin in `@App({ plugins })` hooks `tools/call`, `resources/read` and `prompts/get` for that app's entries only. Give it `scope: "server"` (since 1.9.1), register it on `@FrontMcp` instead ([Registering a plugin on every app](https://frontmcp.dev/reference/sdk/plugin#registering-a-plugin-on-every-app)), or give the hook `appliesTo: "uncovered-apps"` so it also runs for apps that don't have the plugin ([Enforcing an option of your own](https://frontmcp.dev/reference/sdk/plugin#enforcing-an-option-of-your-own)). ### `Unenforced metadata: Tool "…" declares '…'` The server didn't start, with an `UnenforcedMetadataError`: an entry sets an option that only a plugin enforces, like `approval`, `featureFlag` or a key in a plugin's `enforcesMetadata`, and no hook of that plugin reaches the entry. Install the plugin on the server, on the entry's app, or, for a tool inside an `@Agent`, on the agent. A plugin of your own can also cover other apps with `appliesTo: "uncovered-apps"`. Or remove the option from the entry. See [Enforcing an option of your own](https://frontmcp.dev/reference/sdk/plugin#enforcing-an-option-of-your-own). On an edge isolate, [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler#assertstaticstartupconfigconfig) finds this when it's created, before the server is built, and judges each entry by the plugins that reach it, as the full check does: an agent's tool with the plugin only on its app is refused there too. Changed in 1.8.3: before, such an option did nothing where its plugin didn't reach, so an `approval: true` tool on a server without the Approval plugin ran without approval. ### `DynamicPlugin.get() is not implemented` `this.get()` was called in the plugin's constructor. FrontMCP connects `this.get()` to the plugin's providers when the server starts, after the constructor has run. Call it in hooks, or build what you need from options with `useFactory` or `dynamicProviders()`. ### `Type 'PluginReturn<…>' is not assignable to type 'PluginType'` TypeScript refuses a published plugin's `init()` in `plugins`, with `TS2322` and a long list of types from two paths, `node_modules/@frontmcp/sdk` and `node_modules//node_modules/@frontmcp/sdk`. The plugin brought its own copy of the SDK, because it lists `@frontmcp/sdk` in `dependencies` at a version the app doesn't have. Move it to the plugin's `peerDependencies` and publish again ([Publishing a plugin](https://frontmcp.dev/reference/sdk/plugin#publishing-a-plugin)), or install the same SDK version in the app as the plugin's, so npm keeps one copy. ### `ERESOLVE unable to resolve dependency tree` for a plugin's `peer @frontmcp/sdk` The app's `@frontmcp/sdk` is outside the plugin's `peerDependencies` range: `peer @frontmcp/sdk@"^1.10.11" from help-desk-usage-plugin@1.0.0` with `Found: @frontmcp/sdk@1.9.3`. Upgrade the app's FrontMCP packages to a version in the range, or use a release of the plugin built for yours. ### TypeScript says `Generic type 'DynamicPlugin' requires between 1 and 2 type arguments` `DynamicPlugin` has no default type argument. Write `extends DynamicPlugin` for a plugin without options, or `DynamicPlugin`. The same mistake also reports `TS1238` (`Unable to resolve signature of class decorator`) on `@Plugin`. --- # @Adapter > Write an adapter, a class whose fetch() builds tools, resources and prompts from another source when the server starts, and can replace them while it runs. Source: https://frontmcp.dev/reference/sdk/adapter [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `@Adapter` declares an adapter: a class that builds tools, resources and prompts from something other than your code, like an API's spec, the saved views in your help desk's admin, or a catalog in a database. FrontMCP calls its `fetch()` when the server starts and serves what it returns next to the app's own entries; the adapter can replace them while the server runs. Extend `DynamicAdapter` to get `init()`, so an app lists a configured copy in `adapters`. The [OpenAPI adapter](https://frontmcp.dev/reference/adapters/openapi) is one, written this way. To add behaviour around every call rather than entries, write a [plugin](https://frontmcp.dev/reference/sdk/plugin). ```ts @Adapter({ name }) class MyAdapter extends DynamicAdapter { options: { name: string } & Options; fetch(): FrontMcpAdapterResponse { /* { tools, resources, prompts } */ } } @App({ adapters: [MyAdapter.init({ name, ...options })] }) ``` --- ## Reference ### `@Adapter(options)` Apply `@Adapter` to a class that extends `DynamicAdapter`, and list `MyAdapter.init({ name, ... })` in the `adapters` of an `@App`, a `@Plugin` or `@FrontMcp`. This adapter turns each of the help desk's saved views, a named ticket filter, into a tool: ```ts saved-views.adapter.ts import { Adapter, DynamicAdapter, tool, type FrontMcpAdapterResponse } from "@frontmcp/sdk"; import { TicketStore, type TicketFilter } from "./ticket-store"; export interface SavedViewsOptions { views: { name: string; description: string; filter: TicketFilter }[]; } @Adapter({ name: "saved-views", description: "A tool for each saved ticket view" }) export class SavedViewsAdapter extends DynamicAdapter { options: { name: string } & SavedViewsOptions; constructor(options: { name: string } & SavedViewsOptions) { super(); this.options = options; } fetch(): FrontMcpAdapterResponse { return { tools: this.options.views.map((view) => tool({ name: `view_${view.name}`, description: view.description, inputSchema: {} })((_input, ctx) => ({ tickets: ctx.get(TicketStore).find(view.filter), })), ), }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { SavedViewsAdapter } from "./saved-views.adapter"; import { TicketStore } from "./ticket-store"; @App({ id: "help-desk", name: "Help Desk", providers: [TicketStore], adapters: [ SavedViewsAdapter.init({ name: "views", views: [{ name: "open_billing", description: "Open billing tickets", filter: { status: "open", tag: "billing" } }], }), ], }) export class HelpDeskApp {} ``` [See more examples below.](https://frontmcp.dev/reference/sdk/adapter#usage) #### Options | Option | Type | Description | | --- | --- | --- | | `name` | `string` | **Required.** Names the adapter class in FrontMCP's debug logs. The name that matters to clients is each copy's own, `options.name`: see [names](https://frontmcp.dev/reference/sdk/adapter#names). | | `description` | `string` | For people reading the code. Clients never see it. | | `id` | `string` | Accepted and unused. | `adapters` only takes a class with `@Adapter`, or what its `init()` returns: anything else fails when the `@App` decorator runs, with [`adapters items must be annotated with @Adapter()`](https://frontmcp.dev/reference/sdk/adapter#app-invalid-metadata-for-adapters-adapters-items-must-be-annotated-with-adapter--frontmcpadapter). #### `DynamicAdapter` The base class for adapters, exported by `@frontmcp/sdk`. `Options` is the type of what the adapter is configured with, besides `name`. | Member | Description | | --- | --- | | `options` | **Required.** Declare it as `options: { name: string } & Options` and set it in the constructor. FrontMCP reads `options.name`; the rest is yours. TypeScript reports a class without it. | | `fetch()` | **Required.** Returns the adapter's entries, `{ tools?, resources?, prompts? }`, or a promise of them. See [what `fetch()` returns](https://frontmcp.dev/reference/sdk/adapter#what-fetch-returns). | | `static init(options)` | Returns a record to list in `adapters`. `options` needs a non-empty `name`, unique among this class's `init()` calls in the whole process. Calls your constructor with `options` right away, when your module is imported. | | `static init({ name, inject, useFactory })` | Builds the adapter when the server starts: `useFactory` gets the providers `inject` returns, and returns the options, all but `name`, or a promise of them. FrontMCP calls your constructor with them and `name`. `inject` is optional. The factory may instead return an adapter it built, whose `options.name` must be `name`. See [building the options when the server starts](https://frontmcp.dev/reference/sdk/adapter#building-the-options-when-the-server-starts). Changed in 1.9: a factory's options were taken for the adapter, and the server didn't start. | Listing the class itself, `adapters: [MyAdapter]`, calls its constructor with no options, which suits a class whose `options` are written in the class. A `DynamicAdapter` that sets `options` from its constructor's argument fails that way with [`Expected an adapter`](https://frontmcp.dev/reference/sdk/adapter#invalid-adapter--expected-an-adapter-an-object-with-optionsname-and-fetch). Changed in 1.9.7: `init()` is declared to return only a value or factory record, which is what it produces. Before, its return type also included `useClass`, which the adapter validator rejects, so passing the result to a creation function could fail to compile. #### Optional methods FrontMCP calls these when the adapter defines them, in this order: | Method | When | What to do | | --- | --- | --- | | `setLogger(logger)` | Before `fetch()` | Keep the logger. Its lines are labelled `adapter:`. | | `onUpdate(callback)` | After the entries from `fetch()` are registered | Keep `callback`, and call it with a new `{ tools?, resources?, prompts? }` whenever your source changes. Return a function that drops it. | | `startPolling()` | After `onUpdate()`, once for each adapter instance | Start watching your source, like a timer that checks it. | | `stopPolling()` | When the server is disposed, after the function `onUpdate()` returned is called | Stop watching: clear the timer. An adapter that several servers serve is stopped when the last of them is disposed. | Changed in 1.9: disposing a server didn't call `stopPolling()` or drop the `onUpdate()` callback. #### What `fetch()` returns `FrontMcpAdapterResponse` has three lists, of the kinds an `@App` takes: | Field | Holds | | --- | --- | | `tools` | [`@Tool`](https://frontmcp.dev/reference/sdk/tool) classes, or tools written with [`tool()`](https://frontmcp.dev/reference/sdk/tool#writing-a-tool-as-a-function) | | `resources` | [`@Resource`](https://frontmcp.dev/reference/sdk/resource) classes, or [`resource()`](https://frontmcp.dev/reference/sdk/resource#writing-a-resource-as-a-function) | | `prompts` | [`@Prompt`](https://frontmcp.dev/reference/sdk/prompt) classes, or [`prompt()`](https://frontmcp.dev/reference/sdk/prompt) | There's no field for agents, skills, jobs or providers: put those in the app. The entries are served as the app's own and get the app's providers, with `this.get()` in a class and `ctx.get()` in a function. A `fetch()` that throws or rejects stops the server from starting, with its error. A new response passed to the `onUpdate()` callback replaces the lists it has: `{ tools: [] }` removes every tool the adapter added and keeps its resources and prompts. A client connected with [`server.connect()`](https://frontmcp.dev/reference/sdk/connect) gets `notifications/tools/list_changed`, and sees the new list the next time it lists. #### Where you register an adapter | Registered in | Its entries are served | | --- | --- | | `@App({ adapters })` | By that app | | `@Plugin({ adapters })` | By the app the plugin is on | | `@FrontMcp({ adapters })` | Once for the server, next to every app's entries. `fetch()` runs once, however many apps there are. Changed in 1.9: `@FrontMcp` ignored `adapters`. | | `create({ adapters })` | By the server's one app ([`create()`](https://frontmcp.dev/reference/sdk/create)) | #### Names An adapter's entries keep the names `fetch()` gives them. When two sources give a tool the same name, each is listed with its source in front: an adapter's with its `options.name`, the app's own tools with the app's `id`. Two copies of the saved-views adapter named `tickets` and `kb`, each with a view called `search`, list `tickets:view_search` and `kb:view_search`, and an app `desk` with a `view_search` tool of its own would list it as `desk:view_search`. See [handling name clashes](https://frontmcp.dev/reference/server/apps#handling-name-clashes). #### Caveats - `init(options)` **constructs the adapter when your module is imported**, not when the server starts. Use the `useFactory` form for options that need a provider or the environment. - **`init()` names last for the process.** A second `init()` of the same class with the same `name` throws, even for another server, or when a module that calls it is loaded twice. Call `init()` once at module level and reuse the record. - **A record serves a fresh adapter in each server.** The first server built from an `init(options)` record gets the adapter `init()` made. A second server from the same record, like a test that builds the server again, gets a new one, constructed from the same options, and runs its `fetch()` again. The `useFactory` form builds one per server. Don't keep state two servers must share on the adapter. - `fetch()` runs while the server starts, so the server isn't ready until it returns. A source that's slow or down delays or stops the start. --- ## Usage ### Writing an adapter An adapter's job is to read a description and return entries. This one reads the help desk's saved views from its options and returns a tool for each. The tools are written with `tool()`, since there's one per view rather than one class each, and get the app's `TicketStore` with `ctx.get()`: Initial call configuration: ```js { tool: "view_open_billing", arguments: {} } ``` ```ts saved-views.adapter.ts active import { Adapter, DynamicAdapter, tool, type FrontMcpAdapterResponse } from "@frontmcp/sdk"; import { TicketStore, type TicketFilter } from "./ticket-store"; export interface SavedViewsOptions { views: { name: string; description: string; filter: TicketFilter }[]; } @Adapter({ name: "saved-views", description: "A tool for each saved ticket view" }) export class SavedViewsAdapter extends DynamicAdapter { options: { name: string } & SavedViewsOptions; constructor(options: { name: string } & SavedViewsOptions) { super(); this.options = options; } fetch(): FrontMcpAdapterResponse { return { tools: this.options.views.map((view) => tool({ name: `view_${view.name}`, description: view.description, inputSchema: {} })((_input, ctx) => ({ tickets: ctx.get(TicketStore).find(view.filter), })), ), }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { SavedViewsAdapter } from "./saved-views.adapter"; import { TicketStore } from "./ticket-store"; @App({ id: "help-desk", name: "Help Desk", providers: [TicketStore], adapters: [ SavedViewsAdapter.init({ name: "views", views: [ { name: "open_billing", description: "Open billing tickets", filter: { status: "open", tag: "billing" } }, { name: "all_open", description: "Every open ticket", filter: { status: "open" } }, ], }), ], }) export class HelpDeskApp {} ``` ```ts ticket-store.ts import { Provider } from "@frontmcp/sdk"; export interface TicketFilter { status?: "open" | "closed"; tag?: string; } @Provider({ name: "TicketStore" }) export class TicketStore { private tickets = [ { id: "T-1", title: "Cannot log in", status: "open", tag: "login" }, { id: "T-2", title: "Invoice total is wrong", status: "closed", tag: "billing" }, { id: "T-3", title: "Charged twice", status: "open", tag: "billing" }, ]; find(filter: TicketFilter) { return this.tickets.filter( (t) => (!filter.status || t.status === filter.status) && (!filter.tag || t.tag === filter.tag), ); } } ``` ```ts saved-views.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { SavedViewsAdapter } from "./saved-views.adapter"; import { TicketStore } from "./ticket-store"; test("each saved view is a tool, described as the view is", async ({ mcp }) => { const tools = await mcp.tools.list(); expect(tools.map((t: { name: string }) => t.name).sort()).toEqual(["view_all_open", "view_open_billing"]); expect(tools.find((t: { name: string }) => t.name === "view_open_billing")?.description).toBe("Open billing tickets"); }); test("a view's tool gets the app's TicketStore", async ({ mcp }) => { const result = await mcp.tools.call("view_all_open", {}); expect(result).toBeSuccessful(); expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-1", "T-3"]); }); test("two copies with the same tool name are listed under their names", async () => { const view = { name: "search", description: "Open tickets", filter: { status: "open" as const } }; @App({ id: "desk", name: "Desk", providers: [TicketStore], adapters: [ SavedViewsAdapter.init({ name: "tickets", views: [view] }), SavedViewsAdapter.init({ name: "kb", views: [view] }), ], }) class Desk {} const server = await FrontMcpInstance.createDirect({ info: { name: "desk", version: "1.0.0" }, apps: [Desk] }); try { const { tools } = await server.listTools(); expect(tools.map((t: { name: string }) => t.name).sort()).toEqual(["kb:view_search", "tickets:view_search"]); } finally { await server.dispose(); } }); ``` Add a view to the list in `help-desk.app.ts` and its tool appears. The app has no tool classes of its own: everything in `tools/list` came from `fetch()`. ### Adding resources and prompts `fetch()` can return resources and prompts too, in the same response. Here the adapter also serves the list of views as a resource, so a client can show them, and a prompt that asks the model to work through one: Initial call configuration: ```js { resource: "views://saved" } ``` ```ts saved-views.adapter.ts active import { Adapter, DynamicAdapter, prompt, resource, tool, type FrontMcpAdapterResponse } from "@frontmcp/sdk"; import { TicketStore, type TicketFilter } from "./ticket-store"; export interface SavedViewsOptions { views: { name: string; description: string; filter: TicketFilter }[]; } @Adapter({ name: "saved-views", description: "Saved ticket views as tools, a resource and a prompt" }) export class SavedViewsAdapter extends DynamicAdapter { options: { name: string } & SavedViewsOptions; constructor(options: { name: string } & SavedViewsOptions) { super(); this.options = options; } fetch(): FrontMcpAdapterResponse { const { views } = this.options; return { tools: views.map((view) => tool({ name: `view_${view.name}`, description: view.description, inputSchema: {} })((_input, ctx) => ({ tickets: ctx.get(TicketStore).find(view.filter), })), ), resources: [ resource({ name: "saved-views", uri: "views://saved", mimeType: "application/json", description: "The saved ticket views" })( (uri) => ({ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(views.map(({ name, description }) => ({ name, description }))) }], }), ), ], prompts: [ prompt({ name: "work_through_view", description: "Go through a saved view's tickets one by one", arguments: [{ name: "view", description: `One of: ${views.map((v) => v.name).join(", ")}`, required: true }], })((args) => ({ messages: [ { role: "user", content: { type: "text", text: `Call view_${args?.view}, then suggest a next step for each ticket, oldest first.` } }, ], })), ], }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { SavedViewsAdapter } from "./saved-views.adapter"; import { TicketStore } from "./ticket-store"; @App({ id: "help-desk", name: "Help Desk", providers: [TicketStore], adapters: [ SavedViewsAdapter.init({ name: "views", views: [ { name: "open_billing", description: "Open billing tickets", filter: { status: "open", tag: "billing" } }, { name: "all_open", description: "Every open ticket", filter: { status: "open" } }, ], }), ], }) export class HelpDeskApp {} ``` ```ts ticket-store.ts import { Provider } from "@frontmcp/sdk"; export interface TicketFilter { status?: "open" | "closed"; tag?: string; } @Provider({ name: "TicketStore" }) export class TicketStore { private tickets = [ { id: "T-1", title: "Cannot log in", status: "open", tag: "login" }, { id: "T-2", title: "Invoice total is wrong", status: "closed", tag: "billing" }, { id: "T-3", title: "Charged twice", status: "open", tag: "billing" }, ]; find(filter: TicketFilter) { return this.tickets.filter( (t) => (!filter.status || t.status === filter.status) && (!filter.tag || t.tag === filter.tag), ); } } ``` ```ts saved-views.test.ts import { test, expect } from "@frontmcp/testing"; test("the views are a resource", async ({ mcp }) => { expect(await mcp.resources.list()).toContainResource("views://saved"); const views = (await mcp.resources.read("views://saved")).json(); expect(views.map((v: { name: string }) => v.name)).toEqual(["open_billing", "all_open"]); }); test("the prompt names the view's tool", async ({ mcp }) => { expect(await mcp.prompts.list()).toContainPrompt("work_through_view"); const result = await mcp.prompts.get("work_through_view", { view: "open_billing" }); expect(result).toHaveMessages(1); expect(result.messages[0].content).toMatchObject({ text: expect.stringContaining("Call view_open_billing") }); }); ``` ### Building the options when the server starts When the options come from a provider, like the help desk's configuration, use `init({ name, inject, useFactory })`. FrontMCP calls the factory while the server starts, with the providers `inject` lists, and builds the adapter from what it returns. The adapter class doesn't change: Initial call configuration: ```js { tool: "view_vip_open", arguments: {} } ``` ```ts help-desk.app.ts active import { App } from "@frontmcp/sdk"; import { DeskConfig } from "./desk-config"; import { SavedViewsAdapter } from "./saved-views.adapter"; import { TicketStore } from "./ticket-store"; @App({ id: "help-desk", name: "Help Desk", providers: [TicketStore, DeskConfig], adapters: [ SavedViewsAdapter.init({ name: "views", inject: () => [DeskConfig] as const, useFactory: (config: DeskConfig) => ({ views: config.savedViews }), }), ], }) export class HelpDeskApp {} ``` ```ts desk-config.ts import { Provider } from "@frontmcp/sdk"; import type { SavedViewsOptions } from "./saved-views.adapter"; @Provider({ name: "DeskConfig" }) export class DeskConfig { // In a real server, read from a file or a database savedViews: SavedViewsOptions["views"] = [ { name: "vip_open", description: "Open tickets from VIP customers", filter: { status: "open", tag: "vip" } }, ]; } ``` ```ts saved-views.adapter.ts import { Adapter, DynamicAdapter, tool, type FrontMcpAdapterResponse } from "@frontmcp/sdk"; import { TicketStore, type TicketFilter } from "./ticket-store"; export interface SavedViewsOptions { views: { name: string; description: string; filter: TicketFilter }[]; } @Adapter({ name: "saved-views", description: "A tool for each saved ticket view" }) export class SavedViewsAdapter extends DynamicAdapter { options: { name: string } & SavedViewsOptions; constructor(options: { name: string } & SavedViewsOptions) { super(); this.options = options; } fetch(): FrontMcpAdapterResponse { return { tools: this.options.views.map((view) => tool({ name: `view_${view.name}`, description: view.description, inputSchema: {} })((_input, ctx) => ({ tickets: ctx.get(TicketStore).find(view.filter), })), ), }; } } ``` ```ts ticket-store.ts import { Provider } from "@frontmcp/sdk"; export interface TicketFilter { status?: "open" | "closed"; tag?: string; } @Provider({ name: "TicketStore" }) export class TicketStore { private tickets = [ { id: "T-1", title: "Cannot log in", status: "open", tag: "login" }, { id: "T-4", title: "Export is empty", status: "open", tag: "vip" }, ]; find(filter: TicketFilter) { return this.tickets.filter( (t) => (!filter.status || t.status === filter.status) && (!filter.tag || t.tag === filter.tag), ); } } ``` ```ts desk-config.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { SavedViewsAdapter } from "./saved-views.adapter"; test("the views come from DeskConfig", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("view_vip_open"); const result = await mcp.tools.call("view_vip_open", {}); expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-4"]); }); test("a factory that returns something else stops the server", async () => { @App({ id: "desk", name: "Desk", adapters: [SavedViewsAdapter.init({ name: "broken-views", useFactory: () => undefined as never })], }) class Desk {} await expect( FrontMcpInstance.createDirect({ info: { name: "desk", version: "1.0.0" }, apps: [Desk] }), ).rejects.toThrow("Invalid adapter 'broken-views'. Expected useFactory to return the adapter's options object."); }); ``` The factory can be `async`, so it can read a file or call a service before the server takes requests. ### Serving the entries from every app An adapter in `@FrontMcp({ adapters })` belongs to the server: its entries are listed once, next to every app's, and `fetch()` runs once. Its tools get the server's providers, so `TicketStore` moves to `@FrontMcp({ providers })` here: Initial call configuration: ```js { tool: "view_all_open", arguments: {} } ``` ```ts main.ts active import { App, FrontMcp, tool, z } from "@frontmcp/sdk"; import { SavedViewsAdapter } from "./saved-views.adapter"; import { TicketStore } from "./ticket-store"; const getTicket = tool({ name: "get_ticket", description: "Get one ticket by its id", inputSchema: { id: z.string() } })( ({ id }, ctx) => ({ ticket: ctx.get(TicketStore).find({}).find((t) => t.id === id) }), ); const searchArticles = tool({ name: "search_articles", description: "Search help articles", inputSchema: { query: z.string() } })( ({ query }) => ({ articles: [`How to fix: ${query}`] }), ); @App({ id: "tickets", name: "Tickets", tools: [getTicket] }) class TicketsApp {} @App({ id: "kb", name: "Knowledge Base", tools: [searchArticles] }) class KnowledgeBaseApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [TicketsApp, KnowledgeBaseApp], providers: [TicketStore], adapters: [ SavedViewsAdapter.init({ name: "views", views: [{ name: "all_open", description: "Every open ticket", filter: { status: "open" } }] }), ], }) export default class Server {} ``` ```ts saved-views.adapter.ts import { Adapter, DynamicAdapter, tool, type FrontMcpAdapterResponse } from "@frontmcp/sdk"; import { TicketStore, type TicketFilter } from "./ticket-store"; export interface SavedViewsOptions { views: { name: string; description: string; filter: TicketFilter }[]; } @Adapter({ name: "saved-views", description: "A tool for each saved ticket view" }) export class SavedViewsAdapter extends DynamicAdapter { options: { name: string } & SavedViewsOptions; constructor(options: { name: string } & SavedViewsOptions) { super(); this.options = options; } fetch(): FrontMcpAdapterResponse { return { tools: this.options.views.map((view) => tool({ name: `view_${view.name}`, description: view.description, inputSchema: {} })((_input, ctx) => ({ tickets: ctx.get(TicketStore).find(view.filter), })), ), }; } } ``` ```ts ticket-store.ts import { Provider } from "@frontmcp/sdk"; export interface TicketFilter { status?: "open" | "closed"; tag?: string; } @Provider({ name: "TicketStore" }) export class TicketStore { private tickets = [ { id: "T-1", title: "Cannot log in", status: "open", tag: "login" }, { id: "T-2", title: "Invoice total is wrong", status: "closed", tag: "billing" }, { id: "T-3", title: "Charged twice", status: "open", tag: "billing" }, ]; find(filter: TicketFilter) { return this.tickets.filter( (t) => (!filter.status || t.status === filter.status) && (!filter.tag || t.tag === filter.tag), ); } } ``` ```ts server.test.ts import { test, expect } from "@frontmcp/testing"; test("the adapter's tool is listed once, next to both apps' tools", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t: { name: string }) => t.name).sort(); expect(names).toEqual(["get_ticket", "search_articles", "view_all_open"]); }); test("it gets the server's TicketStore", async ({ mcp }) => { const result = await mcp.tools.call("view_all_open", {}); expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-1", "T-3"]); }); ``` To bring an adapter along with a plugin, list it in the plugin's own `adapters`: its entries join the app the plugin is on. ### Updating the entries while the server runs An adapter whose source changes can replace its entries without a restart. FrontMCP calls `onUpdate()` with a callback once `fetch()`'s entries are registered, then `startPolling()`; call the callback with a new response whenever the source changes. Here the saved views live in a `ViewCatalog` provider that a `save_view` tool writes to, and the adapter checks it every 100 ms. The call below saves a view, and the **Tests** tab shows its tool appear at the next check: Initial call configuration: ```js { tool: "save_view", arguments: { name: "open_billing", description: "Open billing tickets", status: "open", tag: "billing" } } ``` ```ts live-views.adapter.ts active import { Adapter, DynamicAdapter, tool, type FrontMcpAdapterResponse, type FrontMcpLogger } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; import type { ViewCatalog } from "./view-catalog"; export interface LiveViewsOptions { catalog: ViewCatalog; everyMs: number; } type Listener = (response: FrontMcpAdapterResponse) => void; @Adapter({ name: "live-views", description: "A tool for each saved view, kept up to date" }) export class LiveViewsAdapter extends DynamicAdapter { options: { name: string } & LiveViewsOptions; private listeners = new Set(); private timer?: ReturnType; private seen = -1; private logger?: FrontMcpLogger; constructor(options: { name: string } & LiveViewsOptions) { super(); this.options = options; } setLogger(logger: FrontMcpLogger) { this.logger = logger; } fetch(): FrontMcpAdapterResponse { this.seen = this.options.catalog.revision; return this.build(); } onUpdate(listener: Listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } startPolling() { this.timer = setInterval(() => { const { catalog } = this.options; if (catalog.revision === this.seen) return; this.seen = catalog.revision; this.logger?.info(`Saved views changed: ${catalog.views.length} now`); const response = this.build(); for (const listener of this.listeners) listener(response); }, this.options.everyMs); } stopPolling() { clearInterval(this.timer); } private build(): FrontMcpAdapterResponse { return { tools: this.options.catalog.views.map((view) => tool({ name: `view_${view.name}`, description: view.description, inputSchema: {} })((_input, ctx) => ({ tickets: ctx.get(TicketStore).find(view.filter), })), ), }; } } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { LiveViewsAdapter } from "./live-views.adapter"; import { TicketStore } from "./ticket-store"; import { ViewCatalog } from "./view-catalog"; @Tool({ name: "save_view", description: "Save a ticket view. It becomes a tool named view_.", inputSchema: { name: z.string().regex(/^[a-z_]+$/).describe("Lowercase words joined by _, like open_billing"), description: z.string(), status: z.enum(["open", "closed"]).optional(), tag: z.string().optional(), }, }) export class SaveView extends ToolContext { async execute({ name, description, status, tag }: { name: string; description: string; status?: "open" | "closed"; tag?: string }) { this.get(ViewCatalog).save({ name, description, filter: { status, tag } }); return { saved: `view_${name}` }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SaveView], providers: [TicketStore, ViewCatalog], adapters: [ LiveViewsAdapter.init({ name: "views", inject: () => [ViewCatalog] as const, useFactory: (catalog: ViewCatalog) => ({ catalog, everyMs: 100 }), }), ], }) export class HelpDeskApp {} ``` ```ts view-catalog.ts import { Provider } from "@frontmcp/sdk"; import type { TicketFilter } from "./ticket-store"; export interface SavedView { name: string; description: string; filter: TicketFilter; } @Provider({ name: "ViewCatalog" }) export class ViewCatalog { revision = 0; views: SavedView[] = [{ name: "all_open", description: "Every open ticket", filter: { status: "open" } }]; save(view: SavedView) { this.views = [...this.views.filter((v) => v.name !== view.name), view]; this.revision++; } } ``` ```ts ticket-store.ts import { Provider } from "@frontmcp/sdk"; export interface TicketFilter { status?: "open" | "closed"; tag?: string; } @Provider({ name: "TicketStore" }) export class TicketStore { private tickets = [ { id: "T-1", title: "Cannot log in", status: "open", tag: "login" }, { id: "T-2", title: "Invoice total is wrong", status: "closed", tag: "billing" }, { id: "T-3", title: "Charged twice", status: "open", tag: "billing" }, ]; find(filter: TicketFilter) { return this.tickets.filter( (t) => (!filter.status || t.status === filter.status) && (!filter.tag || t.tag === filter.tag), ); } } ``` ```ts live-views.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; const nextCheck = () => new Promise((resolve) => setTimeout(resolve, 250)); test("a saved view becomes a tool at the next check", async ({ mcp }) => { expect(await mcp.tools.list()).not.toContainTool("view_open_billing"); await mcp.tools.call("save_view", { name: "open_billing", description: "Open billing tickets", status: "open", tag: "billing" }); await nextCheck(); expect(await mcp.tools.list()).toContainTool("view_open_billing"); const result = await mcp.tools.call("view_open_billing", {}); expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-3"]); }); test("a connected client hears that the tools changed", async () => { const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); const client = await server.connect(); const heard: string[] = []; client.onNotification((notification: { method: string }) => { heard.push(notification.method); }); try { await client.callTool("save_view", { name: "vip", description: "VIP tickets", tag: "vip" }); await nextCheck(); expect(heard).toContain("notifications/tools/list_changed"); } finally { await client.close(); await server.dispose(); } }); ``` The adapter's log line is labelled `adapter:views`, from the logger `setLogger()` was given. When the server is disposed, FrontMCP calls the function `onUpdate()` returned, then `stopPolling()`, which clears the timer. For a source on another service, check it from `startPolling()`'s timer the same way, and compare a version or an `ETag` rather than rebuilding every time. The [OpenAPI adapter's `polling`](https://frontmcp.dev/reference/adapters/openapi#picking-up-spec-changes) works like this. ### Publishing an adapter An adapter other teams install is an npm package, set up like a plugin's: see [Publishing a plugin](https://frontmcp.dev/reference/sdk/plugin#publishing-a-plugin). Export the class and its options type from the package's entry, and list `@frontmcp/sdk` as a peer dependency, so the adapter extends the app's own copy of `DynamicAdapter`. The keyword `frontmcp-adapter` lets people find it with `npm search keywords:frontmcp-adapter`; nothing in FrontMCP reads it. --- ## Troubleshooting ### `Adapter ….init() requires a non-empty 'name' option` `init()` was called without a `name`, or with an empty one. Every copy needs its own: it's what the tools are listed under when names clash, and its logger's label. ### `Duplicate adapter name '…' for …` Two `init()` calls of the same class used the same `name` in one process. Give each copy its own name. With only one call in your code, `init()` ran twice: a module loaded twice, or a function that builds the configuration called again. Call `init()` once at module level and reuse the record. See [Caveats](https://frontmcp.dev/reference/sdk/adapter#caveats). ### `@App invalid metadata for "adapters"`: `adapters items must be annotated with @Adapter() | @FrontMcpAdapter()` Something in `adapters` isn't an adapter: a class without `@Adapter`, or a tool or a plugin. Add `@Adapter({ name })` to the class, or move the entry to `tools` or `plugins`. It fails when the decorator runs, as your module is imported. ### ``Invalid adapter '…'. Expected an adapter: an object with `options.name` and `fetch()`.`` A `DynamicAdapter` was listed as its class, `adapters: [SavedViewsAdapter]`, so its constructor got no options and `options.name` is missing. List `SavedViewsAdapter.init({ name, ... })` instead. ### `Invalid adapter '…'. Expected useFactory to return the adapter's options object.` The factory of an `init({ name, inject, useFactory })` record returned something other than an object, like `undefined` from a function that forgot to `return`. The server doesn't start. ### `Invalid adapter '…'. Expected useFactory to return an adapter named '…', the name given to init(), not '…'.` The factory built the adapter itself, with an `options.name` other than the one given to `init()`. Use the same name, or return the options and let FrontMCP build it. ### `Provider "…" is not available: not found in local or parent registries` A provider in `inject` isn't registered where the adapter is: add it to the `providers` of the same app, or of `@FrontMcp` for an adapter on the server. ### TypeScript says `Non-abstract class '…' does not implement inherited abstract member options` The class extends `DynamicAdapter` and doesn't declare `options`. Add `options: { name: string } & Options;` and set it in the constructor, as in [the example](https://frontmcp.dev/reference/sdk/adapter#adapteroptions). ### The server doesn't start, with an error from my source `fetch()` threw or rejected, and FrontMCP stops the start with that error. Catch what can fail and return fewer entries, or none, when the server should start anyway. ### My adapter's tool is listed as `views:view_…` Another adapter, or one of the app's own tools, has a tool of the same name, so each is listed with its source in front. Rename one. See [names](https://frontmcp.dev/reference/sdk/adapter#names). --- # @Channel > Push events into a connected Claude Code session as they happen, with @Channel. Sources, ChannelContext, emitting events from your code, two-way replies, hooks, and exactly what a client receives. Source: https://frontmcp.dev/reference/sdk/channel [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `@Channel` declares a channel: a stream of events that your server pushes into a connected Claude Code session without being asked, like "billing is down" or "the customer replied". FrontMCP sends each event as a `notifications/claude/channel` message, and tells the model, in the server's instructions, that events arrive as `` tags. A two-way channel also gets a `channel-reply` tool, so the model can answer. Channels are an experimental Claude Code extension of MCP, and only reach clients that ask for them: a client that keeps a session asks when it connects, and a 2026-07-28 client asks on a `subscriptions/listen` stream. See [what a client receives](https://frontmcp.dev/reference/sdk/channel#what-a-client-receives). ```ts @Channel(options) class MyChannel extends ChannelContext { async onEvent(payload: unknown): Promise { /* ... */ } } ``` --- ## Reference ### `@Channel(options)` Apply `@Channel` to a class that extends `ChannelContext`, list the class in an app's `channels` array, and turn channels on in `@FrontMcp`: ```ts outages.channel.ts import { Channel, ChannelContext, type ChannelNotification } from "@frontmcp/sdk"; @Channel({ name: "outages", description: "Service outages as they happen", source: { type: "app-event", event: "outage" }, meta: { team: "sre" }, }) export class OutagesChannel extends ChannelContext { async onEvent(payload: unknown): Promise { const { service } = payload as { service: string }; return { content: `${service} is down`, meta: { service } }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { OutagesChannel } from "./outages.channel"; @App({ id: "ops", name: "Ops", tools: [ReportOutage], channels: [OutagesChannel] }) export class OpsApp {} @FrontMcp({ info: { name: "ops", version: "1.0.0" }, apps: [OpsApp], channels: { enabled: true } }) export default class Server {} ``` [See more examples below.](https://frontmcp.dev/reference/sdk/channel#usage) #### Options Required: | Option | Type | Description | | --- | --- | --- | | `name` | `string` | The channel's name, unique on the server: a second channel with the same name stops the server from starting. It's sent as `meta.source` with every event, and it's what `channel-reply` takes. | | `source` | `ChannelSourceConfig` | Where events come from. See [sources](https://frontmcp.dev/reference/sdk/channel#sources). | Optional: | Option | Type | Default | Description | | --- | --- | --- | --- | | `meta` | `Record` | none | Added to the `meta` of every event from this channel. Keys may only have letters, digits and underscores: `"my-env"` fails when the class is decorated, with `Meta keys must be valid identifiers (letters, digits, underscores)`. | | `twoWay` | `boolean` | `false` | Registers the `channel-reply` tool, which calls this channel's [`onReply()`](https://frontmcp.dev/reference/sdk/channel#channelcontext). See [letting the model reply](https://frontmcp.dev/reference/sdk/channel#letting-the-model-reply). | | `tools` | tool classes | none | Tools that come with the channel, like one that sends a message through the same service. They're registered on the server and listed like any app's tools, and see the providers of the channel's app and of the server, as the channel does. (Changed in 1.9.3: before, they saw only the server's.) | | `replay` | `{ enabled: boolean; maxEvents?: number }` | off | Keeps the last `maxEvents` events (default 50) on the channel. Nothing sends them automatically: see [replaying missed events](https://frontmcp.dev/reference/sdk/channel#replaying-missed-events). | | `description` | `string` | none | For people reading the code. FrontMCP doesn't send it to clients. | | `tags` | `string[]` | `[]` | Labels. Only the `channels:list` flow returns them. | | `availableWhen` | `EntryAvailability` | none | Registers the channel only where the server runs on a matching platform or runtime. Elsewhere it isn't registered: its events go nowhere, and a two-way channel's `channel-reply` tool isn't listed. See [Offering an agent or a channel only where it works](https://frontmcp.dev/reference/server/environment#offering-an-agent-or-a-channel-only-where-it-works). (Changed in 1.9.2: before, it was ignored.) | #### Sources `source.type` decides what calls the channel's `onEvent()`: | `type` | Fields | What runs `onEvent(payload)` | Status | | --- | --- | --- | --- | | `"app-event"` | `event: string` | Your code, with `channelEventBus.emit(event, payload)` ([emitting events](https://frontmcp.dev/reference/sdk/channel#emitting-events-from-your-code)). The payload is what you emit. | Works | | `"manual"` | none | Your code, with `channel.handleEvent(payload)`. | Works | | `"service"` | `service: string`, a label | `this.pushIncoming(payload)`, called from the connection your [`onConnect()`](https://frontmcp.dev/reference/sdk/channel#channelcontext) opens. | Works | | `"file-watcher"` | `paths: string[]`, `events?: ("change" \| "create" \| "delete" \| "rename")[]` | The same as `"service"`: FrontMCP calls `onConnect()` and watches nothing itself, so start the watcher there and call `pushIncoming()`. | Works | | `"webhook"` | `path: string` | An HTTP `POST` to `path`, on FrontMCP's Node server. The payload is `{ body, headers, method, query }`, and the request gets `200` `{"ok":true,"channel":""}`. | Works on the Node server. Also served by [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler). Changed in 1.9.5: before, fetch handlers returned `404`. | | `"job-completion"` | `jobNames?: string[]` | A job finishing. The payload is `{ jobName, jobId, status, durationMs, output, attempt, sessionId }`, with `output` the result as JSON text, or `error` for a failed run. | Works. The event goes only to the session that ran the job. | | `"agent-completion"` | `agentIds?: string[]` | An agent finishing. The payload is `{ agentId, agentName, status, durationMs, output, runId, sessionId }`. | Works, like `"job-completion"`. | Changed in 1.8.5: before, the last three sources never called `onEvent()`. A webhook's `path` must not be one of FrontMCP's own paths, and two channels can't share one, or the server doesn't start. While `onEvent()` handles a webhook, it and the tools it calls with `this.callTool()` are on the `"http-trigger"` [surface](https://frontmcp.dev/reference/sdk/context#getcallsurface-and-getrunningtool). The webhook route was checked in Node, on a server started with `bootstrap()`. #### `ChannelContext` The class your channel extends. | Member | Description | | --- | --- | | `onEvent(payload)` | **Required.** Turns a source's payload into the event to send: `{ content: string; meta?: Record }`, the event's text and its attributes. If it throws, nothing is sent, and the server logs `Channel "…" failed to handle event`. | | `onReply(reply, meta?)` | For two-way channels: called by `channel-reply` with the model's `text` and `meta`. If it throws, or the channel doesn't define it, `channel-reply` fails, and the model reads `Error: Failed to send reply to channel "…": ` and the error's message. (Changed in 1.9: before, the model was told the reply was sent either way.) | | `onConnect()` | For `"service"` and `"file-watcher"` sources: called once, when the server starts. Open the connection here and call `this.pushIncoming()` for each incoming message. If it throws, the server doesn't start. | | `onDisconnect()` | For closing that connection: called once, when the server stops, or when `dispose()` of a `create()` or `createDirect()` server runs. (Changed in 1.9: before, `dispose()` didn't call it.) | | `this.pushIncoming(payload)` | `protected`. Runs `onEvent(payload)` on the same instance `onConnect()` ran on, and sends the result. Only works after `onConnect()`. | | `this.get(token)`, `this.tryGet(token)` | [Providers](https://frontmcp.dev/reference/sdk/provider), from the channel's app and from the server. (Changed in 1.9.2: before, only the server's.) | | `this.metadata` | The channel's options. | A service channel's `onConnect()`, `pushIncoming()`, `onEvent()` and `onReply()` all run on one long-lived instance, so it can keep the connection in a field. For other sources, FrontMCP makes a new instance for each event and each reply. #### Emitting events from your code The channel system belongs to the server's scope, which any tool, resource or prompt reaches as `this.scope`: `this.scope.channels`, `this.scope.channelEventBus` and `this.scope.channelNotifications`, all `undefined` unless `channels.enabled` is `true`. (Changed in 1.9: before, the `ScopeEntry` type didn't declare them, so code had to cast `this.scope`.) | Member | Description | | --- | --- | | `channelEventBus.emit(event, payload)` | Runs `onEvent(payload)` of every `"app-event"` channel whose `source.event` is `event`, and sends what each returns. | | `channels.findByName(name)` | The channel's `ChannelInstance`, or `undefined`. | | `channels.getChannelInstances()` | Every channel on the server. | | `channel.handleEvent(payload, sessionId?)` | Runs `onEvent(payload)` and sends the result, to one session if you pass its id. Returns the event, or `null` if `onEvent()` threw. | | `channel.pushNotification(content, meta?, sessionId?)` | Sends an event without calling `onEvent()`, to one session if you pass its id. Returns a promise (since 1.9). | | `channel.replayBuffer` | The events kept by `replay`, oldest first. | | `channel.replayBufferedEvents(sessionId)` | Sends the kept events to one session, each with `meta.replayed: "true"`. Returns how many. | | `channel.clearReplayBuffer()` | Empties the buffer. | | `channelNotifications.send(name, content, meta?)` | Sends an event on the channel called `name`, without `onEvent()`. Returns a promise (since 1.9). | `this.scope.notifications.unsubscribeChannel(sessionId, name)` stops one session receiving a channel's events, and `subscribeChannel(sessionId, name)` starts it again. #### `@FrontMcp({ channels })` | Field | Type | Description | | --- | --- | --- | | `enabled` | `boolean` | **Required.** Without `enabled: true`, the `channels` of every app are ignored: no `channel-reply` tool, no channel tools, and `this.scope.channels` is `undefined`. | | `defaultMeta` | `Record` | Added to the `meta` of every event, before the channel's `meta` and the event's own, which win. (Changed in 1.9: before, only `channelNotifications.send()` added it.) | `@App({ channels })` is the only place to list channels. A plugin can't add one. #### What a client receives A client has to ask for channels, with `experimental: { "claude/channel": {} }` in its capabilities. A client that keeps a session (protocol versions before 2026-07-28) asks in its `initialize` request: FrontMCP subscribes its session to every channel, and each event arrives as a notification on the session's stream, the `GET` SSE stream for Streamable HTTP or stdout for stdio. A 2026-07-28 client has no session: it asks in a `subscriptions/listen` request, and the events arrive on that request's stream (see [below](https://frontmcp.dev/reference/sdk/channel#receiving-events-on-a-2026-07-28-client)). ```json { "jsonrpc": "2.0", "method": "notifications/claude/channel", "params": { "content": "billing is down", "meta": { "team": "sre", "service": "billing", "source": "outages" } } } ``` - `meta` is the server's `defaultMeta`, then the channel's `meta`, then the `meta` that `onEvent()` returned, then `source`, the channel's name, which always wins. - The `initialize` result, and under 2026-07-28 the `server/discover` result, lists `experimental: { "claude/channel": {} }` in the server's capabilities, and adds `Events arrive as tags.` to its `instructions`, followed by ` Reply with the channel-reply tool.` when a channel is two-way. (Changed in 1.9.3: before, `server/discover` had neither.) - A session whose client didn't ask for channels receives nothing, and neither does a `subscriptions/listen` stream that didn't. - On a **2026-07-28** stream, each event also has the stream's `io.modelcontextprotocol/subscriptionId` in its `params._meta`. Events sent to one session, like `"job-completion"` events, never reach a stream. (Changed in 1.9.2: before, 2026-07-28 clients received no events at all.) - Events sent while nobody is subscribed are dropped, unless `replay` keeps them. This was checked against a real FrontMCP server, over Streamable HTTP with a session (1.9.1) and over stdio (1.8.7), and on the 2026-07-28 stream with `createFetchHandler()` (1.9.3), as [below](https://frontmcp.dev/reference/sdk/channel#receiving-events-on-a-2026-07-28-client). #### What the Playground can show The Playground's client speaks 2026-07-28 and doesn't open a `subscriptions/listen` stream, so **no channel event reaches its Call tab**. Everything else runs: channels register, `onEvent()` runs and returns its event, `channel-reply` calls `onReply()`, a service channel's `onConnect()` runs at startup, and channel tools are listed. Most examples below give their channels `replay`, and read `replayBuffer` to show each event exactly as FrontMCP would send it; [one](https://frontmcp.dev/reference/sdk/channel#receiving-events-on-a-2026-07-28-client) opens a stream of its own in a test. #### Caveats - Channels only reach clients that asked for `claude/channel`: a session's `initialize`, like Claude Code's over stdio or Streamable HTTP, or a 2026-07-28 `subscriptions/listen` request. - A `"webhook"` source works on Node and through `createFetchHandler()`. `"job-completion"` and `"agent-completion"` events go only to the session that ran the job or agent, so a 2026-07-28 client, which has no session, never gets them. - Changed in 1.10.0: before, `create()`, `createDirect()` and `connect()` in its default mode registered webhook routes without serving HTTP. They now skip webhook registration. A configured webhook warns `Channel "…" has a webhook source but no HTTP server to serve …`. `connect()`'s CLI mode keeps its existing initialization. Use an HTTP entry point, including `createFetchHandler()`. - `replay` only keeps events; nothing replays them unless you call `replayBufferedEvents()`. - `description` is accepted and has no effect. #### Hooks on channel flows `ChannelSendHook` and `ChannelListHook` hook the `channels:send-notification` flow (stages `parseInput`, `resolveMeta`, `send`, `finalize`) and the `channels:list` flow (`listChannels`, `finalize`). Every event goes through `channels:send-notification`, however it's sent: `emit()`, `handleEvent()`, `pushNotification()` or `channelNotifications.send()`. `resolveMeta` is where `defaultMeta` and the channel's `meta` are added. A session that asks for channels is subscribed to the ones the `channels:list` flow returns. See [Hooking every event](https://frontmcp.dev/reference/sdk/channel#hooking-every-event). Changed in 1.9: before, FrontMCP never ran either flow itself, so their hooks only ran when your code started the flow. --- ## Usage ### Declaring a channel and emitting events An `"app-event"` channel runs whenever your code emits its event. Here `report_outage` emits `outage`, and `recent_alerts` reads the channel's replay buffer to show what was sent: Initial call configuration: ```js { tool: "report_outage", arguments: { service: "billing" } } ``` ```ts outages.channel.ts active import { Channel, ChannelContext, type ChannelNotification } from "@frontmcp/sdk"; @Channel({ name: "outages", source: { type: "app-event", event: "outage" }, meta: { team: "sre" }, replay: { enabled: true, maxEvents: 20 }, }) export class OutagesChannel extends ChannelContext { async onEvent(payload: unknown): Promise { const { service, severity } = payload as { service: string; severity: string }; return { content: `${service} is down`, meta: { service, severity } }; } } ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "report_outage", description: "Report that a service is down, and alert everyone connected", inputSchema: { service: z.string(), severity: z.enum(["minor", "major"]).default("major") }, }) export class ReportOutage extends ToolContext { async execute({ service, severity }: { service: string; severity: "minor" | "major" }) { this.scope.channelEventBus?.emit("outage", { service, severity }); return { reported: service }; } } @Tool({ name: "recent_alerts", description: "Show the outage alerts sent so far", inputSchema: {} }) export class RecentAlerts extends ToolContext { async execute() { const channel = this.scope.channels?.findByName("outages"); return { alerts: [...(channel?.replayBuffer ?? [])] }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { OutagesChannel } from "./outages.channel"; import { RecentAlerts, ReportOutage } from "./tools"; @App({ id: "ops", name: "Ops", tools: [ReportOutage, RecentAlerts], channels: [OutagesChannel] }) export class OpsApp {} @FrontMcp({ info: { name: "ops", version: "1.0.0" }, apps: [OpsApp], channels: { enabled: true, defaultMeta: { env: "production" } }, }) export default class Server {} ``` ```ts outages.test.ts import { test, expect } from "@frontmcp/testing"; test("emitting the event runs onEvent, and its result is the event sent", async ({ mcp }) => { await mcp.tools.call("report_outage", { service: "billing" }); expect((await mcp.tools.call("recent_alerts", {})).json().alerts).toEqual([ { content: "billing is down", meta: { env: "production", team: "sre", service: "billing", severity: "major", source: "outages" } }, ]); }); test("a channel adds no tools unless it's two-way", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t: { name: string }) => t.name); expect(names.sort()).toEqual(["recent_alerts", "report_outage"]); }); ``` A subscribed session, like Claude Code's over stdio or Streamable HTTP, would receive this event as a `notifications/claude/channel` message with this `content` and `meta`: the server's `defaultMeta`, the channel's `meta`, what `onEvent()` returned, and `source`. Take `channels: { enabled: true }` out and `emit()` isn't reached: `channelEventBus` is `undefined`, and `recent_alerts` finds no channel. ### Receiving events on a 2026-07-28 client A 2026-07-28 client has no session to subscribe, so it opens a `subscriptions/listen` request and keeps reading its response, a stream of notifications. With `experimental: { "claude/channel": {} }` in that request's client capabilities, the stream carries every channel's events. The Playground's client doesn't open one, so this example's test does, through [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), the way a client's requests reach a server: Initial call configuration: ```js { tool: "report_outage", arguments: { service: "billing" } } ``` ```ts listen.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; const asksForChannels = { experimental: { "claude/channel": {} } }; test("a stream that asks for channels gets each event", async () => { const handler = await FrontMcpInstance.createFetchHandler(config); const stream = (await handler(request("subscriptions/listen", { notifications: {} }, asksForChannels))).body!.getReader(); expect((await nextMessage(stream))?.method).toBe("notifications/subscriptions/acknowledged"); await handler(request("tools/call", { name: "report_outage", arguments: { service: "billing" } })); expect(await nextMessage(stream)).toEqual({ jsonrpc: "2.0", method: "notifications/claude/channel", params: { content: "billing is down", meta: { team: "sre", source: "outages" }, _meta: { "io.modelcontextprotocol/subscriptionId": 1 } }, }); void stream.cancel(); // the client goes away }); test("a stream that doesn't ask gets none", async () => { const handler = await FrontMcpInstance.createFetchHandler(config); const stream = (await handler(request("subscriptions/listen", { notifications: {} }))).body!.getReader(); expect((await nextMessage(stream))?.method).toBe("notifications/subscriptions/acknowledged"); await handler(request("tools/call", { name: "report_outage", arguments: { service: "email" } })); expect(await nextMessage(stream)).toBeNull(); void stream.cancel(); }); test("`server/discover` says the server has channels", async ({ mcp }) => { const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" })) as any; expect(result.capabilities.experimental).toEqual({ "claude/channel": {} }); expect(result.instructions).toBe("Events arrive as tags."); }); // What a 2026-07-28 client sends: the method, in the body and a header, and its capabilities in `_meta`. function request(method: string, params: Record, capabilities: Record = {}) { return new Request("https://ops.example/", { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(method === "tools/call" ? { "mcp-name": String(params.name) } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": capabilities } }, }), }); } // The next message on the stream, or null when none comes within 200 ms. async function nextMessage(stream: ReadableStreamDefaultReader) { const chunk = await Promise.race([stream.read(), new Promise((resolve) => setTimeout(() => resolve(null), 200))]); if (!chunk || chunk.done) return null; const data = new TextDecoder().decode(chunk.value).split("\n").find((line) => line.startsWith("data: ")); return data ? JSON.parse(data.slice("data: ".length)) : null; } ``` ```ts outages.channel.ts import { Channel, ChannelContext, type ChannelNotification } from "@frontmcp/sdk"; @Channel({ name: "outages", source: { type: "app-event", event: "outage" }, meta: { team: "sre" } }) export class OutagesChannel extends ChannelContext { async onEvent(payload: unknown): Promise { return { content: `${(payload as { service: string }).service} is down` }; } } ``` ```ts main.ts import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; import { OutagesChannel } from "./outages.channel"; @Tool({ name: "report_outage", description: "Report that a service is down, and alert everyone listening", inputSchema: { service: z.string() } }) export class ReportOutage extends ToolContext { async execute({ service }: { service: string }) { this.scope.channelEventBus?.emit("outage", { service }); return { reported: service }; } } @App({ id: "ops", name: "Ops", tools: [ReportOutage], channels: [OutagesChannel] }) export class OpsApp {} export const config = { info: { name: "ops", version: "1.0.0" }, apps: [OpsApp], channels: { enabled: true }, }; @FrontMcp(config) export default class Server {} ``` The stream starts with `notifications/subscriptions/acknowledged`, then carries each event as `notifications/claude/channel`, with the stream's id, the request's `id`, as `subscriptionId`. A stream that didn't ask for channels gets none, and neither does any stream for an event sent to one session. A client learns that the server has channels from `server/discover`, as the last test shows: it lists the `claude/channel` capability, and its `instructions` tell the model about `` tags, after the server's own instructions when it has some. (Changed in 1.9.3: before, `server/discover` had neither, so a client had to know to ask, and the model learned about the tags only from your own `instructions`. Changed in 1.9.2: before, a 2026-07-28 client received no channel events.) ### Letting the model reply With `twoWay: true`, FrontMCP registers a `channel-reply` tool, with `channel_name`, `text` and an optional `meta`. It calls the channel's `onReply()`, so the reply can go back to wherever the event came from: Initial call configuration: ```js { tool: "channel-reply", arguments: { channel_name: "customer-chat", text: "We're on it.", meta: { chat_id: "C-42" } } } ``` ```ts chat.channel.ts active import { Channel, ChannelContext, type ChannelNotification } from "@frontmcp/sdk"; // Stands in for the chat service the replies go to export const sentReplies: { chatId?: string; text: string }[] = []; @Channel({ name: "customer-chat", source: { type: "manual" }, twoWay: true }) export class CustomerChat extends ChannelContext { async onEvent(payload: unknown): Promise { const { chatId, text } = payload as { chatId: string; text: string }; return { content: text, meta: { chat_id: chatId } }; } async onReply(reply: string, meta?: Record) { sentReplies.push({ chatId: meta?.chat_id, text: reply }); } } ``` ```ts main.ts import { App, Channel, ChannelContext, FrontMcp, Tool, ToolContext, type ChannelNotification } from "@frontmcp/sdk"; import { CustomerChat, sentReplies } from "./chat.channel"; @Channel({ name: "outages", source: { type: "app-event", event: "outage" } }) export class OutagesChannel extends ChannelContext { async onEvent(payload: unknown): Promise { return { content: String(payload) }; } } // 🚩 two-way, with no onReply() @Channel({ name: "status-page", source: { type: "manual" }, twoWay: true }) export class StatusPageChannel extends ChannelContext { async onEvent(payload: unknown): Promise { return { content: String(payload) }; } } @Tool({ name: "sent_replies", description: "Show the replies sent to customers", inputSchema: {} }) export class SentReplies extends ToolContext { async execute() { return { replies: [...sentReplies] }; } } @App({ id: "support", name: "Support", tools: [SentReplies], channels: [CustomerChat, OutagesChannel, StatusPageChannel] }) export class SupportApp {} @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [SupportApp], channels: { enabled: true } }) export default class Server {} ``` ```ts reply.test.ts import { test, expect } from "@frontmcp/testing"; test("channel-reply calls onReply with the text and meta", async ({ mcp }) => { const result = await mcp.tools.call("channel-reply", { channel_name: "customer-chat", text: "We're on it.", meta: { chat_id: "C-42" } }); expect(result.text()).toBe('Reply sent to channel "customer-chat" successfully.'); expect((await mcp.tools.call("sent_replies", {})).json().replies).toEqual([{ chatId: "C-42", text: "We're on it." }]); }); test("a one-way channel refuses replies", async ({ mcp }) => { const result = await mcp.tools.call("channel-reply", { channel_name: "outages", text: "Thanks" }); expect(result).toBeError(); expect(result.text()).toBe('Error: Channel "outages" does not support replies (one-way only).'); }); test("an unknown channel lists the ones that take replies", async ({ mcp }) => { const result = await mcp.tools.call("channel-reply", { channel_name: "chat", text: "Hi" }); expect(result.text()).toBe('Error: Channel "chat" not found. Available channels: customer-chat, status-page'); }); test("a two-way channel without onReply fails the reply", async ({ mcp }) => { const result = await mcp.tools.call("channel-reply", { channel_name: "status-page", text: "Fixed." }); expect(result).toBeError(); expect(result.text()).toBe( 'Error: Failed to send reply to channel "status-page": Channel "status-page" has twoWay: true but onReply() is not implemented. Override onReply() to forward replies to the external system.', ); }); test("the server's instructions say to reply with channel-reply", async ({ mcp }) => { const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" })) as any; expect(result.instructions).toBe("Events arrive as tags. Reply with the channel-reply tool."); }); ``` The model learns which `meta` to send back from the event: `onEvent()` put `chat_id` in its `meta`. One `channel-reply` tool serves every two-way channel on the server. When `onReply()` throws, or a two-way channel has none, the call fails with the error, as the fourth test shows, so the model knows its reply didn't go out. Changed in 1.9: before, `channel-reply` said the reply was sent either way. The server's `instructions` tell the model to reply with `channel-reply`, as the last test shows. ### Connecting to a service A `"service"` channel keeps a connection open. `onConnect()` runs once when the server starts; each incoming message goes through `this.pushIncoming()`, which runs `onEvent()`. The channel's `tools` are the other direction: tools the model calls to send messages. Here a stand-in chat service answers every message it's sent: Initial call configuration: ```js { tool: "send_chat_message", arguments: { to: "dana", text: "Is the invoice sorted?" } } ``` ```ts chat.channel.ts active import { Channel, ChannelContext, Tool, ToolContext, z, type ChannelNotification } from "@frontmcp/sdk"; import { ChatService } from "./chat-service"; @Tool({ name: "send_chat_message", description: "Send a chat message to a customer. Their answer arrives on the customer-chat channel.", inputSchema: { to: z.string(), text: z.string() }, }) export class SendChatMessage extends ToolContext { async execute({ to, text }: { to: string; text: string }) { this.get(ChatService).send(to, text); return { sent: true, to }; } } @Channel({ name: "customer-chat", source: { type: "service", service: "chat" }, tools: [SendChatMessage], replay: { enabled: true }, }) export class CustomerChat extends ChannelContext { // Runs once, when the server starts async onConnect() { this.get(ChatService).onMessage((from, text) => this.pushIncoming({ from, text })); } // Runs once, when the server stops async onDisconnect() { this.get(ChatService).close(); } async onEvent(payload: unknown): Promise { const { from, text } = payload as { from: string; text: string }; return { content: `${from}: ${text}`, meta: { sender: from } }; } } ``` ```ts chat-service.ts import { Provider } from "@frontmcp/sdk"; type Listener = (from: string, text: string) => void; // Stands in for a chat service's client. It answers every message it's sent. @Provider({ name: "ChatService" }) export class ChatService { static closed = 0; private listeners: Listener[] = []; close() { ChatService.closed += 1; this.listeners = []; } onMessage(listener: Listener) { this.listeners.push(listener); } send(to: string, text: string) { for (const listener of this.listeners) listener(to, `Got your message: "${text}"`); } } ``` ```ts main.ts import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk"; import { ChatService } from "./chat-service"; import { CustomerChat } from "./chat.channel"; @Tool({ name: "recent_chat", description: "Show the chat messages that arrived", inputSchema: {} }) export class RecentChat extends ToolContext { async execute() { return { messages: [...(this.scope.channels?.findByName("customer-chat")?.replayBuffer ?? [])] }; } } @App({ id: "support", name: "Support", tools: [RecentChat], channels: [CustomerChat], providers: [ChatService] }) export class SupportApp {} export const config = { info: { name: "support", version: "1.0.0" }, apps: [SupportApp], channels: { enabled: true }, }; @FrontMcp(config) export default class Server {} ``` ```ts chat.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { ChatService } from "./chat-service"; import { config } from "./main"; test("the channel's tool is listed with the app's", async ({ mcp }) => { expect(await mcp.tools.list()).toContainTool("send_chat_message"); }); test("an incoming message goes through pushIncoming and onEvent", async ({ mcp }) => { await mcp.tools.call("send_chat_message", { to: "dana", text: "Is the invoice sorted?" }); expect((await mcp.tools.call("recent_chat", {})).json().messages).toEqual([ { content: 'dana: Got your message: "Is the invoice sorted?"', meta: { sender: "dana", source: "customer-chat" } }, ]); }); test("dispose() runs onDisconnect", async () => { const server = await FrontMcpInstance.createDirect(config); const before = ChatService.closed; await server.dispose(); expect(ChatService.closed - before).toBe(1); }); ``` `ChatService` is registered on the app, so the channel and its tool get the same instance: the tool sends through it, and `onConnect()` listens on it, as the second test shows. (Changed in 1.9.3: before, the tools in a channel's `tools` saw only the server's providers, so `ChatService` had to go on `@FrontMcp`, where it still works.) In a real connector, `onConnect()` would open a WebSocket or start polling, `onDisconnect()` would close it, and `onEvent()` would check who sent each message before passing it to the model. ### Reacting to a job or an agent finishing A `"job-completion"` channel runs when one of its `jobNames` finishes, and an `"agent-completion"` channel when one of its `agentIds` does. Each gets the run's outcome as its payload: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "export-tickets", input: {} } } ``` ```ts completions.channel.ts active import { Channel, ChannelContext, type ChannelNotification } from "@frontmcp/sdk"; /** Every payload the two channels got, for the tests. */ export const received: object[] = []; @Channel({ name: "exports", source: { type: "job-completion", jobNames: ["export-tickets"] } }) export class ExportsChannel extends ChannelContext { async onEvent(payload: unknown): Promise { received.push(payload as object); const run = payload as { jobName: string; status: string }; return { content: `${run.jobName} finished: ${run.status}` }; } } @Channel({ name: "triages", source: { type: "agent-completion", agentIds: ["triage"] } }) export class TriagesChannel extends ChannelContext { async onEvent(payload: unknown): Promise { received.push(payload as object); const run = payload as { agentName: string; output?: string }; return { content: `${run.agentName} answered: ${run.output}` }; } } ``` ```ts main.ts import { Agent, AgentContext, App, FrontMcp, Job, JobContext, z } from "@frontmcp/sdk"; import { ExportsChannel, TriagesChannel } from "./completions.channel"; @Job({ name: "export-tickets", inputSchema: {}, outputSchema: { rows: z.number() } }) export class ExportTickets extends JobContext { async execute() { return { rows: 43 }; } } // A stand-in model, which always answers "High". @Agent({ name: "triage", inputSchema: {}, llm: { adapter: { completion: async () => ({ content: "High", finishReason: "stop" as const }) } } }) export class Triage extends AgentContext {} @App({ id: "ops", name: "Ops", jobs: [ExportTickets], agents: [Triage], channels: [ExportsChannel, TriagesChannel] }) export class OpsApp {} @FrontMcp({ info: { name: "ops", version: "1.0.0" }, apps: [OpsApp], channels: { enabled: true } }) export default class Server {} ``` ```ts completions.test.ts import { test, expect } from "@frontmcp/testing"; import { received } from "./completions.channel"; test("a finished job runs the job-completion channel", async ({ mcp }) => { received.length = 0; await mcp.tools.call("execute_job", { name: "export-tickets", input: {} }); expect(received).toEqual([ { jobName: "export-tickets", jobId: "export-tickets", status: "success", durationMs: expect.any(Number), output: '{"rows":43}', attempt: 1, sessionId: expect.any(String) }, ]); }); test("a finished agent runs the agent-completion channel", async ({ mcp }) => { received.length = 0; await mcp.tools.call("invoke_triage", {}); expect(received).toMatchObject([{ agentId: "triage", agentName: "triage", status: "success", output: '{"response":"High"}' }]); }); ``` The event goes only to the session in `sessionId`, the one that ran the job or agent. The Playground's 2026-07-28 client has none, so it receives nothing, but `onEvent()` still runs. ### Sending to one session By default an event goes to every subscribed session. Pass a session id to `handleEvent()` or `pushNotification()` to send it to one session only, like the one whose tool call caused it. For a client that keeps a session (protocol versions before 2026-07-28), `this.context.sessionId` is that session's id; under 2026-07-28 it's a new id for every request, and an event sent to one session never reaches a `subscriptions/listen` stream. So, for a legacy client: ```ts export.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "start_export", description: "Export tickets; you'll be told when the file is ready", inputSchema: { month: z.string() } }) export class StartExport extends ToolContext { async execute({ month }: { month: string }) { const exports = this.scope.channels?.findByName("exports"); const sessionId = this.context.sessionId; buildExport(month).then((url) => exports?.pushNotification(`The ${month} export is ready: ${url}`, { month }, sessionId)); return { started: month }; } } ``` To stop one session receiving a channel, call `this.scope.notifications.unsubscribeChannel(sessionId, "exports")`; `subscribeChannel()` starts it again. Sessions only exist for clients before 2026-07-28, so this needs a real server and a client like Claude Code: the Playground can't show it. On a real server it was checked with two sessions: the targeted event reached only the first, and after `unsubscribeChannel()` the second stopped receiving the channel. ### Hooking every event Every event goes through the `channels:send-notification` flow, so a plugin can see or change each one with `ChannelSendHook`. Here a plugin keeps an audit log of what was sent, and adds the time to each event's `meta` once the channel's and the server's are in: Initial call configuration: ```js { tool: "report_outage", arguments: { service: "billing" } } ``` ```ts audit.plugin.ts active import { ChannelSendHook, DynamicPlugin, FlowCtxOf, Plugin } from "@frontmcp/sdk"; export const audit: string[] = []; @Plugin({ name: "channel-audit", description: "Logs every channel event, and stamps it with the time" }) export class ChannelAudit extends DynamicPlugin { @ChannelSendHook.Did("resolveMeta") async stamp(ctx: FlowCtxOf<"channels:send-notification">) { ctx.state.set("meta", { ...ctx.state.meta, sent_at: "2026-10-05T09:00:00Z" }); } @ChannelSendHook.Will("send") async record(ctx: FlowCtxOf<"channels:send-notification">) { audit.push(`${ctx.state.channelName}: ${ctx.state.content}`); } } ``` ```ts main.ts import { App, Channel, ChannelContext, FrontMcp, Tool, ToolContext, z, type ChannelNotification } from "@frontmcp/sdk"; import { ChannelAudit } from "./audit.plugin"; @Channel({ name: "outages", source: { type: "app-event", event: "outage" }, meta: { team: "sre" }, replay: { enabled: true } }) export class OutagesChannel extends ChannelContext { async onEvent(payload: unknown): Promise { return { content: `${(payload as { service: string }).service} is down` }; } } @Tool({ name: "report_outage", description: "Report that a service is down", inputSchema: { service: z.string() } }) export class ReportOutage extends ToolContext { async execute({ service }: { service: string }) { this.scope.channelEventBus?.emit("outage", { service }); return { reported: service }; } } @Tool({ name: "update_outage", description: "Tell everyone connected how an outage is going", inputSchema: { text: z.string() } }) export class UpdateOutage extends ToolContext { async execute({ text }: { text: string }) { await this.scope.channelNotifications?.send("outages", text); return { sent: [...(this.scope.channels?.findByName("outages")?.replayBuffer ?? [])] }; } } @App({ id: "ops", name: "Ops", tools: [ReportOutage, UpdateOutage], channels: [OutagesChannel], plugins: [ChannelAudit] }) export class OpsApp {} @FrontMcp({ info: { name: "ops", version: "1.0.0" }, apps: [OpsApp], channels: { enabled: true } }) export default class Server {} ``` ```ts audit.test.ts import { test, expect } from "@frontmcp/testing"; import { audit } from "./audit.plugin"; test("the hooks run for emit() and for send()", async ({ mcp }) => { await mcp.tools.call("report_outage", { service: "billing" }); const result = await mcp.tools.call("update_outage", { text: "billing is being looked at" }); expect(audit).toEqual(["outages: billing is down", "outages: billing is being looked at"]); expect(result.json().sent).toEqual([ { content: "billing is down", meta: { team: "sre", sent_at: "2026-10-05T09:00:00Z", source: "outages" } }, { content: "billing is being looked at", meta: { team: "sre", sent_at: "2026-10-05T09:00:00Z", source: "outages" } }, ]); }); ``` Changed in 1.9: before, FrontMCP never ran this flow for an event, so these hooks only ran when your own code started the flow. ### Replaying missed events Events sent while no session is subscribed are lost. With `replay`, the channel keeps the last `maxEvents`, and `replayBufferedEvents(sessionId)` sends them to a session, marked `replayed: "true"`. FrontMCP doesn't call it for you, so give the model a tool to catch up with: Initial call configuration: ```js { tool: "catch_up", arguments: {} } ``` ```ts main.ts active import { App, Channel, ChannelContext, FrontMcp, Tool, ToolContext, z, type ChannelNotification } from "@frontmcp/sdk"; @Channel({ name: "outages", source: { type: "app-event", event: "outage" }, replay: { enabled: true, maxEvents: 2 } }) export class OutagesChannel extends ChannelContext { async onEvent(payload: unknown): Promise { return { content: `${(payload as { service: string }).service} is down` }; } } @Tool({ name: "report_outage", description: "Report that a service is down", inputSchema: { service: z.string() } }) export class ReportOutage extends ToolContext { async execute({ service }: { service: string }) { this.scope.channelEventBus?.emit("outage", { service }); return { reported: service }; } } @Tool({ name: "catch_up", description: "Resend the latest outage alerts to this session", inputSchema: {} }) export class CatchUp extends ToolContext { async execute() { const channel = this.scope.channels?.findByName("outages"); const kept = [...(channel?.replayBuffer ?? [])].map((event) => event.content); const resent = channel?.replayBufferedEvents(this.context.sessionId ?? "") ?? 0; return { kept, resent }; } } @App({ id: "ops", name: "Ops", tools: [ReportOutage, CatchUp], channels: [OutagesChannel] }) export class OpsApp {} @FrontMcp({ info: { name: "ops", version: "1.0.0" }, apps: [OpsApp], channels: { enabled: true } }) export default class Server {} ``` ```ts replay.test.ts import { test, expect } from "@frontmcp/testing"; test("the buffer keeps the last maxEvents events", async ({ mcp }) => { for (const service of ["search", "billing", "email"]) await mcp.tools.call("report_outage", { service }); expect((await mcp.tools.call("catch_up", {})).json().kept).toEqual(["billing is down", "email is down"]); }); ``` In the Playground, `resent` is still counted, but nothing arrives: the Playground's client has no session. On a real server, a Claude Code session that calls `catch_up` gets both events, each with `meta.replayed` set to `"true"`. --- ## Troubleshooting ### Claude Code never shows my channel's events Check, in order: 1. `@FrontMcp({ channels: { enabled: true } })` is set, and the channel is in an app's `channels`. 2. The client asked for channels, with `experimental: { "claude/channel": {} }`: in its `initialize`, for a client that keeps a session, or in its `subscriptions/listen` request, for a 2026-07-28 client. The Playground's client opens no stream, so it never receives channel events. 3. The channel's `availableWhen`, if it has one, matches where the server runs: otherwise the channel isn't registered. 4. The source can reach the client: a `"webhook"` needs a matching POST request, and `"job-completion"` and `"agent-completion"` events go only to the session that ran the job or agent. 5. `onEvent()` didn't throw. The server's log says `Channel "…" failed to handle event` when it did. 6. The session is still subscribed: `unsubscribeChannel()` stops a channel for one session. ### `Meta keys must be valid identifiers (letters, digits, underscores)` A key in the channel's `meta` has another character, like `"my-env"`. Rename it, `my_env`. The check runs when the class is decorated, so the server doesn't start. ### `Duplicate channel name "…" — each channel must have a unique name.` Two channels on the server have the same `name`, maybe in different apps. Channel names are server-wide. ### `Failed to send reply to channel "…"` `channel-reply` failed because the channel's `onReply()` threw, with its message after the colon, or because the two-way channel doesn't define one: `Channel "…" has twoWay: true but onReply() is not implemented`. Implement `onReply()`, or fix what it calls. Before 1.9, FrontMCP logged `Channel "…" failed to handle reply` and told the model the reply was sent. ### `POST /hooks/…` returns 404 Check that the request is a `POST` to the channel's configured path. Changed in 1.9.5: `createFetchHandler()` serves webhook routes too; before, it returned `404`. Before 1.8.5, no server registered the route. ### `Channel "…" has a webhook source but no HTTP server to serve …` The server runs in process through `create()`, `createDirect()` or `connect()` in its default mode. These entry points skip webhook registration and log this warning. Changed in 1.10.0: before, they registered webhook routes without serving HTTP. `connect()`'s CLI mode keeps its existing initialization. Start an HTTP server or use `createFetchHandler()` to receive webhook requests. ### `Channel "…" has job-completion source but no job emitter available` FrontMCP wires `"job-completion"` and `"agent-completion"` sources to the scope's completion events: see [Reacting to a job or an agent finishing](https://frontmcp.dev/reference/sdk/channel#reacting-to-a-job-or-an-agent-finishing). Changed in 1.8.5: before, FrontMCP logged this at startup and never wired either source. ### `this.scope.channels` is `undefined` Channels aren't enabled. Set `channels: { enabled: true }` on `@FrontMcp`. The same goes for `channelEventBus` and `channelNotifications`. ### TypeScript says `Property 'channelEventBus' does not exist on type 'ScopeEntry'` The project has a FrontMCP older than 1.9, whose `ScopeEntry` type didn't declare the channel members of the scope. Since 1.9 it declares `channels`, `channelEventBus` and `channelNotifications`: upgrade, and the casts older code needed can go. ### `Provider "…" is not available` in a channel The channel asked for a provider that isn't registered on its app or on the server, like one on another app: register it on the channel's app, or in `@FrontMcp({ providers })` to share it between apps. Before 1.9.2, a channel didn't see its own app's providers either. A channel's `tools` find providers in the same places. Before 1.9.3 they saw only the server's, so on an older FrontMCP, register a provider they use in `@FrontMcp({ providers })`. --- # Hook decorators > Run code before, after, around or inside any request stage with Will, Did, Around and Stage hooks: flows, priority, filters, what ctx can change, and run order. Source: https://frontmcp.dev/reference/sdk/hooks [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Every request FrontMCP handles runs through a **flow**: a fixed list of named stages. `tools/call` runs the `tools:call-tool` flow, from `parseInput` and `findTool` to `execute` and `finalize`. A **hook** is a method that FrontMCP calls at one of those stages: before it (`Will`), after it (`Did`), around it (`Around`), or as an extra step inside it (`Stage`). Hooks live on [plugins](https://frontmcp.dev/reference/sdk/plugin), providers, and the classes of tools, resources, prompts, agents and jobs. [Hooking into Calls](https://frontmcp.dev/learn/hooking-into-calls) teaches them step by step. ```ts @ToolHook.Will(stage, { priority, filter }) async before(ctx: FlowCtxOf<"tools:call-tool">) { /* ... */ } @ToolHook.Around(stage, { priority, filter }) async around(ctx: FlowCtxOf<"tools:call-tool">, next: () => Promise) { /* ... */ } ``` --- ## Reference ### `Will`, `Did`, `Around` and `Stage` Each flow has a set of four decorators, like `ToolHook` for `tools:call-tool`. Put one on an `async` method of a [plugin](https://frontmcp.dev/reference/sdk/plugin), a provider, or a tool, resource, prompt, agent or job class ([where hooks can be declared](https://frontmcp.dev/reference/sdk/hooks#where-hooks-can-be-declared)). ```ts freeze.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, PublicMcpError, ToolHook } from "@frontmcp/sdk"; @Plugin({ name: "freeze" }) export class FreezePlugin extends DynamicPlugin { @ToolHook.Will("execute", { filter: (ctx) => !ctx.state.tool?.metadata.annotations?.readOnlyHint }) async refuseChanges(ctx: FlowCtxOf<"tools:call-tool">) { throw new PublicMcpError(`Changes are frozen until Monday, so ${ctx.state.tool?.metadata.name} is unavailable.`); } } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/hooks#usage) | Decorator | The method runs | Signature | | --- | --- | --- | | `Will(stage, options?)` | Just before the stage. | `(ctx) => Promise` | | `Did(stage, options?)` | Just after the stage, **only if the stage succeeded**. | `(ctx) => Promise` | | `Around(stage, options?)` | Instead of the stage: `await next()` runs the stage and rejects if it fails. Code before `next()` runs after the `Will` hooks, code after it before the `Did` hooks. Not calling `next()` skips the stage; calling it again runs it again. | `(ctx, next) => Promise` | | `Stage(stage, options?)` | Inside the stage, as a step next to FrontMCP's own: at the default priority, before FrontMCP's step, and with a priority above 0, after it. Inside any `Around` hooks. | `(ctx) => Promise` | A stage name that isn't in the flow's plan is a TypeScript error, and a hook on one never runs. #### Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `priority` | `number` | `0` | The order among hooks of the same kind on the same stage: **lower runs first**, for `Will`, `Did` and `Stage` alike. For `Around` hooks, lower is outer. See [the order hooks run in](https://frontmcp.dev/reference/sdk/hooks#the-order-hooks-run-in). | | `filter` | `(ctx) => boolean \| Promise` | none | Called with the same `ctx` before each run. When it returns `false`, the hook is skipped; for an `Around` hook, the stage still runs. It's a plain function in the decorator, so it can't use `this`. | | `appliesTo` | `"own-app" \| "uncovered-apps"` | `"own-app"` | For a hook of an app's plugin on `tools/call`, `resources/read` or `prompts/get`. `"own-app"` runs it for that app's entries. `"uncovered-apps"` also runs it for the entries of any app that no instance of the same hook covers, from its own plugins or the server's. Meant for gates that an entry's options ask for: see [`@Plugin`](https://frontmcp.dev/reference/sdk/plugin#enforcing-an-option-of-your-own). | #### Decorator sets and their flows `@frontmcp/sdk` exports a decorator set for the flows you're most likely to hook. `FlowHooksOf(flow)` makes one for any other flow. | Export | Flow | Runs for | Stages, in order | | --- | --- | --- | --- | | `ToolHook` | `tools:call-tool` | `tools/call` | `parseInput`, `ensureRemoteCapabilities`, `findTool`, `checkPublicAccess`, `checkToolAuthorization`, `checkEntryAuthorities`, `createTaskIfRequested`, `createToolCallContext`, `checkToolCredentials`, `acquireQuota`, `acquireSemaphore`; then `validateInput`, `execute`, `validateOutput`; then always `releaseSemaphore`, `releaseQuota`, `applyUI`, `finalize` | | `ListToolsHook` | `tools:list-tools` | `tools/list` | `parseInput`, `ensureRemoteCapabilities`, `findTools`, `filterByAuthorities`, `resolveConflicts`, `parseTools` | | `ResourceHook` | `resources:read-resource` | `resources/read` | `parseInput`, `ensureRemoteCapabilities`, `findResource`, `checkEntryAuthorities`, `createResourceContext`; then `execute`, `validateOutput`; then always `finalize` | | `ListResourcesHook` | `resources:list-resources` | `resources/list` | `parseInput`, `ensureRemoteCapabilities`, `findResources`, `filterByAuthorities`, `resolveConflicts`, `parseResources` | | `ListResourceTemplatesHook` | `resources:list-resource-templates` | `resources/templates/list` | `parseInput`, `ensureRemoteCapabilities`, `findTemplates`, `filterByAuthorities`, `resolveConflicts`, `parseTemplates` | | `PromptHook` | `prompts:get-prompt` | `prompts/get` | `parseInput`, `ensureRemoteCapabilities`, `findPrompt`, `checkPublicAccess`, `checkEntryAuthorities`, `createPromptContext`; then `execute`, `validateOutput`; then always `finalize` | | `ListPromptsHook` | `prompts:list-prompts` | `prompts/list` | `parseInput`, `ensureRemoteCapabilities`, `findPrompts`, `filterByAuthorities`, `resolveConflicts`, `parsePrompts` | | `HttpHook` | `http:request` | Every HTTP request, before it's handled | `traceRequest`, `checkIpFilter`, `acquireQuota`, `acquireSemaphore`, `checkAuthorization`, `acquireIdentityQuota`, `router`, then the transport stages (`handleMcp2026`, …) and `audit`, `metrics`, `releaseSemaphore`, `releaseQuota`, `finalize` | | `AgentCallHook` | `agents:call-agent` | A call to an agent's `invoke_`, inside its `tools:call-tool` flow (since 1.8.5). See [Hooking an agent's calls](https://frontmcp.dev/reference/sdk/hooks#hooking-an-agents-calls). | `parseInput`, `findAgent`, `checkCallDepth` (since 1.9), `checkEntryAuthorities`, `checkAgentAuthorization`, `createAgentContext`, `acquireQuota`, `acquireSemaphore`; then `validateInput`, `execute`, `validateOutput`; then always `releaseSemaphore`, `releaseQuota`, `parseOutput`, `emitCompletion`, `finalize` | | `JobHook` | `jobs:execute-job` | Each attempt of a [job](https://frontmcp.dev/reference/sdk/job): run by `execute_job`, while the client waits or in the background, each retry, and each [workflow](https://frontmcp.dev/reference/sdk/workflow) step (since 1.9.4). See [Hooking a job's attempts](https://frontmcp.dev/reference/sdk/hooks#hooking-a-jobs-attempts). | `parseInput`, `checkJobAuthorization`, `validateInput`, `createJobContext`; then `execute`, `validateOutput`; then always `updateRunState`, `finalize` | | `ChannelSendHook`, `ChannelListHook` | `channels:send-notification`, `channels:list` | Every channel event, and the channels a session subscribes to (since 1.9; before, only runs of those flows you started yourself). See [`@Channel`](https://frontmcp.dev/reference/sdk/channel#hooks-on-channel-flows). | `parseInput`, `resolveMeta`, `send`, `finalize`; `listChannels`, `finalize` | | `OAuthAuthorizeHook`, `OAuthTokenHook`, `OAuthCallbackHook`, `OAuthRegisterHook` | `oauth:authorize`, `oauth:token`, `oauth:callback`, `oauth:register` | The OAuth endpoints of `local` and `remote` auth. They need a real server, so this page doesn't show them. | | `WillHookOf(flow)`, `DidHookOf(flow)`, `AroundHookOf(flow)` and `StageHookOf(flow)` each return one of the four decorators, as in `@(WillHookOf("resources:read-resource")("execute"))`. `CompletionHook` hooks `completion:complete`, the `completion/complete` requests. `JobHook`, and the `ExecuteJobFlow` type, are new in 1.9.4: before, jobs ran through no flow of their own. Changed in 1.9: `PromptHook`, `ListPromptsHook` and `CompletionHook` are new, and `FlowHooksOf()` and `FlowCtxOf<>` accept the three flows' names. Before, TypeScript rejected `FlowHooksOf("prompts:get-prompt")`, and code wrote `FlowHooksOf` with a `ctx` type of its own. Stages run in groups. The first group runs until a stage fails or answers; the execute group runs if it got that far; the last group, where the table says "always", runs whether the request worked or not. When a stage fails, its `Did` hooks and every later stage outside the last group are skipped. #### `ctx` A hook's first argument is the running flow. Type it with `FlowCtxOf<"tools:call-tool">`, or the name of the flow you hook. | Member | Description | | --- | --- | | `ctx.state` | What the flow knows so far. Fields depend on the flow and on how far it has got: see [`tools:call-tool` state](https://frontmcp.dev/reference/sdk/hooks#toolscall-tool-state). Read a field as `ctx.state.tool` or `ctx.state.get("tool")`; `ctx.state.required.tool` and `ctx.state.getOrThrow("tool")` throw if it isn't set. `ctx.state.set("tools", list)` or `ctx.state.set({ ... })` replaces fields, and `ctx.state.snapshot()` returns a copy. | | `ctx.respond(result)` | Ends the request with `result`, which must be a complete response for the flow, like a whole `tools/call` result with `content`, or `{ result, logs }` for a job's attempt. The stages and hooks that haven't run yet are skipped, apart from the closing group. In a `Did` hook it's ignored. [`this.respond`](https://frontmcp.dev/reference/sdk/respond) compares it with a tool's own `this.respond()`. | | `ctx.fail(error)` | Ends the request with `error`, exactly like throwing it. | | `ctx.get(token)` | A provider of the **server**, from `@FrontMcp({ providers })`. An app's providers aren't found here: in a plugin use `this.get()`, and in a tool call `ctx.state.toolContext.get()`. | | `ctx.rawInput` | The flow's input before parsing. For `http:request`, `ctx.rawInput.request` has `method`, `path`, `headers`, `query` and `body`. | | `ctx.scopeLogger` | The server's logger. | Throwing from any hook, or calling `ctx.fail()`, fails the request. A `PublicMcpError` reaches the client with its message and code, `PUBLIC_ERROR` by default; any other error becomes a `SERVER_ERROR`, whose message is hidden in production. A `Did` hook that throws fails the call **after** the stage ran, so the tool's side effects have already happened. #### `tools:call-tool` state | Field | Set by | What it is | | --- | --- | --- | | `ctx.state.input` | `parseInput` | The `tools/call` params as the client sent them: `name`, `arguments` and `_meta`. Changing `arguments` before `validateInput` changes what's validated. | | `ctx.state.authInfo`, `progressToken`, `jsonRpcRequestId` | `parseInput` | Who's calling, and the request's progress token and JSON-RPC id. | | `ctx.state.tool` | `findTool` | The tool: `metadata` is everything from `@Tool({ ... })`, including keys FrontMCP doesn't know, and `owner.id` is its app's `id`. | | `ctx.state.toolContext` | `createToolCallContext` | The call's tool instance: `input` (validated once `validateInput` has run), `output` (once `execute` has run; assign it in a `Did("execute")` hook to change the result), and `get()` for the app's providers. | | `ctx.state.rawOutput` | `validateOutput` | What `execute()` returned. Not set yet in `Did("execute")`; the output schema is checked later, in `finalize`. | | `ctx.state.resultMeta` | Your hook, with `ctx.state.set("resultMeta", { … })` | An object FrontMCP merges into the result's `_meta` in `finalize`. It never becomes part of the tool's data. New in 1.8.6: see [Adding to the result's `_meta`](https://frontmcp.dev/reference/sdk/hooks#adding-to-the-results-_meta). | #### Where hooks can be declared | Declared on | Flows | Runs for | `this` in the hook | | --- | --- | --- | --- | | A plugin class, or a provider in its `providers` | Any | An app's plugin: that app's tool calls, resource reads and prompt gets (and other apps' too, for a hook with [`appliesTo: "uncovered-apps"`](https://frontmcp.dev/reference/sdk/hooks#options)), its job attempts, calls to tool names no app has, and every list and HTTP request. A server's plugin: every request and job attempt. An agent's plugin: the calls of the tools the agent's model uses, and its reads of the agent's resources and prompts (since 1.9.4). See [`@Plugin`](https://frontmcp.dev/reference/sdk/plugin#where-you-register-a-plugin). | The plugin, or the provider | | A provider in `@App({ providers })` | Any | Like a plugin on that app | The provider. For a `CONTEXT` provider, the instance built for that request (since 1.9) | | A provider in `@FrontMcp({ providers })` | Any | Like a plugin on the server: every app's requests (since 1.9) | The provider | | A `@Tool` class | `tools:call-tool`: an instance method from `Did("createToolCallContext")` on, a `static` method on any stage | That tool's calls | An instance method: the call's tool context, `this.input`, `this.get()`. A `static` method: the class | | A `@Resource` or `@ResourceTemplate` class | `resources:read-resource`: an instance method from `Did("createResourceContext")` on, a `static` method on any stage | That resource's reads | The read's resource context, or the class | | A `@Prompt` class | `prompts:get-prompt`: an instance method from `Did("createPromptContext")` on, a `static` method on any stage | That prompt's requests | The request's prompt context, or the class | | An `@Agent` class | `agents:call-agent`: an instance method from `Did("createAgentContext")` on, a `static` method on any stage | That agent's calls | The call's agent context, or the class | | A `@Job` class | `jobs:execute-job`: an instance method from `Did("createJobContext")` on, a `static` method on any stage | That job's attempts | The attempt's job context, `this.input`, `this.attempt`, or the class | A `static` hook on an entry class gets the flow as `ctx`, like any hook, and needs no instance, so it can hook the stages before the entry's instance exists, like `parseInput`, `findTool` or `checkToolAuthorization`: see [Hooking the stages before an entry's instance](https://frontmcp.dev/reference/sdk/hooks#hooking-the-stages-before-an-entrys-instance). Hooks that could never run stop the server from starting, with `InvalidHookFlowError` (`INVALID_HOOK_FLOW`): an instance method on an entry class for a stage before its context is built, like `Will("findTool")` or `Will("createToolCallContext")` on a tool; list hooks, like `ListToolsHook`, on an entry class, `static` or not; and hooks for another flow, like a `ToolHook` on a `@Job` class. The message names the class, the method and the stage, and says to declare an early hook as a `static` method. A provider or a plugin can hook every stage and flow. Changed in 1.9: before, all of these were accepted and never ran, with no warning, and so were hooks on a provider in `@FrontMcp({ providers })` or with `scope: ProviderScope.CONTEXT`, which run now. Changed in 1.9.4: `static` hook methods are new; before, only a provider or a plugin could hook the stages before an entry's instance. And a `@Job` class's hooks run on its attempts; before, jobs ran through no flow, and any hook on a `@Job` class stopped the server from starting. #### The order hooks run in For one stage: 1. `Will` hooks, lowest priority first. 2. `Around` hooks, lowest priority outermost. 3. The stage itself: `Stage` steps with priority 0 or less, FrontMCP's own step, then `Stage` steps with a higher priority. 4. `Did` hooks, lowest priority first. Hooks with the same priority on the same stage run in this order: an app's providers, then the app's plugins in the order of its `plugins` array, then the server's plugins, then the tool's own class. Within one class, in the order they're declared. #### Caveats - Hooks of an app's plugin or provider on **list** requests (`tools/list`, `resources/list`, `resources/templates/list`, `prompts/list`) and on **HTTP** requests aren't tied to the app: they see and can change every app's entries. To change only the entries the plugin's own call hooks judge, check each one with [`isEntryGatedBy()`](https://frontmcp.dev/reference/sdk/plugin#hiding-what-a-plugin-refuses). - **Hiding isn't blocking.** A list hook changes what's listed; a client that calls a hidden tool by name still reaches it. - In an `Around` hook, the error `next()` rejects with is FrontMCP's: a `ToolExecutionError` wrapping what `execute()` threw, or, when the tool called `this.fail()`, an internal error whose `message` is empty. Rethrow it to keep the tool's own error for the client. - To give a call a result from an `Around` hook that skips the stage, assign `ctx.state.toolContext.output` or call `ctx.respond()`. `ctx.state.set("output", …)` isn't read: the call ends with [`Flow exited without producing output`](https://frontmcp.dev/reference/sdk/hooks#flow-exited-without-producing-output). - In `Did("finalize")`, `ctx.state.output` isn't set yet. `ctx.state.rawOutput` is what `execute()` returned, if it returned. - `HttpHook` hooks see requests as they arrive. For 2026-07-28 requests, the flow's closing stages can run before the call they carry is answered, so don't time requests with them. --- ## Usage ### Running code before and after a stage A `Will` hook runs before its stage, and a `Did` hook after it if the stage worked. The closing stages, like `finalize`, run for every call. This plugin records which of its hooks ran: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts trace.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; import { Trace } from "./trace"; type CallCtx = FlowCtxOf<"tools:call-tool">; @Plugin({ name: "trace", providers: [Trace], exports: [Trace] }) export class TracePlugin extends DynamicPlugin { private note(ctx: CallCtx, line: string) { if (ctx.state.input?.name !== "show_trace") this.get(Trace).lines.push(line); } @ToolHook.Will("validateInput") async willValidate(ctx: CallCtx) { this.note(ctx, "will validateInput"); } @ToolHook.Did("validateInput") async didValidate(ctx: CallCtx) { this.note(ctx, "did validateInput"); } @ToolHook.Will("execute") async willExecute(ctx: CallCtx) { this.note(ctx, "will execute"); } @ToolHook.Did("execute") async didExecute(ctx: CallCtx) { this.note(ctx, "did execute"); } @ToolHook.Did("finalize") async didFinalize(ctx: CallCtx) { this.note(ctx, "did finalize"); } } ``` ```ts trace.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "Trace" }) export class Trace { lines: string[] = []; } ``` ```ts tools.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { Trace } from "./trace"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { if (id === "T-9") this.fail(new PublicMcpError("There's no ticket T-9.")); return { id, title: "Cannot log in" }; } } @Tool({ name: "show_trace", description: "Show the hooks that ran since the last call to show_trace", inputSchema: {} }) export class ShowTrace extends ToolContext { async execute() { return { trace: this.get(Trace).lines.splice(0) }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket, ShowTrace } from "./tools"; import { TracePlugin } from "./trace.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, ShowTrace], plugins: [TracePlugin] }) export class HelpDeskApp {} ``` ```ts stages.test.ts import { test, expect } from "@frontmcp/testing"; const traceOf = async (mcp: any) => (await mcp.tools.call("show_trace", {})).json().trace; test("a call that works runs every hook", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-1" }); expect(await traceOf(mcp)).toEqual(["will validateInput", "did validateInput", "will execute", "did execute", "did finalize"]); }); test("when execute fails, its Did hook is skipped", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-9" }); expect(await traceOf(mcp)).toEqual(["will validateInput", "did validateInput", "will execute", "did finalize"]); }); test("when validation fails, execute is skipped entirely", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "ticket nine" }); expect(await traceOf(mcp)).toEqual(["will validateInput", "did finalize"]); }); ``` For code that must run whether a stage worked or not, use `Around` or a hook on a closing stage like `Did("finalize")`. ### Wrapping a stage An `Around` hook runs the stage itself, with `await next()`. It can catch a failure, and it can run the stage again. This one retries tools marked `idempotentHint` once: Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-7" } } ``` ```ts retry.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; type CallCtx = FlowCtxOf<"tools:call-tool">; @Plugin({ name: "retry", description: "Retries idempotent tools once when they fail" }) export class RetryPlugin extends DynamicPlugin { @ToolHook.Around("execute", { filter: (ctx) => ctx.state.tool?.metadata.annotations?.idempotentHint === true }) async retryOnce(ctx: CallCtx, next: () => Promise) { try { await next(); // runs execute() } catch (error) { ctx.scopeLogger.warn(`${ctx.state.tool?.metadata.name} failed, retrying once`); await next(); // runs execute() again; if it fails again, so does the call } } } ``` ```ts billing.ts import { Provider, Tool, ToolContext, z } from "@frontmcp/sdk"; // Stands in for a service that drops the first request of each pair @Provider({ name: "BillingApi" }) export class BillingApi { requests = 0; fetchInvoice(id: string) { this.requests += 1; if (this.requests % 2 === 1) throw new Error("Connection reset"); return { id, total: 120, attempt: this.requests }; } } @Tool({ name: "get_invoice", description: "Get one invoice by its id", inputSchema: { id: z.string() }, annotations: { readOnlyHint: true, idempotentHint: true }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return this.get(BillingApi).fetchInvoice(id); } } @Tool({ name: "charge_invoice", description: "Charge an invoice to the customer's card", inputSchema: { id: z.string() } }) export class ChargeInvoice extends ToolContext { async execute({ id }: { id: string }) { return this.get(BillingApi).fetchInvoice(id); } } ``` ```ts billing.app.ts import { App } from "@frontmcp/sdk"; import { BillingApi, ChargeInvoice, GetInvoice } from "./billing"; import { RetryPlugin } from "./retry.plugin"; @App({ id: "billing", name: "Billing", tools: [GetInvoice, ChargeInvoice], providers: [BillingApi], plugins: [RetryPlugin] }) export class BillingApp {} ``` ```ts retry.test.ts import { test, expect } from "@frontmcp/testing"; test("an idempotent tool is retried, and the second attempt's result is sent", async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id: "INV-7" }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ id: "INV-7", total: 120, attempt: 2 }); }); test("other tools aren't retried", async ({ mcp }) => { const result = await mcp.tools.call("charge_invoice", { id: "INV-7" }); expect(result).toBeError(); expect(result).toHaveTextContent("Connection reset"); }); ``` The `filter` keeps the hook off `charge_invoice`, which isn't safe to run twice. Swallowing the error without running the stage again would leave the call with no result: see [Troubleshooting](https://frontmcp.dev/reference/sdk/hooks#flow-exited-without-producing-output). ### Answering without running the rest `ctx.respond()` ends the request with a result of your own. In `Will("findTool")` it runs before FrontMCP looks the tool up, so it can answer calls to a tool that doesn't exist, like one that was renamed: Initial call configuration: ```js { tool: "find_ticket", arguments: { id: "T-1" } } ``` ```ts renames.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; const renamed: Record = { find_ticket: "get_ticket", ticket_search: "search_tickets" }; @Plugin({ name: "renames", description: "Tells the model the new name of a renamed tool" }) export class RenamesPlugin extends DynamicPlugin { @ToolHook.Will("findTool") async pointToNewName(ctx: FlowCtxOf<"tools:call-tool">) { const name = ctx.state.input?.name; const newName = name ? renamed[name] : undefined; if (!newName) return; ctx.respond({ content: [{ type: "text", text: `${name} was renamed to ${newName}. Call ${newName} with the same arguments.` }], isError: true, }); } } ``` ```ts tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in" }; } } @Tool({ name: "get_invoice", description: "Get one invoice by its id", inputSchema: { id: z.string() } }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, total: 120 }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { GetInvoice, GetTicket } from "./tools"; import { RenamesPlugin } from "./renames.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [RenamesPlugin] }) export class HelpDeskApp {} @App({ id: "billing", name: "Billing", tools: [GetInvoice] }) export class BillingApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp] }) export default class Server {} ``` ```ts renames.test.ts import { test, expect } from "@frontmcp/testing"; test("the old name gets a result that names the new one", async ({ mcp }) => { const result = await mcp.tools.call("find_ticket", { id: "T-1" }); expect(result).toBeError(); expect(result.text()).toBe("find_ticket was renamed to get_ticket. Call get_ticket with the same arguments."); }); test("other unknown names still fail as usual", async ({ mcp }) => { const result = await mcp.tools.call("delete_everything", {}); expect(result).not.toHaveTextContent("renamed"); }); test("the new name works", async ({ mcp }) => { expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeSuccessful(); }); ``` An app's plugin hooks `tools/call` only for that app's tools, but a name no app has belongs to no app, and FrontMCP runs every app's hooks for it: the plugin is on the help desk app and still answers `find_ticket` for the whole server. The result you pass is sent as it is: give it `content`, or the client gets `"content": []`. Answering from a cache in `Will("execute")` works the same way: see [`this.respond`](https://frontmcp.dev/reference/sdk/respond#answering-a-call-from-a-hook). ### Adding to the result's `_meta` A hook that wants to say something about a result, without changing the tool's data, sets `ctx.state.resultMeta`. FrontMCP merges it into the result's `_meta` when the call finishes, and `structuredContent` and the text stay what the tool returned. The [Cache plugin](https://frontmcp.dev/reference/plugins/cache) marks a hit this way, with `cache: "hit"`. Here a plugin marks every result as audited: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts audit.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; @Plugin({ name: "audit" }) export class AuditPlugin extends DynamicPlugin { @ToolHook.Did("execute") async mark(ctx: FlowCtxOf<"tools:call-tool">) { ctx.state.set("resultMeta", { audited: true }); } } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { AuditPlugin } from "./audit.plugin"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() }, outputSchema: { id: z.string(), status: z.string() }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [AuditPlugin] }) export class HelpDeskApp {} ``` ```ts result-meta.test.ts import { test, expect } from "@frontmcp/testing"; test("`resultMeta` goes into `_meta`", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result.raw._meta?.audited).toBe(true); }); test("the tool's data is what `execute()` returned", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result.raw.structuredContent).toEqual({ id: "T-1", status: "open" }); expect(result.json()).toEqual({ id: "T-1", status: "open" }); }); ``` ### Hooking resources and prompts `ResourceHook` hooks `resources/read`, and `PromptHook` hooks `prompts/get`. Their state has `resource` and `resourceContext`, or `prompt` and `promptContext`, in place of `tool` and `toolContext`: Initial call configuration: ```js { resource: "desk://tickets/T-1" } ``` ```ts reads.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, PromptHook, Provider, ResourceHook } from "@frontmcp/sdk"; @Provider({ name: "ReadLog" }) export class ReadLog { lines: string[] = []; } @Plugin({ name: "reads", providers: [ReadLog], exports: [ReadLog] }) export class ReadsPlugin extends DynamicPlugin { @ResourceHook.Did("execute") async resourceRead(ctx: FlowCtxOf<"resources:read-resource">) { this.get(ReadLog).lines.push(`read ${ctx.state.input?.uri}`); } @PromptHook.Will("execute") async promptRequested(ctx: FlowCtxOf<"prompts:get-prompt">) { this.get(ReadLog).lines.push(`prompt ${ctx.state.input?.name} ${JSON.stringify(ctx.state.input?.arguments)}`); } } ``` ```ts entries.ts import { Prompt, PromptContext, ResourceContext, ResourceTemplate, type GetPromptResult } from "@frontmcp/sdk"; @ResourceTemplate({ name: "ticket", uriTemplate: "desk://tickets/{id}", mimeType: "application/json" }) export class TicketResource extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { return { id, title: "Cannot log in" }; } } @Prompt({ name: "summarize_ticket", description: "Summarize a ticket for a handover", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class SummarizeTicket extends PromptContext { async execute({ id }: Record): Promise { return { messages: [{ role: "user", content: { type: "text", text: `Summarize ticket ${id} for the next agent on shift.` } }] }; } } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext } from "@frontmcp/sdk"; import { SummarizeTicket, TicketResource } from "./entries"; import { ReadLog, ReadsPlugin } from "./reads.plugin"; @Tool({ name: "read_log", description: "Show resource reads and prompt requests", inputSchema: {} }) export class ShowReadLog extends ToolContext { async execute() { return { lines: [...this.get(ReadLog).lines] }; } } @App({ id: "help-desk", name: "Help Desk", tools: [ShowReadLog], resources: [TicketResource], prompts: [SummarizeTicket], plugins: [ReadsPlugin], }) export class HelpDeskApp {} ``` ```ts reads.test.ts import { test, expect } from "@frontmcp/testing"; test("reads and prompt requests are both logged", async ({ mcp }) => { await mcp.resources.read("desk://tickets/T-1"); await mcp.prompts.get("summarize_ticket", { id: "T-2" }); expect((await mcp.tools.call("read_log", {})).json().lines).toEqual([ "read desk://tickets/T-1", 'prompt summarize_ticket {"id":"T-2"}', ]); }); ``` ### Changing what clients list A hook on a list flow's `find…` stage can filter what's listed. This one hides every resource whose URI has an `internal/` path from `resources/list`: Initial call configuration: ```js { resource: "desk://internal/escalations" } ``` ```ts hide-internal.plugin.ts active import { DynamicPlugin, FlowCtxOf, ListResourcesHook, Plugin } from "@frontmcp/sdk"; const uriOf = (metadata: { uri?: string; uriTemplate?: string }) => metadata.uri ?? metadata.uriTemplate ?? ""; @Plugin({ name: "hide-internal" }) export class HideInternalPlugin extends DynamicPlugin { @ListResourcesHook.Did("findResources") async hide(ctx: FlowCtxOf<"resources:list-resources">) { const resources = ctx.state.resources ?? []; ctx.state.set("resources", resources.filter(({ resource }) => !uriOf(resource.metadata).includes("://internal/"))); } } ``` ```ts main.ts import { App, FrontMcp, Resource, ResourceContext } from "@frontmcp/sdk"; import { HideInternalPlugin } from "./hide-internal.plugin"; @Resource({ name: "open-tickets", uri: "desk://open-tickets", mimeType: "application/json" }) export class OpenTickets extends ResourceContext { async execute() { return { count: 12 }; } } @Resource({ name: "escalations", uri: "desk://internal/escalations", mimeType: "application/json" }) export class Escalations extends ResourceContext { async execute() { return { escalated: ["T-7"] }; } } @Resource({ name: "ledger", uri: "billing://internal/ledger", mimeType: "application/json" }) export class Ledger extends ResourceContext { async execute() { return { balance: 0 }; } } @App({ id: "help-desk", name: "Help Desk", resources: [OpenTickets, Escalations], plugins: [HideInternalPlugin] }) export class HelpDeskApp {} // No plugin here @App({ id: "billing", name: "Billing", resources: [Ledger] }) export class BillingApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp] }) export default class Server {} ``` ```ts list.test.ts import { test, expect } from "@frontmcp/testing"; test("internal resources aren't listed", async ({ mcp }) => { const resources = await mcp.resources.list(); expect(resources).toContainResource("desk://open-tickets"); expect(resources).not.toContainResource("desk://internal/escalations"); }); test("the help desk app's list hook hides the billing app's resource too", async ({ mcp }) => { expect(await mcp.resources.list()).not.toContainResource("billing://internal/ledger"); }); test("hiding isn't blocking: a hidden resource can still be read", async ({ mcp }) => { expect((await mcp.resources.read("desk://internal/escalations")).json()).toEqual({ escalated: ["T-7"] }); }); ``` The plugin is on the help desk app, but list flows aren't tied to an app, so it hides the billing app's ledger too. To refuse reads as well, add a `ResourceHook.Will("execute")` that throws. ### Hooks on a tool or a provider Hooks don't need a plugin. On a `@Tool` class they run only for that tool, and `this` is the call's tool context. On a provider in `@App({ providers })`, they run for every tool of the app: ### Declaring hooks without a plugin ### Example: On a tool Initial call configuration: ```js { tool: "schedule_callback", arguments: { id: "T-1", at: "2020-01-01T09:00:00Z" } } ``` ```ts schedule-callback.tool.ts active import { FlowCtxOf, PublicMcpError, Tool, ToolContext, ToolHook, z } from "@frontmcp/sdk"; type Input = { id: string; at: string }; @Tool({ name: "schedule_callback", description: "Schedule a phone call back to the customer", inputSchema: { id: z.string(), at: z.string().datetime().describe("When to call, as an ISO date and time") }, }) export class ScheduleCallback extends ToolContext { // Runs for this tool only; `this` is the call's ToolContext, so this.input is the validated input @ToolHook.Will("execute") async refusePastTimes(ctx: FlowCtxOf<"tools:call-tool">) { const { at } = this.input as Input; if (new Date(at) < new Date("2026-01-01")) throw new PublicMcpError(`${at} is in the past. Pick a time after now.`); } async execute({ id, at }: Input) { return { id, callbackAt: at }; } } ``` ```ts callback.test.ts import { test, expect } from "@frontmcp/testing"; test("a time in the past is refused by the tool's own hook", async ({ mcp }) => { const result = await mcp.tools.call("schedule_callback", { id: "T-1", at: "2020-01-01T09:00:00Z" }); expect(result).toBeError(); expect(result.text()).toBe("2020-01-01T09:00:00Z is in the past. Pick a time after now."); }); test("a future time is scheduled", async ({ mcp }) => { expect(await mcp.tools.call("schedule_callback", { id: "T-1", at: "2027-01-01T09:00:00Z" })).toBeSuccessful(); }); ``` ### Example: On an app's provider Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-7" } } ``` ```ts legal-hold.ts active import { FlowCtxOf, Provider, PublicMcpError, ToolHook } from "@frontmcp/sdk"; @Provider({ name: "LegalHold" }) export class LegalHold { held = ["T-7"]; // On a provider in @App({ providers }): runs for every tool in that app @ToolHook.Will("execute") async refuseHeldTickets(ctx: FlowCtxOf<"tools:call-tool">) { const id = (ctx.state.toolContext?.input as { id?: string } | undefined)?.id; if (id && this.held.includes(id)) throw new PublicMcpError(`Ticket ${id} is on legal hold.`); } } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { LegalHold } from "./legal-hold"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket, GetTicket], providers: [LegalHold] }) export class HelpDeskApp {} ``` ```ts hold.test.ts import { test, expect } from "@frontmcp/testing"; test("the provider's hook runs for every tool in the app", async ({ mcp }) => { expect(await mcp.tools.call("close_ticket", { id: "T-7" })).toHaveTextContent("legal hold"); expect(await mcp.tools.call("get_ticket", { id: "T-7" })).toHaveTextContent("legal hold"); expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeSuccessful(); }); ``` A tool's instance methods can only hook stages from `Did("createToolCallContext")` on: before that, there's no tool instance to run them on. A `static` method can hook any stage, as the next section shows. A provider's hooks run on an app's provider, a `CONTEXT` one included, and on a provider in `@FrontMcp({ providers })` for every app ([where hooks can be declared](https://frontmcp.dev/reference/sdk/hooks#where-hooks-can-be-declared)). ### Hooking the stages before an entry's instance A `static` hook method on a tool class runs for that tool only, like an instance method, but needs no instance: `this` is the class, and it can hook the stages before `createToolCallContext`. Here one cleans up the id the client sent before anything checks it, and another refuses an archived ticket before the tool is built. `close_ticket` takes the same input and has no hooks of its own: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: " t-2 " } } ``` ```ts get-ticket.tool.ts active import { FlowCtxOf, PublicMcpError, Tool, ToolContext, ToolHook, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string().regex(/^T-\d+$/) } }) export class GetTicket extends ToolContext { @ToolHook.Did("parseInput") static cleanId(ctx: FlowCtxOf<"tools:call-tool">) { const input = ctx.state.required.input; const id = input.arguments?.id; if (typeof id !== "string") return; ctx.state.set("input", { ...input, arguments: { ...input.arguments, id: id.trim().toUpperCase() } }); } @ToolHook.Will("checkToolAuthorization") static refuseArchived(ctx: FlowCtxOf<"tools:call-tool">) { if (ctx.state.input?.arguments?.id === "T-0") throw new PublicMcpError("T-0 is archived."); } async execute({ id }: { id: string }) { return { id, title: "Cannot log in" }; } } @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string().regex(/^T-\d+$/) } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } ``` ```ts static-hooks.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance, Tool, ToolContext, ToolHook } from "@frontmcp/sdk"; test("the static hook cleans up the id before the schema checks it", async ({ mcp }) => { expect((await mcp.tools.call("get_ticket", { id: " t-2 " })).json()).toEqual({ id: "T-2", title: "Cannot log in" }); }); test("it runs for its own tool only", async ({ mcp }) => { expect(await mcp.tools.call("close_ticket", { id: " t-2 " })).toBeError("INVALID_INPUT"); }); test("an archived ticket is refused before the tool is built", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "t-0" }); expect(result).toBeError("PUBLIC_ERROR"); expect(result.text()).toBe("T-0 is archived."); }); test("an instance method on an early stage stops the server from starting", async () => { @Tool({ name: "early", description: "Hooks a stage it can't reach", inputSchema: {} }) class Early extends ToolContext { @ToolHook.Will("findTool") async tooSoon() {} async execute() { return {}; } } @App({ id: "help-desk", name: "Help Desk", tools: [Early] }) class HelpDeskApp {} await expect(FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] })).rejects.toThrow( /tooSoon\(\) \(will 'findTool' of tools:call-tool\): runs before 'createToolCallContext' builds the instance it runs on; declare it as a static method/, ); }); ``` A static hook gets the same `ctx` as a plugin's, so it reads and changes `ctx.state` the same way: here `input`, which `parseInput` sets. It has no `this.input` or `this.get()`: `this` is the class, on a later stage too. With the same priority, an entry's static hooks run after the providers' and plugins' hooks on that stage, as its instance hooks do. `@Resource`, `@Prompt`, `@Agent` and `@Job` classes take static hooks the same way, each on its own flow, like `PromptHook.Will("findPrompt")`, `AgentCallHook.Will("findAgent")` or `JobHook.Did("parseInput")`. Hooks on list flows, like `ListToolsHook`, still stop the server from starting on an entry class, static or not: put them on a plugin or a provider. New in 1.9.4: before, only a provider or a plugin could hook these stages, and the startup error didn't mention static methods ([Troubleshooting](https://frontmcp.dev/reference/sdk/hooks#tool--declares-hooks-that-would-never-run)). ### Hooking an agent's calls A client calls an agent as a tool, `invoke_`, so a `ToolHook` runs for it. Inside that call, the agent runs in its own flow, `agents:call-agent`, whose hooks are `AgentCallHook`, on a plugin or on the agent class. The tools the agent's model calls are the agent's own: a plugin on the app or the server doesn't hook them, only a plugin in `@Agent({ plugins })` does ([`@Agent`](https://frontmcp.dev/reference/sdk/agent#giving-the-agents-tools-a-plugin)): Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts call-log.plugin.ts active import { AgentCallHook, DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; export const calls: string[] = []; @Plugin({ name: "call-log" }) export class CallLog extends DynamicPlugin { @ToolHook.Will("execute") async tool(ctx: FlowCtxOf<"tools:call-tool">) { calls.push(`tool ${ctx.state.tool?.metadata.name}`); } @AgentCallHook.Will("execute") async agent(ctx: FlowCtxOf<"agents:call-agent">) { calls.push(`agent ${ctx.state.agent?.metadata.name}`); } } ``` ```ts main.ts import { Agent, AgentContext, App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; import { CallLog } from "./call-log.plugin"; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in" }; } } // A stand-in model: it reads the ticket, then answers. @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket], llm: { adapter: { async completion(prompt) { if (prompt.messages.length === 1) return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "1", name: "get_ticket", arguments: { id: "T-1" } }] }; return { content: "High", finishReason: "stop" }; }, }, }, }) export class Triage extends AgentContext {} @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) export class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [CallLog] }) export default class Server {} ``` ```ts agent-hooks.test.ts import { test, expect } from "@frontmcp/testing"; import { calls } from "./call-log.plugin"; test("the tool call, then the agent's own flow; not the agent's tool", async ({ mcp }) => { calls.length = 0; await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(calls).toEqual(["tool invoke_triage", "agent triage"]); }); ``` The limits of `@Agent`, like `rateLimit`, are applied once, by the tool call. Changed in 1.8.5: before, the `agents:call-agent` flow never ran, so `AgentCallHook` hooks never did. ### Hooking a job's attempts Each attempt of a [job](https://frontmcp.dev/reference/sdk/job) runs the `jobs:execute-job` flow, whose hooks are `JobHook`: a call to `execute_job` while the client waits, a run in the background, each retry, and each step of a [workflow](https://frontmcp.dev/reference/sdk/workflow). Here a plugin records how each attempt ended, and answers `count-open-tickets` from a snapshot instead of running it: Initial call configuration: ```js { tool: "execute_job", arguments: { name: "notify-agent", input: { agent: "nour" } } } ``` ```ts job-audit.plugin.ts active import { DynamicPlugin, FlowCtxOf, JobHook, Plugin } from "@frontmcp/sdk"; export const attempts: string[] = []; @Plugin({ name: "job-audit" }) export class JobAudit extends DynamicPlugin { @JobHook.Did("updateRunState") async record(ctx: FlowCtxOf<"jobs:execute-job">) { const { job, attempt, flowError } = ctx.state; attempts.push(`${job?.name} attempt ${attempt}: ${flowError ? flowError.message : "ok"}`); } @JobHook.Will("execute", { filter: (ctx) => ctx.state.job?.name === "count-open-tickets" }) async fromSnapshot(ctx: FlowCtxOf<"jobs:execute-job">) { ctx.respond({ result: { open: 12 }, logs: ["Read from the 06:00 snapshot"] }); } } ``` ```ts jobs.ts import { Job, JobContext, z } from "@frontmcp/sdk"; let mailCalls = 0; export let counted = 0; @Job({ name: "notify-agent", inputSchema: { agent: z.string() }, outputSchema: { sent: z.boolean() }, retry: { maxAttempts: 3, backoffMs: 10 } }) export class NotifyAgent extends JobContext { async execute() { mailCalls += 1; if (mailCalls % 3 !== 0) throw new Error("Mail server busy"); return { sent: true }; } } @Job({ name: "count-open-tickets", inputSchema: {}, outputSchema: { open: z.number() } }) export class CountOpenTickets extends JobContext { async execute() { counted += 1; return { open: 15 }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { JobAudit } from "./job-audit.plugin"; import { CountOpenTickets, NotifyAgent } from "./jobs"; @App({ id: "help-desk", name: "Help Desk", jobs: [NotifyAgent, CountOpenTickets], plugins: [JobAudit] }) export class HelpDeskApp {} ``` ```ts job-hooks.test.ts import { test, expect } from "@frontmcp/testing"; import { attempts } from "./job-audit.plugin"; import { counted } from "./jobs"; test("each attempt runs the flow, retries included", async ({ mcp }) => { attempts.length = 0; const result = await mcp.tools.call("execute_job", { name: "notify-agent", input: { agent: "nour" } }); expect(result.json()).toMatchObject({ state: "completed", result: { sent: true } }); expect(attempts).toEqual([ "notify-agent attempt 1: Mail server busy", "notify-agent attempt 2: Mail server busy", "notify-agent attempt 3: ok", ]); }); test("a hook's `respond()` is the run's result, and the job doesn't run", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: {} }); expect(result.json()).toMatchObject({ state: "completed", result: { open: 12 }, logs: ["Read from the 06:00 snapshot"] }); expect(counted).toBe(0); }); ``` `ctx.respond()` takes the attempt's whole answer, `{ result, logs }`. The result is checked against the job's `outputSchema`: extra fields are dropped, and a mismatch fails the attempt with `INVALID_OUTPUT` without retrying. Changed in 1.9.5: before, hook results skipped validation. `updateRunState` still runs, so the run is `completed` and `Did("updateRunState")` hooks see it, like the first hook here. That hook runs for failed attempts too: `updateRunState` is a closing stage, and `ctx.state.flowError` is the attempt's error. [`jobs:execute-job`](https://frontmcp.dev/reference/server/flows#jobsexecute-job) lists the stages and what each one sets. An app's plugins hook that app's jobs only; a plugin on `@FrontMcp` hooks every app's. On a `@Job` class, instance methods hook from `Did("createJobContext")` on, with `this` the attempt's job context, and `static` methods hook any stage. New in 1.9.4: before, jobs ran through no flow, so the closest a hook got was the `execute_job` tool's `tools:call-tool`: one call, not each attempt, and only the start of a run in the background. ### Seeing every HTTP request `HttpHook` runs for each HTTP request the server receives, before FrontMCP routes it, so it sees requests of every kind, including ones that fail later. `ctx.rawInput.request` is the request, with its parsed `body`: Initial call configuration: ```js { tool: "request_counts", arguments: {} } ``` ```ts requests.plugin.ts active import { DynamicPlugin, FlowCtxOf, HttpHook, Plugin, Provider } from "@frontmcp/sdk"; @Provider({ name: "RequestCounts" }) export class RequestCounts { byMethod: Record = {}; } @Plugin({ name: "requests", providers: [RequestCounts], exports: [RequestCounts] }) export class RequestsPlugin extends DynamicPlugin { @HttpHook.Will("router") async count(ctx: FlowCtxOf<"http:request">) { const method: string = ctx.rawInput.request.body?.method ?? "(not JSON-RPC)"; const { byMethod } = this.get(RequestCounts); byMethod[method] = (byMethod[method] ?? 0) + 1; } } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext } from "@frontmcp/sdk"; import { RequestCounts, RequestsPlugin } from "./requests.plugin"; @Tool({ name: "request_counts", description: "How many requests of each kind the server has had", inputSchema: {} }) export class ShowRequestCounts extends ToolContext { async execute() { return { ...this.get(RequestCounts).byMethod }; } } @App({ id: "help-desk", name: "Help Desk", tools: [ShowRequestCounts], plugins: [RequestsPlugin] }) export class HelpDeskApp {} ``` ```ts requests.test.ts import { test, expect } from "@frontmcp/testing"; test("every request is counted by its JSON-RPC method", async ({ mcp }) => { await mcp.tools.list(); await mcp.resources.list(); await mcp.tools.call("no_such_tool", {}); expect((await mcp.tools.call("request_counts", {})).json()).toEqual({ "tools/list": 1, "resources/list": 1, "tools/call": 2, }); }); ``` The call to `no_such_tool` is counted too: the hook ran before FrontMCP found out the tool doesn't exist. The Playground's own requests, like the one it sends when the example starts, go through the same hook. ### The order hooks run in This example puts every kind of hook on one stage, from an app provider, two app plugins, a server plugin and the tool itself, and records the order they ran in: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts plugins.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; import { trace, type CallCtx } from "./trace"; @Plugin({ name: "first" }) export class FirstPlugin extends DynamicPlugin { @ToolHook.Will("execute") async will(ctx: CallCtx) { trace(ctx, "first plugin: Will"); } @ToolHook.Did("execute", { priority: 10 }) async did(ctx: CallCtx) { trace(ctx, "first plugin: Did, priority 10"); } @ToolHook.Around("execute", { priority: 5 }) async inner(ctx: CallCtx, next: () => Promise) { trace(ctx, "first plugin: Around, priority 5, before next()"); await next(); trace(ctx, "first plugin: Around, priority 5, after next()"); } @ToolHook.Stage("execute", { priority: 10 }) async afterTheTool(ctx: CallCtx) { trace(ctx, "first plugin: Stage, priority 10"); } } @Plugin({ name: "second" }) export class SecondPlugin extends DynamicPlugin { @ToolHook.Will("execute", { priority: -10 }) async will(ctx: CallCtx) { trace(ctx, "second plugin: Will, priority -10"); } @ToolHook.Did("execute") async did(ctx: CallCtx) { trace(ctx, "second plugin: Did"); } @ToolHook.Around("execute") async outer(ctx: CallCtx, next: () => Promise) { trace(ctx, "second plugin: Around, before next()"); await next(); trace(ctx, "second plugin: Around, after next()"); } @ToolHook.Stage("execute") async beforeTheTool(ctx: CallCtx) { trace(ctx, "second plugin: Stage"); } } @Plugin({ name: "server-wide" }) export class ServerPlugin extends DynamicPlugin { @ToolHook.Will("execute") async will(ctx: CallCtx) { trace(ctx, "server plugin: Will"); } } ``` ```ts trace.ts import { FlowCtxOf, Provider, ProviderScope } from "@frontmcp/sdk"; export type CallCtx = FlowCtxOf<"tools:call-tool">; // One per call, so each call's result shows only its own trace @Provider({ name: "Trace", scope: ProviderScope.CONTEXT }) export class Trace { lines: string[] = []; } export const trace = (ctx: CallCtx, line: string) => ctx.state.toolContext?.get(Trace).lines.push(line); ``` ```ts main.ts import { App, FlowCtxOf, FrontMcp, Provider, Tool, ToolContext, ToolHook, z } from "@frontmcp/sdk"; import { FirstPlugin, SecondPlugin, ServerPlugin } from "./plugins"; import { Trace, trace } from "./trace"; @Provider({ name: "Guard" }) export class Guard { @ToolHook.Will("execute") async will(ctx: FlowCtxOf<"tools:call-tool">) { trace(ctx, "app provider: Will"); } } @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { @ToolHook.Will("execute") async will(ctx: FlowCtxOf<"tools:call-tool">) { trace(ctx, "tool class: Will"); } async execute({ id }: { id: string }) { this.get(Trace).lines.push("get_ticket runs"); return { id, trace: this.get(Trace).lines }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], providers: [Guard, Trace], plugins: [FirstPlugin, SecondPlugin], }) export class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [ServerPlugin] }) export default class Server {} ``` ```ts order.test.ts import { test, expect } from "@frontmcp/testing"; test("hooks run by kind, then priority, then where they're declared", async ({ mcp }) => { const { trace } = (await mcp.tools.call("get_ticket", { id: "T-1" })).json(); expect(trace).toEqual([ "second plugin: Will, priority -10", "app provider: Will", "first plugin: Will", "server plugin: Will", "tool class: Will", "second plugin: Around, before next()", "first plugin: Around, priority 5, before next()", "second plugin: Stage", "get_ticket runs", "first plugin: Stage, priority 10", "first plugin: Around, priority 5, after next()", "second plugin: Around, after next()", "second plugin: Did", "first plugin: Did, priority 10", ]); }); ``` The `Did` lines are in the result because the hooks add them to the same array after the tool returns it and before FrontMCP formats it. --- ## Troubleshooting ### My hook never runs Check, in order: 1. The class the hook is on is registered: the plugin in `plugins`, the provider in `@App({ providers })`. 2. It's declared on something whose hooks run ([where hooks can be declared](https://frontmcp.dev/reference/sdk/hooks#where-hooks-can-be-declared)). Before 1.9, hooks on a provider in `@FrontMcp({ providers })` or on a `CONTEXT` provider never ran; before 1.8.5, `AgentCallHook` never ran anywhere. 3. The stage exists in the flow: a hook on a stage name that isn't in the [stage list](https://frontmcp.dev/reference/sdk/hooks#decorator-sets-and-their-flows) never runs. 4. The request reaches the stage. `Did` hooks are skipped when their stage fails, and a failed stage skips every later stage but the closing ones. 5. For a plugin on an app: the call is to one of that app's tools, or the job is one of that app's jobs. For every app's, register the plugin on `@FrontMcp`, or give the hook [`appliesTo: "uncovered-apps"`](https://frontmcp.dev/reference/sdk/hooks#options) to reach the apps that don't have the plugin. For a plugin on an agent: the call comes from the agent's model, a tool call or, since 1.9.4, a read of the agent's resources or prompts, not from a client. 6. `filter` returns `true` for this request. ### `Tool "…" declares hooks that would never run` The server didn't start, with `InvalidHookFlowError` (`INVALID_HOOK_FLOW`). The full message names each hook that can't run, and why: ``` Tool "Early" declares hooks that would never run: tooSoon() (will 'findTool' of tools:call-tool): runs before 'createToolCallContext' builds the instance it runs on; declare it as a static method to run it without an instance. Hooks declared as instance methods on a tool class run on the instance built for each call; static methods run without one, from the first stage of a call. Declare hooks for list flows on a provider or a plugin instead. ``` An instance method hooks a stage before the entry's instance is built. Make it `static`, reading `ctx.state` instead of `this`, as in [Hooking the stages before an entry's instance](https://frontmcp.dev/reference/sdk/hooks#hooking-the-stages-before-an-entrys-instance), or move it to a plugin. For a list flow, like `(did 'findTools' of tools:list-tools): list flows build no entry instance`, move the hook to a plugin or a provider: `static` doesn't help there. `Resource "…"`, `Prompt "…"`, `Agent "…"` and `Job "…"` read the same way. A hook for another flow, like a `ToolHook` on a job class, fails with `Job "…" has hooks for unsupported flows: … on tools:call-tool`. ### `Flow exited without producing output` The call ended with no result, code `FLOW_EXITED_WITHOUT_OUTPUT`. An `Around` hook caught the error `next()` threw and didn't rethrow it, or returned without calling `next()` and without setting a result: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-9" } } ``` ```ts log-errors.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; @Plugin({ name: "log-errors" }) export class LogErrorsPlugin extends DynamicPlugin { @ToolHook.Around("execute") async logFailures(ctx: FlowCtxOf<"tools:call-tool">, next: () => Promise) { try { await next(); } catch (error) { ctx.scopeLogger.error(`${ctx.state.tool?.metadata.name} failed`); // 🚩 The error is swallowed, and the call has no result } } } ``` ```ts help-desk.app.ts import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { LogErrorsPlugin } from "./log-errors.plugin"; @Tool({ name: "get_ticket", description: "Get one support ticket by its 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" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [LogErrorsPlugin] }) export class HelpDeskApp {} ``` ```ts swallowed.test.ts import { test, expect } from "@frontmcp/testing"; test("the tool's error is replaced by FLOW_EXITED_WITHOUT_OUTPUT", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-9" }); expect(result.raw._meta?.code).toBe("FLOW_EXITED_WITHOUT_OUTPUT"); expect(result.text()).toBe("Flow exited without producing output"); }); ``` Add `throw error;` after logging, and the client gets the tool's own error, `There's no ticket T-9.`, again. To answer instead of failing, give the call a result: assign `ctx.state.toolContext.output` before returning, or call `ctx.respond()`. `ctx.state.set("output", …)` isn't read. ### `Provider "…" is not available` from `ctx.get()` in a hook `ctx.get()` only finds the server's providers, the ones in `@FrontMcp({ providers })`: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts region.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; import { DeskSettings } from "./desk-settings"; @Plugin({ name: "region" }) export class RegionPlugin extends DynamicPlugin { @ToolHook.Will("execute") async checkRegion(ctx: FlowCtxOf<"tools:call-tool">) { // 🚩 DeskSettings is an app provider: ctx.get() can't find it. this.get(DeskSettings) can. const settings = ctx.get(DeskSettings); ctx.scopeLogger.info(`region ${settings.region}`); } } ``` ```ts desk-settings.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "DeskSettings" }) export class DeskSettings { region = "eu"; } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { DeskSettings } from "./desk-settings"; import { RegionPlugin } from "./region.plugin"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], providers: [DeskSettings], plugins: [RegionPlugin] }) export class HelpDeskApp {} ``` ```ts provider.test.ts import { test, expect } from "@frontmcp/testing"; test("ctx.get() doesn't find the app's provider", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result).toBeError(); expect(result).toHaveTextContent('Provider "DeskSettings" is not available'); }); ``` In a plugin's hook, use `this.get()`, which finds the plugin's providers and the app's. In any `tools:call-tool` hook, `ctx.state.toolContext?.get()` finds what the tool would. ### The client gets `"content": []` after `ctx.respond()` `ctx.respond()` sends what you pass as the whole result. Pass a complete `tools/call` result: `{ content: [{ type: "text", text }], structuredContent? }`. ### My `Did` hook doesn't see failed calls `Did` hooks only run when their stage worked. To see failures too, use `Around` and catch what `next()` throws, as in [Wrapping a stage](https://frontmcp.dev/reference/sdk/hooks#wrapping-a-stage), or a hook on a closing stage like `Did("finalize")`. ### `ctx.respond()` in a `Did` hook does nothing By the time a `Did` hook runs, the stage has finished, and FrontMCP ignores `ctx.respond()` there. In `Did("execute")`, assign `ctx.state.toolContext.output` to change the result, as in [Hooking into Calls](https://frontmcp.dev/learn/hooking-into-calls#changing-the-result). ### The client sees "Internal FrontMCP error" instead of my hook's message The hook threw an error that isn't a `PublicMcpError`, and the server runs in production, which hides the messages of other errors. Throw `new PublicMcpError("…")` for messages the model should read. --- # Context classes > What `this` is inside a tool, resource, prompt, agent, job, channel or skill, and every member each context class has. Source: https://frontmcp.dev/reference/sdk/contexts [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Inside `execute()`, `this` is a context object that FrontMCP creates for the call: a `ToolContext` for a tool, a `ResourceContext` for a resource, and so on. It carries the call's input, the caller, the request, and the helpers you call, like `this.get()`, `this.fetch()` and `this.fail()`. Every context class extends one base class, `ExecutionContextBase`, and so has the same core members. This page lists every member of every context class and describes the ones that don't have a page of their own. ```ts @Tool(options) class MyTool extends ToolContext { async execute(input) { this.… } } @Resource(options) class MyResource extends ResourceContext { async execute(uri, params) { this.… } } @Prompt(options) class MyPrompt extends PromptContext { async execute(args) { this.… } } @Agent(options) class MyAgent extends AgentContext { async execute(input) { this.… } } @Job(options) class MyJob extends JobContext { async execute(input) { this.… } } @Channel(options) class MyChannel extends ChannelContext { async onEvent(payload) { this.… } } ``` --- ## Reference ### The context classes | Class | Extended by | FrontMCP creates one | Base class | | --- | --- | --- | --- | | [`ToolContext`](https://frontmcp.dev/reference/sdk/contexts#toolcontext) | [`@Tool`](https://frontmcp.dev/reference/sdk/tool) classes | For every `tools/call` | `ExecutionContextBase` | | [`ResourceContext`](https://frontmcp.dev/reference/sdk/contexts#resourcecontext) | [`@Resource`](https://frontmcp.dev/reference/sdk/resource) and [`@ResourceTemplate`](https://frontmcp.dev/reference/sdk/resource-template) classes | For every `resources/read` | `ExecutionContextBase` | | [`PromptContext`](https://frontmcp.dev/reference/sdk/contexts#promptcontext) | [`@Prompt`](https://frontmcp.dev/reference/sdk/prompt) classes | For every `prompts/get` | `ExecutionContextBase` (since 1.9.2) | | [`AgentContext`](https://frontmcp.dev/reference/sdk/contexts#agentcontext) | [`@Agent`](https://frontmcp.dev/reference/sdk/agent) classes | For every call to the agent's `invoke_` tool | `ExecutionContextBase` | | [`JobContext`](https://frontmcp.dev/reference/sdk/contexts#jobcontext) | [`@Job`](https://frontmcp.dev/reference/sdk/job) classes | For every run a client starts with `execute_job`, or a workflow step | `ExecutionContextBase` | | [`ChannelContext`](https://frontmcp.dev/reference/sdk/contexts#channelcontext) | [`@Channel`](https://frontmcp.dev/reference/sdk/channel) classes | For every event, or once for a service channel | `ExecutionContextBase` | | [`SkillContext`](https://frontmcp.dev/reference/sdk/contexts#skillcontext) | [`@Skill`](https://frontmcp.dev/reference/sdk/skill) classes, optionally | Once, when the skill is first loaded, for a class that overrides `loadInstructions()` or `build()` | `ExecutionContextBase` | Each one is created for one call and dropped after it (only a service channel and a skill keep one instance), so a value you keep in a field of `this` is gone by the next call: [keep it in a provider](https://frontmcp.dev/reference/sdk/contexts#a-value-kept-on-this-is-gone-on-the-next-call). A workflow runs in a `WorkflowContext` too, but you never extend that one: [`@Workflow`](https://frontmcp.dev/reference/sdk/workflow) has no `execute()` of its own. ### Members every class shares These come from `ExecutionContextBase`. Where a cell says more than "Yes", the member works differently in that class. | Member | Tool | Resource | Prompt | Agent | Job | Channel | | --- | --- | --- | --- | --- | --- | --- | | [`this.get()`, `this.tryGet()`](https://frontmcp.dev/reference/sdk/get) | Yes | Yes | Yes | Yes | Yes | Yes | | [`this.fail()`](https://frontmcp.dev/reference/sdk/fail) | Yes | Yes | Yes | Yes | Yes | Yes | | [`this.auth`](https://frontmcp.dev/reference/sdk/auth) | Yes | Yes | Yes | Yes | The caller who started the run | An empty caller | | [`this.context`, `this.tryGetContext()`](https://frontmcp.dev/reference/sdk/context) | Yes | Yes | Yes | Yes | [The request that started the run](https://frontmcp.dev/reference/sdk/contexts#where-there-is-no-request-context) | [Throws](https://frontmcp.dev/reference/sdk/contexts#where-there-is-no-request-context) | | [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch) | Yes, aborted with `this.signal` | Yes | Yes | Yes | Yes | [Without headers](https://frontmcp.dev/reference/sdk/contexts#where-there-is-no-request-context) | | [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool) | Yes | Yes | Yes | Yes, on the `"agent"` surface | Yes, on the `"job"` surface | As an anonymous caller | | [`this.scope`](https://frontmcp.dev/reference/sdk/contexts#thisscope) | Yes | Yes | Yes | Yes | Yes | Yes | | [`this.logger`](https://frontmcp.dev/reference/sdk/contexts#thislogger-and-thiscontextlogger) | Yes | Yes | Yes | Yes | Yes | Yes | | [`this.contextLogger`](https://frontmcp.dev/reference/sdk/contexts#thislogger-and-thiscontextlogger) | Yes | Yes | Yes | Yes | Yes | Yes | | [`this.metadata`](https://frontmcp.dev/reference/sdk/contexts#thismetadata) | Yes | Yes | Yes | Yes | Yes | Yes | | [`this.config`](https://frontmcp.dev/reference/sdk/contexts#thisconfig) | Yes | Yes | Yes | Yes | Yes | Yes | | [`this.workerEnv`](https://frontmcp.dev/reference/sdk/contexts#thisworkerenv) | Yes | Yes | Yes | Yes | The request's, if it runs in one | The request's, if it runs in one | | [`this.authInfo`, `this.getAuthInfo()`](https://frontmcp.dev/reference/sdk/contexts#thisauthinfo-and-thisgetauthinfo) | Yes | Yes | Yes | Yes | Yes | Yes | | [`this.runId`, `this.activeStage`, `this.mark()`](https://frontmcp.dev/reference/sdk/contexts#thisrunid-thisactivestage-and-thismark) | Yes | Yes | Yes | Yes | Yes | Yes | | [`this.error`](https://frontmcp.dev/reference/sdk/contexts#thiserror) | Yes | Yes | Yes | Yes | Yes | Yes | | [`this.runtimeContext`, `isEnv()` and the other checks](https://frontmcp.dev/reference/sdk/contexts#thisruntimecontext-and-the-isenv-checks) | Yes | Yes | Yes | Yes | Yes | Yes | ### Members of some classes | Member | Tool | Resource | Prompt | Agent | Job | Channel | | --- | --- | --- | --- | --- | --- | --- | | [`this.respond()`](https://frontmcp.dev/reference/sdk/respond) | Yes | Yes (since 1.9.3) | Yes (since 1.9.3) | Yes | Yes: ends the run | No | | [`this.notify()`](https://frontmcp.dev/reference/sdk/notify) | Yes | No | No | Yes | No | No | | [`this.progress()`](https://frontmcp.dev/reference/sdk/progress) | Yes | No | No | Yes | Sends nothing | No | | [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit) | Yes | No | No | Yes | No | No | | [`this.input`](https://frontmcp.dev/reference/sdk/contexts#thisinput-and-thisinputhistory) | Yes | No | No | Yes | Yes | No | | [`this.inputHistory`](https://frontmcp.dev/reference/sdk/contexts#thisinput-and-thisinputhistory) | Yes | No | No | Yes | No | No | | [`this.output`](https://frontmcp.dev/reference/sdk/contexts#thisoutput-and-thisoutputhistory) | Yes | Yes | Yes | Yes | Yes | No | | [`this.outputHistory`](https://frontmcp.dev/reference/sdk/contexts#thisoutput-and-thisoutputhistory) | Yes | Yes | Yes | Yes | No | No | | [`this.signal`](https://frontmcp.dev/reference/sdk/contexts#thissignal) | Yes | No | No | No | [In a workflow step](https://frontmcp.dev/reference/sdk/job#jobcontext) | No | | [`this.clientInfo`, `this.platform`](https://frontmcp.dev/reference/sdk/contexts#thisclientinfo-and-thisplatform) | Yes | No | No | Yes | No | No | | [`this.sample()`, `this.listRoots()`](https://frontmcp.dev/reference/sdk/contexts#thissample-and-thislistroots) | Yes | No | No | No | No | No | | [`this.notifyResourceUpdated()`, `this.notifyResourceListChanged()`](https://frontmcp.dev/reference/sdk/contexts#telling-clients-a-resource-changed) | Yes | No | No | No | No | No | | [`this.uri`, `this.params`](https://frontmcp.dev/reference/sdk/contexts#thisuri-and-thisparams) | No | Yes | No | No | No | No | | [`this.notifyUpdated()`](https://frontmcp.dev/reference/sdk/contexts#telling-clients-a-resource-changed) | No | Yes | No | No | No | No | | [`this.getArgumentCompleter()`](https://frontmcp.dev/reference/sdk/resource-template) | No | Yes | No | No | No | No | | [`this.args`](https://frontmcp.dev/reference/sdk/contexts#thisargs) | No | No | Yes | No | No | No | | [`this.log()`, `this.attempt`, `this.getLogs()`](https://frontmcp.dev/reference/sdk/job#jobcontext) | No | No | No | No | Yes | No | | [The model loop's methods](https://frontmcp.dev/reference/sdk/agent#agentcontext) | No | No | No | Yes | No | No | | [`onEvent()`, `onReply()`, `onConnect()`, `onDisconnect()`, `pushIncoming()`](https://frontmcp.dev/reference/sdk/channel#channelcontext) | No | No | No | No | No | Yes | Each class also has its entry's name and id, as `protected` fields: `toolName` and `toolId`, `resourceName` and `resourceId`, `promptName` and `promptId`, `agentName` and `agentId`, `jobName` and `jobId`, and `channelName`. Plugins can add members of their own to every context class, like `this.remember`. None are there unless a plugin that adds them is installed: see [`@Plugin`](https://frontmcp.dev/reference/sdk/plugin). ### Members without a page of their own #### `this.scope` The scope serving the call: the server, with its registries of tools, resources, prompts, agents, skills and providers. `this.scope.id` is `"root"` for a `@FrontMcp` server. Every context class has it. [Scope and registries](https://frontmcp.dev/reference/sdk/scope) documents what you can do with it. #### `this.logger` and `this.contextLogger` `this.logger` writes to the server's own log, never to the client. It has `verbose()`, `debug()`, `info()`, `warn()`, `error()` and `child()`. Lines go to the server's output; in the Playground, to the **Logs** tab. Levels, transports, prefixes and where the lines end up are in [Logging](https://frontmcp.dev/reference/server/logging). `this.contextLogger` is the same logger, with the first eight characters of the request id and of the trace id at the start of each line, so the lines of one request can be found together. Both are `protected`: call them inside the class. For messages meant for the client, use [`this.notify()`](https://frontmcp.dev/reference/sdk/notify). See [Logging from a call](https://frontmcp.dev/reference/sdk/contexts#logging-from-a-call). #### `this.metadata` The options of the decorator, as FrontMCP parsed them: the `@Tool`'s `name`, `inputSchema` and so on, the `@Resource`'s `uri`, the `@Job`'s `retry`. Read-only. #### `this.input` and `this.inputHistory` `this.input` is the validated input, the same object `execute()` receives, with defaults filled in and unknown fields dropped. A hook can replace it before `execute()` runs, by setting `ctx.state.toolContext.input`. `this.inputHistory` records every value it had, as `{ at, stage, value }`: the first is what the client sent, validated. See [Reading what a hook changed](https://frontmcp.dev/reference/sdk/contexts#reading-what-a-hook-changed). #### `this.output` and `this.outputHistory` `this.output` is `execute()`'s result once it has returned, and `undefined` before. It's what `Did("execute")` hooks read, as `ctx.state.toolContext.output`, and a hook can replace it. `this.outputHistory` records each value, like `inputHistory`. #### `this.signal` An `AbortSignal` that aborts when the tool's [`timeout`](https://frontmcp.dev/reference/sdk/guard) passes, or, for a tool called by [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool#giving-the-called-tool-less-time), when the caller's `opts.signal` aborts. `this.fetch()` in a tool is aborted with it. Pass it to anything else that takes one; work that ignores it keeps running after the call has ended. Only `ToolContext` has it. [Limiting and Timing Out Calls](https://frontmcp.dev/learn/limiting-calls) shows it in use. For an inline tool call under MCP 2026-07-28 over stdio, `notifications/cancelled` with the call's `requestId` aborts this signal while the request is in flight, and closing the connection aborts requests in flight. Pass it to work that should stop when the caller cancels. To stop a background task, send [`tasks/cancel`](https://frontmcp.dev/reference/server/tasks#cancelling-a-task) with its `taskId`. New in 1.10.5; see [Revisions over stdio](https://frontmcp.dev/reference/server/protocol-versions#revisions-over-stdio). #### `this.clientInfo` and `this.platform` `this.clientInfo` is the client's `{ name, version }`, from each MCP 2026-07-28 request's `_meta`, or from `initialize` for session clients and [`connect()`](https://frontmcp.dev/reference/sdk/connect). `this.platform` is the AI platform FrontMCP guesses from the name, like `"claude"` for `claude-ai` or `"cursor"`, `"generic-mcp"` for a name it doesn't know, and `"unknown"` when there's no client info. A client that declares the MCP Apps extension is `"ext-apps"` whatever it's called, unless one of your `platformDetection` mappings matches its name first (since 1.9.1; [how FrontMCP recognizes the client](https://frontmcp.dev/reference/ui/hosts#how-frontmcp-recognizes-the-client)). The client writes both: use them for formatting, never to decide access. [Reading the Request](https://frontmcp.dev/learn/reading-the-request) shows them per entry point. `AgentContext` has them too, and since 1.8.5 they're set in an agent's own `execute()` as in a tool. #### `this.runId`, `this.activeStage` and `this.mark()` `this.runId` is a UUID for this call's context object. It isn't the request id (that's [`this.context.requestId`](https://frontmcp.dev/reference/sdk/context)), and in a job it isn't the run's `runId`. `this.activeStage` names the stage the call is in, `"execute"` inside `execute()`; `this.mark(name)` sets it to `name`, for your own debugging. Both are `protected`. For timings, use [`this.context.mark()`](https://frontmcp.dev/reference/sdk/context#timing-parts-of-a-call), which is a different method. #### `this.error` The error the call failed with through [`this.fail()`](https://frontmcp.dev/reference/sdk/fail), once it has. `undefined` before. You'll only see it in code that runs after `this.fail()`, like a `catch` around it. `protected`, except in `PromptContext`. #### `this.config` Typed access to configuration and environment variables: `get(key, default?)`, `getRequired(key)`, `getNumber()`, `getBoolean()`, `has()`. It needs the built-in `ConfigPlugin` in the server's `plugins`. Without it, reading `this.config` throws [`Provider "ConfigService" is not available`](https://frontmcp.dev/reference/sdk/contexts#provider-configservice-is-not-available). `ConfigPlugin`, which adds `this.config`, and how its values combine with the environment and `.env` files are in [Configuration files](https://frontmcp.dev/reference/server/config-files#configplugin). #### `this.authInfo` and `this.getAuthInfo()` The raw result of authentication, the same data as [`this.context.authInfo`](https://frontmcp.dev/reference/sdk/context#authinfo). `this.authInfo` is deprecated: use [`this.auth`](https://frontmcp.dev/reference/sdk/auth) for the caller, and `this.context.authInfo` for the rest. `this.getAuthInfo()` returns `this.context.authInfo` where there's a request context. A prompt reads its caller from `this.auth` too: see [Reading the caller in a prompt](https://frontmcp.dev/reference/sdk/auth#reading-the-caller-in-a-prompt). (Changed in 1.9.2: before, `PromptContext` had no `this.auth`, and its `this.authInfo` field was the way to the caller.) #### `this.workerEnv` The hosting platform's bindings for the request: on a Cloudflare Worker, the `env` object that holds KV namespaces, D1 databases, R2 buckets, `[vars]` and secrets, as the Worker passed it to [`createFetchHandler()`'s handler](https://frontmcp.dev/reference/sdk/create-fetch-handler#reading-a-workers-bindings). A server you build in your process with [`create()`, `createDirect()`](https://frontmcp.dev/reference/sdk/create#passing-a-workers-bindings) or [`connect()`](https://frontmcp.dev/reference/sdk/connect#parameters) passes the `workerEnv` you give it, or the one a call or a client gives. It's `undefined` when nothing passes one, as with stdio or the Node server. A job, and a channel's `onEvent()`, read the `env` of the request they run in: a job a client started with `execute_job`, or an event a tool emitted. (New in 1.8.7. Changed in 1.9: prompts have it, and jobs and channels read it; before, they got `undefined`. Changed in 1.9.4: before, it was `undefined` in every in-process call.) #### `this.runtimeContext` and the `isEnv()` checks `this.runtimeContext` describes where the server runs: `{ os, platform, runtime, deployment, provider, target, env }`, like `runtime: "node"`, `deployment: "standalone"` and `env: "development"` for a server run with Node. `this.isEnv(env)`, `this.isRuntime(runtime)`, `this.isDeployment(deployment)` and `this.isPlatform(os)` compare one field. In Node, `env` is `NODE_ENV`, or `"development"` when it isn't set, read each time you ask. In FrontMCP's browser build, the other fields are fixed: `runtime`, `platform` and `os` are `"browser"`. Its `env` is the page's `process.env.NODE_ENV` if the page defines one, else the value the bundler wrote in, else `"development"`; the Playground on this site sets `"development"`. (Changed in 1.9.2: before, the browser build's `env` was always `"production"`.) (Changed in 1.8.7: before, Node read `NODE_ENV` once, so a change after the first call went unseen.) See [Checking where the server runs](https://frontmcp.dev/reference/sdk/contexts#checking-where-the-server-runs). How FrontMCP detects each value, and `availableWhen`, which uses the same values, are in [Environment awareness](https://frontmcp.dev/reference/server/environment). #### `this.sample()` and `this.listRoots()` `this.sample(options)` asks the client's model for a completion (`sampling/createMessage`), and `this.listRoots()` asks which filesystem roots the client exposes (`roots/list`). Both are deprecated in MCP 2026-07-28, and both need a client that declares the capability: otherwise the call fails with error `-32021`, [`This request requires the sampling client capability`](https://frontmcp.dev/reference/sdk/contexts#this-request-requires-the-sampling-client-capability). To use a model from your server, call a provider's API from a tool, or write an [agent](https://frontmcp.dev/reference/sdk/agent). `protected`, and only in `ToolContext`. #### Telling clients a resource changed `this.notifyResourceUpdated(uri)` in a tool, and `this.notifyUpdated(uri?)` in a resource, send `notifications/resources/updated` to the sessions that subscribed to `uri` with `resources/subscribe`. `this.notifyResourceListChanged()` sends `notifications/resources/list_changed` to every session. Only clients before MCP 2026-07-28 have sessions to subscribe with; a 2026-07-28 client that listens with `subscriptions/listen` gets a resource's own `this.notifyUpdated()`, and a tool's `this.notifyResourceUpdated()` (fixed in 1.9.5) ([Protocol versions](https://frontmcp.dev/reference/server/protocol-versions)). The methods never throw. #### `this.uri` and `this.params` In a resource, the URI being read and, for a [`@ResourceTemplate`](https://frontmcp.dev/reference/sdk/resource-template), the values of its variables: the same two values `execute(uri, params)` receives. #### `this.args` In a prompt, the arguments the client sent, as strings: the same object `execute(args)` receives. ### The classes #### `ExecutionContextBase` The base class of every context class but `PromptContext`, and the source of the [shared members](https://frontmcp.dev/reference/sdk/contexts#members-every-class-shares). You don't extend it yourself. `import { ExecutionContextBase } from "@frontmcp/sdk"` lets a helper accept any context, but the helper can't call its `protected` members: see [the TypeScript error](https://frontmcp.dev/reference/sdk/contexts#property-fail-is-protected). #### `ToolContext` The fullest context: everything in the tables above except the resource, prompt, job and channel members. `execute(input)` receives the validated input and returns the result. See [`@Tool`](https://frontmcp.dev/reference/sdk/tool). #### `ResourceContext` `execute(uri, params)` returns `{ contents }`. It has the shared members, `this.uri`, `this.params`, `this.output` and `this.notifyUpdated()`, and `getArgumentCompleter()` for template variables. No `this.signal`, `this.clientInfo`, `this.notify()` or `this.progress()`. See [`@Resource`](https://frontmcp.dev/reference/sdk/resource). #### `PromptContext` `execute(args)` returns the prompt's messages. It has every shared member, `this.args` and `this.output`. No `this.input`, `this.signal`, `this.clientInfo`, `this.notify()`, `this.progress()` or `this.elicit()`. [`this.respond(value)`](https://frontmcp.dev/reference/sdk/respond#ending-early-everywhere-else) ends the request as `return value` would (since 1.9.3: before, it failed the request). See [`@Prompt`](https://frontmcp.dev/reference/sdk/prompt). (Changed in 1.9.2: before, `PromptContext` was a class of its own, without `this.auth`, `this.context`, `this.fetch()`, `this.callTool()`, `this.contextLogger` or `this.config`.) #### `AgentContext` Has most of what `ToolContext` has, plus the model loop's methods. In an agent's own `execute()`, `this.context`, `this.clientInfo`, `this.platform` and `this.fetch()`'s tracing headers work as in a tool (since 1.8.5), and so do `this.notify()` and `this.progress()` (since 1.9.2: before, they sent nothing). `this.elicit()` reaches the client (since 1.8.5: see [Asking the user from an agent](https://frontmcp.dev/reference/sdk/agent#asking-the-user-from-an-agent)), and `this.callTool()` calls on the `"agent"` surface. The tools the agent's model calls are ordinary tool calls, with all of it. Since 1.9.4, the model also reads the agent's resources and prompts through built-in tools, and with `execution.enableStreaming` the loop calls `streamCompletion()` and sends the model's text as progress; `streamsReplies()` decides whether a run streams. See [`@Agent`](https://frontmcp.dev/reference/sdk/agent#agentcontext). #### `JobContext` `execute(input)` returns the job's result. It adds `this.log()`, `this.attempt` and `this.getLogs()`, and, when a workflow step runs it, `this.signal`, which aborts when the step's `timeout` passes (new in 1.9.5). It has the shared members. `this.context` is the context of the `execute_job` or `execute_workflow` request that started the run, or a copy of it for a run in the background, so `this.fetch()` carries the request's tracing headers. `this.get()` sees its app's providers, and `this.callTool()` calls on the `"job"` surface. See [`@Job`](https://frontmcp.dev/reference/sdk/job#jobcontext). (Changed in 1.9.2: before, a job had no `this.context`, and `this.get()` saw only the server's providers.) #### `ChannelContext` `onEvent(payload)` turns an event into what the channel sends. It has the shared members, but runs outside any request: no `this.context`, and an empty caller in `this.auth`. `this.get()` sees its app's providers (since 1.9.2; before, only the server's). See [`@Channel`](https://frontmcp.dev/reference/sdk/channel#channelcontext). #### `SkillContext` A `@Skill` class may extend it. When the class overrides `loadInstructions()` or `build()`, FrontMCP creates one instance, the first time the skill is loaded, and the skill's content comes from those methods; their result is kept for later loads. Without an override, the content comes from the decorator's options. See [`@Skill`](https://frontmcp.dev/reference/sdk/skill). (Changed in 1.9.2: before, FrontMCP never created one, and overrides had no effect.) #### Caveats - **A new object for every call.** Fields of `this` start fresh each time. Share state through [providers](https://frontmcp.dev/reference/sdk/provider). - **`protected` members work only inside the class.** That covers `this.fail()`, `this.elicit()`, `this.logger`, `this.contextLogger`, an agent's `notify()` and `progress()`, and more. A helper function outside the class should return values, or throw a `PublicMcpError`, which [`this.fail()` treats the same](https://frontmcp.dev/reference/sdk/fail#failing-vs-throwing). A tool's `notify()`, `progress()`, `notifyResourceUpdated()` and `notifyResourceListChanged()`, and a job's `log()` and `progress()`, are public since 1.9.1, so a `tool()` or `job()` handler can call them on its `ctx`. - Where a member exists but does nothing useful, like `this.progress()` in a job, it's marked in the tables above. The pages it links to say more. --- ## Usage ### Where there is no request context A channel's `onEvent()` runs without a request context. `this.tryGetContext()` is `undefined` there, `this.context` throws, and `this.fetch()` is plain `fetch()`, without the `traceparent` and `x-request-id` headers it adds in a tool. `this.auth` still works. A job runs in the request that started it, so it has all three (since 1.9.2; before, it was like a channel). Each tab calls the same echo service, which answers with the headers it received: ### Jobs and channels ### Example: Job Initial call configuration: ```js { tool: "execute_job", arguments: { name: "sync-tickets", input: {} } } ``` ```ts sync.job.ts active import { Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "sync-tickets", description: "Sync tickets with the CRM", inputSchema: {}, outputSchema: { hasContext: z.boolean(), headers: z.record(z.string(), z.string()), caller: z.string(), contextRunId: z.string() }, }) export class SyncTickets extends JobContext { async execute() { const response = await this.fetch("https://echo.example/headers"); return { hasContext: this.tryGetContext() !== undefined, headers: await response.json(), caller: this.auth.user.sub, contextRunId: this.runId }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { SyncTickets } from "./sync.job"; import { SyncNow } from "./sync-now.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [SyncNow], jobs: [SyncTickets] }) export class HelpDeskApp {} ``` ```ts sync-now.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; // The same request from a tool, for comparison. @Tool({ name: "sync_now", description: "Sync tickets with the CRM now", inputSchema: {} }) export class SyncNow extends ToolContext { async execute() { const response = await this.fetch("https://echo.example/headers"); return { hasContext: this.tryGetContext() !== undefined, headers: await response.json() }; } } ``` ```ts echo-service.ts // Stands in for a service at https://echo.example that answers with the request's // headers. It replaces the global fetch() for that host only. const passThrough = globalThis.fetch; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init); if (!request.url.startsWith("https://echo.example/")) return passThrough(input, init); return Response.json(Object.fromEntries(request.headers)); }; ``` ```ts job.test.ts import { test, expect } from "@frontmcp/testing"; test("a tool's fetch() carries the request's headers", async ({ mcp }) => { const result = (await mcp.tools.call("sync_now", {})).json(); expect(result.hasContext).toBe(true); expect(result.headers).toMatchObject({ traceparent: expect.any(String), "x-request-id": expect.any(String) }); }); test("a job has the request context, and its fetch() carries the same headers", async ({ mcp }) => { const { runId, result } = (await mcp.tools.call("execute_job", { name: "sync-tickets", input: {} })).json(); expect(result).toMatchObject({ hasContext: true, headers: { traceparent: expect.any(String), "x-request-id": expect.any(String) }, caller: expect.stringMatching(/^anon:/), }); expect(result.contextRunId).not.toBe(runId); // this.runId isn't the run's id }); ``` ### Example: Channel Initial call configuration: ```js { tool: "report_outage", arguments: { service: "login" } } ``` ```ts outages.ts active import { Channel, ChannelContext, Tool, ToolContext, z, type ChannelEventBus, type ChannelNotification } from "@frontmcp/sdk"; export const lastEvent: Record = {}; @Channel({ name: "outages", description: "Service outages", source: { type: "app-event", event: "outage" } }) export class OutagesChannel extends ChannelContext { async onEvent(payload: unknown): Promise { const { service } = payload as { service: string }; const response = await this.fetch("https://echo.example/headers"); Object.assign(lastEvent, { hasContext: this.tryGetContext() !== undefined, headers: await response.json(), caller: this.auth.user.sub, anonymous: this.auth.isAnonymous, calledAs: (await this.callTool("whoami", {})).structuredContent, }); return { content: `${service} is down` }; } } @Tool({ name: "whoami", description: "Say who the server thinks is calling", inputSchema: {} }) export class WhoAmI extends ToolContext { async execute() { return { sub: this.auth.user.sub }; } } @Tool({ name: "report_outage", description: "Report that a service is down", inputSchema: { service: z.string() } }) export class ReportOutage extends ToolContext { async execute({ service }: { service: string }) { const { channelEventBus } = this.scope as unknown as { channelEventBus?: ChannelEventBus }; for (const key of Object.keys(lastEvent)) delete lastEvent[key]; channelEventBus?.emit("outage", { service }); for (let i = 0; i < 100 && !("calledAs" in lastEvent); i++) await new Promise((r) => setTimeout(r, 10)); // let onEvent() finish return { reported: service, event: lastEvent }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { OutagesChannel, ReportOutage, WhoAmI } from "./outages"; @App({ id: "status", name: "Status", tools: [ReportOutage, WhoAmI], channels: [OutagesChannel] }) class Status {} @FrontMcp({ info: { name: "status", version: "1.0.0" }, apps: [Status], channels: { enabled: true } }) export default class Server {} ``` ```ts echo-service.ts // Stands in for a service at https://echo.example that answers with the request's headers. const passThrough = globalThis.fetch; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init); if (!request.url.startsWith("https://echo.example/")) return passThrough(input, init); return Response.json(Object.fromEntries(request.headers)); }; ``` ```ts channel.test.ts import { test, expect } from "@frontmcp/testing"; test("onEvent() runs outside any request, as an empty caller", async ({ mcp }) => { const result = await mcp.tools.call("report_outage", { service: "login" }); expect(result.json().event).toEqual({ hasContext: false, headers: {}, caller: "", anonymous: true, calledAs: { sub: expect.stringMatching(/^anon:/) }, }); }); ``` An agent's own `execute()` has the request context, as a tool does (changed in 1.8.5: before, it had none): Initial call configuration: ```js { tool: "invoke_triage", arguments: { query: "Nobody can log in." } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority for a new ticket", inputSchema: { query: z.string() }, llm: { adapter: model } }) export class Triage extends AgentContext { async execute(input: { query: string }) { const answer = (await super.execute(input)) as { response: string }; const response = await this.fetch("https://echo.example/headers"); return { ...answer, hasContext: this.tryGetContext() !== undefined, headers: await response.json(), client: this.clientInfo ?? null, platform: this.platform, caller: this.auth.user.sub, }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) export class HelpDeskApp {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion() { return { content: "High: the customer can't work.", finishReason: "stop" }; }, }; ``` ```ts echo-service.ts // Stands in for a service at https://echo.example that answers with the request's headers. const passThrough = globalThis.fetch; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init); if (!request.url.startsWith("https://echo.example/")) return passThrough(input, init); return Response.json(Object.fromEntries(request.headers)); }; ``` ```ts agent.test.ts import { test, expect } from "@frontmcp/testing"; test("an agent's execute() has the request, as a tool does", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { query: "Nobody can log in." }); expect(result.json()).toEqual({ response: "High: the customer can't work.", hasContext: true, headers: { traceparent: expect.any(String), "x-request-id": expect.any(String) }, client: { name: "frontmcp.dev playground", version: "1.0.0" }, platform: "generic-mcp", caller: expect.stringMatching(/^anon:/), }); }); ``` Pass what a channel needs, like a request id to log, in the event's payload. In an agent, the tools its model calls have the request context too. ### Looking at a tool's context The members a tool reads most, in one call. `this.runId` is new for each call, and isn't the request id; `this.activeStage` follows `this.mark()`; `this.input` is `execute()`'s argument, with the defaults filled in; `this.error` is set once `this.fail()` has run: Initial call configuration: ```js { tool: "inspect_call", arguments: { id: "T-1" } } ``` ```ts inspect.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "inspect_call", description: "Describe this call's context. For debugging.", inputSchema: { id: z.string(), verbose: z.boolean().default(false) }, }) export class InspectCall extends ToolContext { async execute(input: { id: string; verbose: boolean }) { const stageBefore = this.activeStage; this.mark("described"); let failedWith = ""; try { this.fail(new PublicMcpError("just looking")); // caught below, for this example only } catch { failedWith = this.error?.message ?? ""; } return { name: this.metadata.name, input: this.input, inputIsArgument: this.input === input, runIdIsRequestId: this.runId === this.context.requestId, stages: [stageBefore, this.activeStage], output: this.output ?? null, signalAborted: this.signal?.aborted ?? null, client: this.clientInfo ?? null, platform: this.platform, failedWith, }; } } ``` ```ts inspect.test.ts import { test, expect } from "@frontmcp/testing"; test("what a tool's context holds during execute()", async ({ mcp }) => { expect((await mcp.tools.call("inspect_call", { id: "T-1" })).json()).toEqual({ name: "inspect_call", input: { id: "T-1", verbose: false }, inputIsArgument: true, runIdIsRequestId: false, stages: ["execute", "described"], output: null, signalAborted: false, client: { name: "frontmcp.dev playground", version: "1.0.0" }, platform: "generic-mcp", failedWith: "just looking", }); }); ``` Catching `this.fail()` like this only makes sense to show `this.error`: in a real tool, the `catch` would [swallow the failure](https://frontmcp.dev/reference/sdk/fail#a-tool-call-succeeds-after-thisfail). ### Logging from a call `this.logger` writes to the server's log. `this.contextLogger` does too, and starts each line with the request and trace ids, so all the lines of one request can be found together. Open the **Logs** tab after the call. The server sets `LogLevel.Info` because the Playground runs FrontMCP in a browser, where the [level](https://frontmcp.dev/reference/server/logging#options) is `Warn` unless you set it, and `info()` lines would be dropped: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { this.logger.info(`closing ${id}`); // the server's log, untagged this.contextLogger.info(`closing ${id}`); // tagged with this request return { id, status: "closed", requestId: this.context.requestId }; } } ``` ```ts main.ts import { App, FrontMcp, LogLevel } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], logging: { level: LogLevel.Info } }) export default class Server {} ``` ```ts close.test.ts import { test, expect } from "@frontmcp/testing"; test("the call succeeds; the log lines stay on the server", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.json()).toMatchObject({ id: "T-1", status: "closed" }); expect(result.text()).not.toContain("closing"); }); ``` The tagged line starts with two ids in brackets, like `[[1f0c2a9e:5d1b4c7e]]`: the first eight characters of `requestId`, which the result shows in full, and of the trace id. Neither line reaches the client. ### Reading what a hook changed A hook can replace a call's input before `execute()` runs. `this.input` is the value `execute()` gets, and `this.inputHistory` keeps each earlier one. Here a plugin cleans up search queries: Initial call configuration: ```js { tool: "search_tickets", arguments: { query: " Cannot LOG IN " } } ``` ```ts normalize.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; @Plugin({ name: "normalize", description: "Trims and lowercases search queries" }) export class NormalizePlugin extends DynamicPlugin { @ToolHook.Will("execute") async normalize(ctx: FlowCtxOf<"tools:call-tool">) { const call = ctx.state.toolContext; const input = call?.input as { query?: string } | undefined; if (call && typeof input?.query === "string") call.input = { ...input, query: input.query.trim().toLowerCase() }; } } ``` ```ts search.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search tickets by words in their title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, sameAsInput: query === this.input.query, history: this.inputHistory.map((entry) => entry.value) }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { NormalizePlugin } from "./normalize.plugin"; import { SearchTickets } from "./search.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets], plugins: [NormalizePlugin] }) export class HelpDesk {} ``` ```ts normalize.test.ts import { test, expect } from "@frontmcp/testing"; test("execute() gets the cleaned query; the history keeps what was sent", async ({ mcp }) => { const result = await mcp.tools.call("search_tickets", { query: " Cannot LOG IN " }); expect(result.json()).toEqual({ query: "cannot log in", sameAsInput: true, history: [{ query: " Cannot LOG IN " }, { query: "cannot log in" }], }); }); ``` Each history entry also has `at`, a timestamp, and `stage`, the stage the call was in when the value was set. [Hooking into Calls](https://frontmcp.dev/learn/hooking-into-calls#normalizing-input) covers normalizing input. ### Checking where the server runs `this.runtimeContext` and its checks tell a call where the server is running. Here a tool adds debugging details only outside production: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts get-ticket.tool.ts active import { 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 }) { const { runtime, env } = this.runtimeContext; const ticket = { id, title: "Cannot log in", where: { runtime, env } }; if (this.isEnv("production")) return ticket; return { ...ticket, debug: { loadedFrom: "tickets.db" } }; } } ``` ```ts runtime.test.ts import { test, expect } from "@frontmcp/testing"; const inBrowser = typeof WorkerGlobalScope !== "undefined"; test("debugging details outside production", async ({ mcp }) => { const result = (await mcp.tools.call("get_ticket", { id: "T-1" })).json(); expect(result.where).toEqual({ runtime: inBrowser ? "browser" : "node", env: "development" }); // NODE_ENV isn't set in the test run expect(result.debug).toEqual({ loadedFrom: "tickets.db" }); }); test("`env` follows NODE_ENV as it changes", async ({ mcp }) => { const getTicket = async () => (await mcp.tools.call("get_ticket", { id: "T-1" })).json(); const before = process.env.NODE_ENV; try { process.env.NODE_ENV = "production"; const inProduction = await getTicket(); expect(inProduction.where.env).toBe("production"); expect(inProduction.debug).toBeUndefined(); process.env.NODE_ENV = "staging"; expect((await getTicket()).where.env).toBe("staging"); } finally { if (before === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = before; } }); ``` `env` is `NODE_ENV`, or `"development"` when it isn't set: start the server with `NODE_ENV=production` and `debug` goes away. `runtime` is `"node"` in Node and `"browser"` when this example runs on this page, in your browser. (Changed in 1.9: the Playground runs FrontMCP's browser build, which says `"browser"`; before, it said `"node"`.) The browser build reads `NODE_ENV` the same way, from the page's `process.env` or else the bundler, so its `env` and the way it formats errors agree. (Changed in 1.9.2: before, the browser build's `env` was always `"production"`, while its errors were formatted for development.) --- ## Troubleshooting ### `Property 'fail' is protected` TypeScript's full message is `Property 'fail' is protected and only accessible within class 'ExecutionContextBase<…>' and its subclasses`. The code calls `fail()` (or `elicit()`, `logger`) on a context from outside the class, like a helper that receives `this`, or a `tool()` handler's `ctx`: ```ts close.ts async function closeAll(ctx: ToolContext, ids: string[]) { for (const id of ids) { await ctx.notify(`Closing ${id}`); // public since 1.9.1 if (id === "T-0") ctx.fail(new PublicMcpError("There's no ticket T-0.")); // 🚩 TS2445: protected } } ``` To fail from a helper, throw a `PublicMcpError`, or another [public error class](https://frontmcp.dev/reference/sdk/error-classes): it reaches the client the same way. Before 1.9.1, a tool's `notify()` and `progress()` were protected too, so `ctx.notify()` failed with `Property 'notify' is protected`; since 1.9.1 they're public. ### `Provider "ConfigService" is not available` The code read `this.config`, and the server doesn't have the `ConfigPlugin` that provides it: Initial call configuration: ```js { tool: "support_email", arguments: {} } ``` ```ts support-email.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "support_email", description: "The address customers can write to", inputSchema: {} }) export class SupportEmail extends ToolContext { async execute() { return { email: this.config.get("SUPPORT_EMAIL", "help@desk.example") }; // 🚩 no ConfigPlugin } } ``` ```ts config.test.ts import { test, expect } from "@frontmcp/testing"; test("🚩 this.config needs the ConfigPlugin", async ({ mcp }) => { const result = await mcp.tools.call("support_email", {}); expect(result).toBeError("TOOL_EXECUTION_ERROR"); expect(result.text()).toContain('Provider "ConfigService" is not available'); }); ``` Add the built-in `ConfigPlugin` to `@FrontMcp({ plugins })`; by default it also loads `.env` files. Or read `process.env` in a [provider](https://frontmcp.dev/reference/sdk/provider) of your own. ### `This request requires the sampling client capability` The tool called `this.sample()`, or `this.listRoots()` (`roots` then), and the client didn't declare that capability. The request fails with JSON-RPC error `-32021`, and `data.requiredCapabilities` names what's missing: Initial call configuration: ```js { tool: "summarize_ticket", arguments: { id: "T-1" } } ``` ```ts summarize.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "summarize_ticket", description: "Summarize a ticket with the client's model", inputSchema: { id: z.string() } }) export class SummarizeTicket extends ToolContext { async execute({ id }: { id: string }) { const reply = await this.sample({ messages: [{ role: "user", content: { type: "text", text: `Summarize ticket ${id} in one line.` } }], maxTokens: 60, }); return { id, summary: reply }; } } ``` ```ts sample.test.ts import { test, expect } from "@frontmcp/testing"; test("the Playground's client doesn't offer sampling", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "summarize_ticket", arguments: { id: "T-1" } } }); expect(response.error).toMatchObject({ code: -32021, message: "This request requires the `sampling` client capability", data: { requiredCapabilities: { sampling: {} } } }); }); ``` Few clients offer sampling, and MCP 2026-07-28 deprecates it. Call a model provider's API from the tool, or use an [agent](https://frontmcp.dev/reference/sdk/agent). ### A value kept on `this` is gone on the next call FrontMCP creates a new context object for every call, so a field starts over each time: Initial call configuration: ```js { tool: "count_calls", arguments: {} } ``` ```ts count.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; let calls = 0; // module state: shared by every call (and every caller) @Tool({ name: "count_calls", description: "Count how often this tool was called", inputSchema: {} }) export class CountCalls extends ToolContext { private count = 0; // 🚩 a new object each call async execute() { this.count += 1; calls += 1; return { field: this.count, module: calls }; } } ``` ```ts count.test.ts import { test, expect } from "@frontmcp/testing"; test("the field is 1 on every call", async ({ mcp }) => { await mcp.tools.call("count_calls", {}); expect((await mcp.tools.call("count_calls", {})).json()).toEqual({ field: 1, module: 2 }); }); ``` Keep state that outlasts a call in a [provider](https://frontmcp.dev/reference/sdk/provider): a `GLOBAL` one for the server, a `CONTEXT` one for one request. Module variables work too, but they're shared by every caller and every app, and hard to replace in tests. ### `RequestContext not available` `this.context` was read in a channel's `onEvent()`. See [Where there is no request context](https://frontmcp.dev/reference/sdk/contexts#where-there-is-no-request-context), and [`this.context`](https://frontmcp.dev/reference/sdk/context#requestcontext-not-available). --- # this.auth > Who is calling a tool, resource, prompt, agent or job, with their scopes, roles and claims, and what each auth mode and entry point fills in. Source: https://frontmcp.dev/reference/sdk/auth [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `this.auth` describes the caller that your server's [authentication](https://frontmcp.dev/learn/authenticating-clients) established for this request: who they are (`user.sub`), whether they signed in (`isAnonymous`), what they were granted (`scopes`, `roles`, `permissions`), and what their token said (`claims`), with checks like `hasScope()`. The model writes a tool's arguments, but it can't touch any of this, so `this.auth` is what access decisions should be based on. It's there in tools, resources, prompts, agents and jobs. ```ts const { user, isAnonymous, scopes, roles, permissions, claims } = this.auth this.auth.hasScope(scope) ``` --- ## Reference ### `this.auth` Read `this.auth` inside `execute()`. ```ts close-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket. Support agents only.", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { if (!this.auth.hasScope("tickets:write")) { this.fail(new PublicMcpError("Only signed-in support agents can close tickets. Ask the user to sign in.", "FORBIDDEN")); } return { id, status: "closed", closedBy: this.auth.user.sub }; } } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/auth#usage) #### Properties | Property | Type | Description | | --- | --- | --- | | `user` | `{ sub, name?, email?, picture? }` | The caller. `sub` is their id: the token's `sub` claim (or, in a token without one, its `client_id` or `azp`; a token with none of them is refused), or the id FrontMCP gives callers without a token. `name`, `email` and `picture` are the claims of the same name, when there are any. | | `isAnonymous` | `boolean` | `true` when `user.sub` is empty or starts with `anon:`, that is, when the caller didn't sign in. `isAnonymousSubject(sub)`, exported by `@frontmcp/sdk`, applies the same rule to a `sub` you hold. | | `scopes` | `readonly string[]` | The scopes the caller was granted: a token's `scope` and `scp` claims, or the scopes your auth mode gives. | | `roles` | `readonly string[]` | The token's `roles` claim, or the claim [`claimsMapping.roles`](https://frontmcp.dev/reference/sdk/auth#options-that-change-thisauth) points to. `[]` when there's none. | | `permissions` | `readonly string[]` | The token's `permissions` claim, or the claim `claimsMapping.permissions` points to. A string is split on spaces. | | `claims` | `Readonly>` | Every claim of the caller's token, like `tenant` or `realm_access`. Callers without a token get a few made-up claims: see [what each mode fills in](https://frontmcp.dev/reference/sdk/auth#what-each-auth-mode-fills-in). | | `sessionId` | `string` | The same as [`this.context.sessionId`](https://frontmcp.dev/reference/sdk/context): under MCP 2026-07-28, a new `anon:` id on every request, even for a caller with a static key, and `direct:` and a UUID in-process. Don't use it to identify anyone. (Changed in 1.8.5: before, the auth layer could record an id of its own here.) | | `mode` | `string` | Meant to name the auth mode. It's `"public"` for a caller holding an [anonymous token](https://frontmcp.dev/reference/sdk/auth#anonymous-tokens), and `"authenticated"` for every other caller, in every mode, `public` included, so don't rely on it. | #### Methods | Method | Returns `true` when | | --- | --- | | `hasScope(scope)` | `scopes` includes `scope`. | | `hasAllScopes(scopes)`, `hasAnyScope(scopes)` | `scopes` includes every one, or at least one, of them. | | `hasRole(role)` | `roles` includes `role`. | | `hasAllRoles(roles)`, `hasAnyRole(roles)` | `roles` includes every one, or at least one, of them. | | `hasPermission(permission)` | `permissions` includes `permission`. | | `hasAllPermissions(permissions)`, `hasAnyPermission(permissions)` | `permissions` includes every one, or at least one, of them. | Each is an exact, case-sensitive match: `hasScope("tickets")` is `false` for a caller with `tickets:read`, and nothing expands wildcards. #### What each auth mode fills in | | `public` (the default) | `static` | `transparent`, with a token | `transparent` with `allowAnonymous`, no token | | --- | --- | --- | --- | --- | | `user.sub` | `anon:` and a new id per request | `static:` and 12 hex characters | The token's `sub` | `anon:` and a new id per request | | `user.name`, `user.email` | `"Anonymous"`, none | `"Static token"`, none | The token's `name` and `email` | `"Anonymous"`, none | | `isAnonymous` | `true` | `false` | `false` | `true` | | `scopes` | `anonymousScopes`, `["anonymous"]` by default | The mode's `scopes`, `["static"]` by default | The token's `scope` and `scp` | `anonymousScopes` | | `roles`, `permissions` | `[]` | `[]` | From the token's claims | `[]` | | `claims` | `{ iss: "public", sub, name, scope }` | `{ iss: "static", sub, name, scope }` | Every claim in the token | `{ iss: "transparent-anon", sub, name, scope }` | [Seeing what each mode gives](https://frontmcp.dev/reference/sdk/auth#seeing-what-each-mode-gives) shows all four, and an anonymous token. `local` and `remote`, the modes where FrontMCP runs the sign-in itself, need a browser sign-in, so they aren't shown on this page: [Local auth](https://frontmcp.dev/reference/auth/local) and [Remote and proxied auth](https://frontmcp.dev/reference/auth/remote#what-tools-see) show what their tools see. #### Anonymous tokens In `public` mode, and in `local` mode with `allowDefaultPublic`, a client can also ask `/oauth/token` for a token with `grant_type=anonymous` and its `client_id`. Whoever holds that token is still anonymous: `user.sub` is `anon:` and an id that stays the same for as long as the token lasts, `isAnonymous` is `true`, and `mode` is `"public"`. Its `scopes` are the mode's `anonymousScopes`, as are those of a caller without a token. #### What each entry point fills in The table of modes above is for requests over HTTP: the Node server, [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), and the Playground. In-process entry points have no token to check, so the calling code says who the user is: | Entry point | `this.auth` | | --- | --- | | [`create()` or `createDirect()`](https://frontmcp.dev/reference/sdk/create), without `authContext` | `user.sub` is `"direct"`, and `isAnonymous` is `false`: a signed-in user with no scopes and no roles. | | `create()` or `createDirect()`, with `authContext: { user, scopes }` | `user.sub`, `name` and `email`, `roles` and `permissions` come from `user`, and `claims` holds all of it. `scopes` is `authContext.scopes`, or `[]` without it: a `scope` claim in `user` isn't read. | | [`connect()`](https://frontmcp.dev/reference/sdk/connect), without `session.user` | `user.sub` is `""`, and `isAnonymous` is `true`. `authToken` isn't checked or read. | | `connect()`, with `session: { user, scopes }` | As for `authContext.user`. `scopes` is `session.scopes`, or `[]` without it, with or without a `user`. | Nothing checks the user you pass in-process: your code is the authentication, so pass only a user your code has verified. Clients on MCP versions before 2026-07-28, which keep a session over HTTP, get the token's scopes in `this.auth.scopes` like any other caller. `createFetchHandler()` and the Playground keep no sessions, so this page can't show it: it was checked against a server listening over HTTP, with a client that opened a session with `initialize`. Changed in 1.8.7: before, `this.auth.scopes` was empty for those clients, although `this.context.authInfo.scopes` had the scopes, so a tool that checked `hasScope()` refused them. `this.auth` now merges what the request's context knows about the caller, so the two agree. #### Where it's available | In | `this.auth` | | --- | --- | | Tools, resources, agents | The caller of the request. | | Jobs | The caller who started the run. | | Channels | An empty caller: `user.sub` is `""` and `isAnonymous` is `true`. See [Context classes](https://frontmcp.dev/reference/sdk/contexts#channelcontext). | | Prompts | The caller of the request. See [Reading the caller in a prompt](https://frontmcp.dev/reference/sdk/auth#reading-the-caller-in-a-prompt). | #### Options that change `this.auth` `@FrontMcp({ authorities })` holds the server's [authorization rules](https://frontmcp.dev/learn/authorizing-calls#declaring-who-may-call-a-tool). Its `claimsMapping` also tells `this.auth` where the claims are: | Option | What it changes | | --- | --- | | `claimsMapping.roles` | A dot path to the roles claim, like `"realm_access.roles"`. `this.auth.roles` reads it instead of `roles`. | | `claimsMapping.permissions` | A dot path to the permissions claim. `"scope"` turns the token's scopes into permissions. | | `claimsMapping.userId` | A dot path to the claim to use as `user.sub`, instead of `sub`. The token must still have a `sub`, `client_id` or `azp`: one with none of them is refused before the mapping applies. | | `pipes` | Functions that add fields of your own to `this.auth`, from the caller's claims. See [Adding fields with pipes](https://frontmcp.dev/reference/sdk/auth#adding-fields-with-pipes). (Changed in 1.9: before, FrontMCP never ran them.) | To work roles out in code instead, see [`claimsResolver`](https://frontmcp.dev/reference/auth/authorities#working-out-roles-in-code). With one, `claimsMapping` isn't read: `this.auth.roles`, `permissions` and `claims` are what the resolver returns, the same as rules see, and `user.sub` is the token's `sub`. (Changed in 1.9.2: before, the resolver changed only what rules saw.) #### Caveats - `this.auth` is built from the raw result of authentication, which is also [`this.context.authInfo`](https://frontmcp.dev/reference/sdk/context#authinfo). Use `authInfo` for what `this.auth` leaves out, like `token`, the caller's token itself. - FrontMCP checked the token's signature, issuer, expiry and audience. What a claim means is up to your identity provider: a `roles` claim holds whatever it put there. - Everything else about a request, like its arguments, `clientInfo` and headers, is written by the client. [Reading the Request](https://frontmcp.dev/learn/reading-the-request#arguments-client-info-and-headers-are-claims) explains why only `this.auth` should decide access. - How the caller is established depends on the server's mode, in [Auth modes](https://frontmcp.dev/reference/auth/modes), and on how its token is checked, in [Tokens and sessions](https://frontmcp.dev/reference/auth/tokens). --- ## Usage ### Checking a scope before acting This server accepts tokens from an identity provider in `transparent` mode, and lets callers without a token in with the scope `tickets:read`. `close_ticket` needs `tickets:write`. The Playground has no token, so its call is refused; the tests call as two signed-in users, with tokens a test key signed ahead of time. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket. Support agents only.", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { if (!this.auth.hasScope("tickets:write")) { this.fail(new PublicMcpError("Only signed-in support agents can close tickets. Ask the user to sign in.", "FORBIDDEN")); } return { id, status: "closed", closedBy: this.auth.user.sub }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] }) class HelpDesk {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], auth: { mode: "transparent" as const, provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", allowAnonymous: true, anonymousScopes: ["tickets:read"], // The provider's public key, so the example runs offline. Usually FrontMCP fetches it. providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } }, }, }; @FrontMcp(config) export default class Server {} ``` ```ts call-as.ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; /** Sends one tools/call over HTTP, as a client with this token would. */ export async function callAs(token: string | undefined, name: string, args: Record = {}, server: Parameters[0] = config) { const handler = await FrontMcpInstance.createFetchHandler(server); const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name, ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return (await response.json()).result; } ``` ```ts tokens.ts // Access tokens for two users, signed ahead of time with the key in main.ts. // nour: scope "tickets:read tickets:write", roles ["agent"], realm_access.roles ["lead"], tenant "acme" export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw"; // sam: scope "tickets:read" export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw"; ``` ```ts close-ticket.test.ts import { test, expect } from "@frontmcp/testing"; import { callAs } from "./call-as"; import { NOUR, SAM } from "./tokens"; test("a caller without a token is refused", async ({ mcp }) => { expect(await mcp.tools.call("close_ticket", { id: "T-1" })).toBeError("FORBIDDEN"); }); test("a user with tickets:write can close", async () => { const result = await callAs(NOUR, "close_ticket", { id: "T-1" }); expect(result.structuredContent).toEqual({ id: "T-1", status: "closed", closedBy: "nour" }); }); test("a user with only tickets:read can't", async () => { const result = await callAs(SAM, "close_ticket", { id: "T-1" }); expect(result).toMatchObject({ isError: true, _meta: { code: "FORBIDDEN" } }); }); ``` Check before you change anything, so a refused call has no effect, and say in the tool's description who may use it. To refuse the call before `execute()` runs, and hide the tool from callers who can't use it, declare [`authorities`](https://frontmcp.dev/learn/authorizing-calls#declaring-who-may-call-a-tool) instead. ### Using who the caller is `user` and `claims` carry what the token says about the caller. Here a note records its author, and is filed under the caller's tenant, a claim this provider adds. The tests use the same `callAs()` and tokens as above. Initial call configuration: ```js { tool: "add_note", arguments: { ticket: "T-1", text: "Called the customer back." } } ``` ```ts add-note.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "add_note", description: "Add a note to a support ticket", inputSchema: { ticket: z.string(), text: z.string() } }) export class AddNote extends ToolContext { async execute({ ticket, text }: { ticket: string; text: string }) { const { user, isAnonymous, claims } = this.auth; return { ticket, text, author: isAnonymous ? "a guest" : `${user.name} <${user.email}>`, tenant: typeof claims.tenant === "string" ? claims.tenant : null, }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { AddNote } from "./add-note.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [AddNote] }) class HelpDesk {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], auth: { mode: "transparent" as const, provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", allowAnonymous: true, providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } }, }, }; @FrontMcp(config) export default class Server {} ``` **Hidden fixture/test file (runs with the example):** ```ts call-as.ts hidden import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; /** Sends one tools/call over HTTP, as a client with this token would. */ export async function callAs(token: string | undefined, name: string, args: Record = {}, server: Parameters[0] = config) { const handler = await FrontMcpInstance.createFetchHandler(server); const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name, ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return (await response.json()).result; } ``` **Hidden fixture/test file (runs with the example):** ```ts tokens.ts hidden // Access tokens for two users, signed ahead of time with the key in main.ts. // nour: scope "tickets:read tickets:write", roles ["agent"], realm_access.roles ["lead"], tenant "acme" export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw"; // sam: scope "tickets:read" export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw"; ``` ```ts add-note.test.ts import { test, expect } from "@frontmcp/testing"; import { callAs } from "./call-as"; import { NOUR } from "./tokens"; test("the author and tenant come from the token", async () => { const result = await callAs(NOUR, "add_note", { ticket: "T-1", text: "Called back." }); expect(result.structuredContent).toEqual({ ticket: "T-1", text: "Called back.", author: "Nour Haddad ", tenant: "acme" }); }); test("a guest has neither", async ({ mcp }) => { const result = await mcp.tools.call("add_note", { ticket: "T-1", text: "Called back." }); expect(result.json()).toMatchObject({ author: "a guest", tenant: null }); }); ``` `claims` is typed `unknown` per claim, because a token can hold anything. Check a claim's type before you use it. ### Reading roles from another claim Identity providers put roles in different places. Keycloak, for one, puts them in `realm_access.roles`. Point `claimsMapping.roles` at them, and `this.auth.roles` and `hasRole()` read them from there. `permissions: "scope"` turns the token's scopes into permissions too, and `userId` picks the claim that becomes `user.sub`: Initial call configuration: ```js { tool: "whoami", arguments: {} } ``` ```ts whoami.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "whoami", description: "Say who the server thinks is calling", inputSchema: {} }) export class WhoAmI extends ToolContext { async execute() { const { user, roles, permissions } = this.auth; return { sub: user.sub, roles, permissions, isLead: this.auth.hasRole("lead") }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { WhoAmI } from "./whoami.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [WhoAmI] }) class HelpDesk {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], auth: { mode: "transparent" as const, provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", allowAnonymous: true, providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } }, }, authorities: { claimsMapping: { roles: "realm_access.roles", permissions: "scope", userId: "email" }, profiles: {}, }, }; @FrontMcp(config) export default class Server {} ``` **Hidden fixture/test file (runs with the example):** ```ts call-as.ts hidden import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; /** Sends one tools/call over HTTP, as a client with this token would. */ export async function callAs(token: string | undefined, name: string, args: Record = {}, server: Parameters[0] = config) { const handler = await FrontMcpInstance.createFetchHandler(server); const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name, ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return (await response.json()).result; } ``` **Hidden fixture/test file (runs with the example):** ```ts tokens.ts hidden // Access tokens for two users, signed ahead of time with the key in main.ts. // nour: scope "tickets:read tickets:write", roles ["agent"], realm_access.roles ["lead"], tenant "acme" export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw"; // sam: scope "tickets:read" export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw"; ``` ```ts roles.test.ts import { test, expect } from "@frontmcp/testing"; import { callAs } from "./call-as"; import { config } from "./main"; import { NOUR } from "./tokens"; test("roles, permissions and the id come from the mapped claims", async () => { // nour's token has roles: ["agent"] and realm_access: { roles: ["lead"] } const result = await callAs(NOUR, "whoami"); expect(result.structuredContent).toEqual({ sub: "nour@example.com", roles: ["lead"], permissions: ["tickets:read", "tickets:write"], isLead: true, }); }); test("without claimsMapping, the top-level roles claim is used", async () => { const { authorities, ...unmapped } = config; const result = await callAs(NOUR, "whoami", {}, unmapped); expect(result.structuredContent).toEqual({ sub: "nour", roles: ["agent"], permissions: [], isLead: false }); }); ``` Without `claimsMapping`, the same token gives `roles: ["agent"]`, from its `roles` claim, and `permissions: []`, as the second test shows. The same mapping is used by `authorities` rules, so a rule and a check in `execute()` agree on what a role is. ### Seeing what each mode gives `whoami` returns the whole of `this.auth` except the session id. The Playground's server is in `public` mode; the tests start one server in each of the other modes and call it over HTTP. Initial call configuration: ```js { tool: "whoami", arguments: {} } ``` ```ts whoami.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "whoami", description: "Say who the server thinks is calling", inputSchema: {} }) export class WhoAmI extends ToolContext { async execute() { const { user, isAnonymous, scopes, roles, permissions, claims, mode } = this.auth; const matches = { exact: this.auth.hasScope("tickets:read"), prefix: this.auth.hasScope("tickets"), upperCase: this.auth.hasScope("TICKETS:READ") }; return { user, isAnonymous, scopes, roles, permissions, claims, mode, matches }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { WhoAmI } from "./whoami.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [WhoAmI] }) export class HelpDesk {} ``` ```ts modes.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDesk } from "./help-desk.app"; import { SAM } from "./tokens"; const jwks = { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] }; async function whoami(auth: object, token?: string, server?: (request: Request) => Promise) { const handler = server ?? (await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], auth })); const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "whoami", ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "whoami", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return (await response.json()).result.structuredContent; } test("public", async ({ mcp }) => { const me = (await mcp.tools.call("whoami", {})).json(); expect(me).toEqual({ user: { sub: expect.stringMatching(/^anon:/), name: "Anonymous" }, isAnonymous: true, scopes: ["anonymous"], roles: [], permissions: [], claims: { iss: "public", sub: me.user.sub, name: "Anonymous", scope: "anonymous" }, mode: "authenticated", matches: { exact: false, prefix: false, upperCase: false }, }); // a new anonymous id on every request expect((await mcp.tools.call("whoami", {})).json().user.sub).not.toBe(me.user.sub); }); test("static", async () => { const me = await whoami({ mode: "static", tokens: ["desk-key-1"], scopes: ["tickets:read"] }, "desk-key-1"); expect(me).toEqual({ user: { sub: expect.stringMatching(/^static:[0-9a-f]{12}$/), name: "Static token" }, isAnonymous: false, scopes: ["tickets:read"], roles: [], permissions: [], claims: { iss: "static", sub: me.user.sub, name: "Static token", scope: "tickets:read" }, mode: "authenticated", matches: { exact: true, prefix: false, upperCase: false }, }); }); test("transparent, with a token", async () => { const me = await whoami({ mode: "transparent", provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", providerConfig: { jwks } }, SAM); expect(me).toMatchObject({ user: { sub: "sam", name: "Sam Ortiz" }, isAnonymous: false, scopes: ["tickets:read"], roles: [], mode: "authenticated" }); expect(me.claims).toMatchObject({ iss: "https://auth.example.com", sub: "sam", aud: "https://desk.example.com", scope: "tickets:read" }); }); test("transparent, anonymous", async () => { const me = await whoami({ mode: "transparent", provider: "https://auth.example.com", allowAnonymous: true, anonymousScopes: ["tickets:read"], providerConfig: { jwks } }); expect(me).toMatchObject({ user: { sub: expect.stringMatching(/^anon:/), name: "Anonymous" }, isAnonymous: true, scopes: ["tickets:read"], mode: "authenticated" }); expect(me.claims).toMatchObject({ iss: "transparent-anon", scope: "tickets:read" }); }); test("public, with an anonymous token", async () => { const auth = { mode: "public", anonymousScopes: ["tickets:read"] }; const server = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], auth }); const grant = await server( new Request("https://desk.example.com/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "grant_type=anonymous&client_id=desk-console", }), ); const { access_token } = await grant.json(); const me = await whoami(auth, access_token, server); expect(me).toMatchObject({ user: { sub: expect.stringMatching(/^anon:/) }, isAnonymous: true, scopes: ["tickets:read"], roles: [], mode: "public" }); // the same anonymous id for as long as the token lasts expect((await whoami(auth, access_token, server)).user.sub).toBe(me.user.sub); // without the token, a new anonymous caller each time, with the same scopes expect(await whoami(auth, undefined, server)).toMatchObject({ isAnonymous: true, scopes: ["tickets:read"], mode: "authenticated" }); }); test("local, with allowDefaultPublic and an anonymous token", async () => { const auth = { mode: "local", allowDefaultPublic: true, anonymousScopes: ["tickets:read"] }; const server = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], auth }); const grant = await server( new Request("https://desk.example.com/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "grant_type=anonymous&client_id=desk-console", }), ); const { access_token } = await grant.json(); expect(await whoami(auth, access_token, server)).toMatchObject({ user: { sub: expect.stringMatching(/^anon:/) }, isAnonymous: true, scopes: ["tickets:read"], mode: "public" }); }); ``` **Hidden fixture/test file (runs with the example):** ```ts tokens.ts hidden export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw"; ``` ### Calling as a user from tests and scripts In-process, the code that calls the server says who the user is. `create()` and `createDirect()` take an `authContext` per call, and `connect()` a `session` per client: ```ts whoami.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "whoami", description: "Say who the server thinks is calling", inputSchema: {} }) export class WhoAmI extends ToolContext { async execute() { const { user, isAnonymous, scopes, roles, permissions } = this.auth; return { sub: user.sub, isAnonymous, scopes, roles, permissions }; } } ``` ```ts in-process.test.ts import { test, expect } from "@frontmcp/testing"; import { App, connect, create } from "@frontmcp/sdk"; import { WhoAmI } from "./whoami.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [WhoAmI] }) class HelpDesk {} test("create(): authContext.user is the caller, with authContext.scopes", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [WhoAmI] }); const nour = { user: { sub: "nour", roles: ["agent"], permissions: ["tickets:export"] }, scopes: ["tickets:write"] }; const asNour = await server.callTool("whoami", {}, { authContext: nour }); // a `scope` claim in user isn't read const fromClaim = await server.callTool("whoami", {}, { authContext: { user: { sub: "sam", scope: "tickets:write" } } }); const nobody = await server.callTool("whoami", {}); await server.dispose(); expect(asNour.structuredContent).toEqual({ sub: "nour", isAnonymous: false, scopes: ["tickets:write"], roles: ["agent"], permissions: ["tickets:export"] }); expect(fromClaim.structuredContent).toMatchObject({ sub: "sam", scopes: [] }); expect(nobody.structuredContent).toEqual({ sub: "direct", isAnonymous: false, scopes: [], roles: [], permissions: [] }); }); test("connect(): session.user is the caller, with session.scopes", async () => { const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] }; const asSam = await connect(config, { session: { user: { sub: "sam", roles: ["agent"] }, scopes: ["tickets:read"] } }); const anonymous = await connect(config, { authToken: "anything" }); // not checked, not read const sam = await asSam.callTool("whoami", {}); const nobody = await anonymous.callTool("whoami", {}); await asSam.close(); await anonymous.close(); expect(sam).toMatchObject({ structuredContent: { sub: "sam", isAnonymous: false, scopes: ["tickets:read"], roles: ["agent"] } }); expect(nobody).toMatchObject({ structuredContent: { sub: "", isAnonymous: true, scopes: [] } }); }); ``` `create()` and `createDirect()` give a caller the scopes in `authContext.scopes`, and `connect()` the scopes in `session.scopes`, so a test can check a tool's `hasScope()` in-process. A `scope` claim in `user` isn't read by either. (Changed in 1.9.2: before, in-process callers never had scopes. Changed in 1.9.3: `connect()` takes `session.scopes`; before, its callers always had `[]`.) [Running FrontMCP Anywhere](https://frontmcp.dev/learn/running-frontmcp-anywhere) covers `create()`, `createDirect()` and `connect()`. ### The caller in resources, agents and jobs `ResourceContext`, `AgentContext` and `JobContext` have the same `this.auth`. In a job, it's the caller who started the run. ### Other entries ### Example: Resource Initial call configuration: ```js { resource: "me://tickets" } ``` ```ts my-tickets.resource.ts active import { Resource, ResourceContext } from "@frontmcp/sdk"; const assigned: Record = { nour: ["T-1", "T-3"] }; @Resource({ name: "my_tickets", uri: "me://tickets", mimeType: "application/json", description: "Tickets assigned to the caller" }) export class MyTickets extends ResourceContext { async execute(uri: string) { const { user, isAnonymous } = this.auth; const tickets = isAnonymous ? [] : (assigned[user.sub] ?? []); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ caller: user.sub, tickets }) }] }; } } ``` ```ts my-tickets.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { MyTickets } from "./my-tickets.resource"; test("an anonymous caller has no tickets", async ({ mcp }) => { const body = JSON.parse((await mcp.resources.read("me://tickets")).text()!); expect(body).toEqual({ caller: expect.stringMatching(/^anon:/), tickets: [] }); }); test("nour gets her own", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, resources: [MyTickets] }); const result = await server.readResource("me://tickets", { authContext: { user: { sub: "nour" } } }); await server.dispose(); expect(JSON.parse((result.contents[0] as { text: string }).text)).toEqual({ caller: "nour", tickets: ["T-1", "T-3"] }); }); ``` ### Example: Job Initial call configuration: ```js { tool: "execute_job", arguments: { name: "export-my-tickets", input: {} } } ``` ```ts export.job.ts active import { Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "export-my-tickets", description: "Export the caller's tickets", inputSchema: {}, outputSchema: { owner: z.string(), anonymous: z.boolean() } }) export class ExportMyTickets extends JobContext { async execute() { this.log(`Exporting for ${this.auth.user.sub}`); return { owner: this.auth.user.sub, anonymous: this.auth.isAnonymous }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { ExportMyTickets } from "./export.job"; @App({ id: "help-desk", name: "Help Desk", jobs: [ExportMyTickets] }) export class HelpDeskApp {} ``` ```ts export.test.ts import { test, expect } from "@frontmcp/testing"; test("the job runs as the caller who started it", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "export-my-tickets", input: {} }); expect(result.json().result).toEqual({ owner: expect.stringMatching(/^anon:/), anonymous: true }); }); ``` ### Example: Agent Initial call configuration: ```js { tool: "invoke_triage", arguments: { query: "Nobody can log in." } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, PublicMcpError, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority for a new support ticket. Signed-in agents only.", systemInstructions: "You triage tickets for a help desk. Answer high, normal or low, and say why in one sentence.", inputSchema: { query: z.string().describe("What the customer wrote") }, llm: { adapter: model }, }) export class Triage extends AgentContext { async execute(input: { query: string }) { if (this.auth.isAnonymous) { // checked before the loop, so a refused call costs no model call this.fail(new PublicMcpError("Triage is for signed-in agents. Ask the user to sign in.", "SIGN_IN_REQUIRED")); } return super.execute(input); } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) export class HelpDeskApp {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion() { return { content: "High: the customer can't work.", finishReason: "stop" }; }, }; ``` ```ts triage.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; test("an anonymous caller is refused before the model runs", async ({ mcp }) => { expect(await mcp.tools.call("invoke_triage", { query: "Nobody can log in." })).toBeError("SIGN_IN_REQUIRED"); }); test("a signed-in agent gets an answer", async () => { const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); const result = await server.callTool("invoke_triage", { query: "Nobody can log in." }, { authContext: { user: { sub: "nour" } } }); await server.dispose(); expect(result.structuredContent).toEqual({ response: "High: the customer can't work." }); }); ``` ### Adding fields with pipes `authorities.pipes` turns claims into fields of your own on `this.auth`. Each pipe gets the caller's claims and returns an object, at once or as a promise, and FrontMCP adds its fields before any hook or `execute()` reads `this.auth`, in tools, resources, prompts, agents and jobs. Declare the fields on `ExtendFrontMcpAuthContext` to give them types: Initial call configuration: ```js { tool: "whoami", arguments: {} } ``` ```ts main.ts active import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk"; import { planOf } from "./plans"; declare global { interface ExtendFrontMcpAuthContext { tenantId: string; plan: "enterprise" | "free"; } } @Tool({ name: "whoami", description: "Say which tenant the caller belongs to, and its plan", inputSchema: {} }) export class WhoAmI extends ToolContext { async execute() { return { sub: this.auth.user.sub, tenantId: this.auth.tenantId, plan: this.auth.plan }; } } @App({ id: "help-desk", name: "Help Desk", tools: [WhoAmI] }) class HelpDesk {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], authorities: { profiles: {}, pipes: [ (claims: Readonly>) => ({ tenantId: String(claims.tenant ?? "none") }), async (claims: Readonly>) => ({ plan: await planOf(String(claims.tenant ?? "")) }), ], }, }; @FrontMcp(config) export default class Server {} ``` ```ts plans.ts // Stands in for a lookup in the billing database. export async function planOf(tenant: string): Promise<"enterprise" | "free"> { return tenant === "acme" ? "enterprise" : "free"; } ``` ```ts pipes.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; test("a caller without the claim gets the defaults", async ({ mcp }) => { expect((await mcp.tools.call("whoami", {})).json()).toMatchObject({ tenantId: "none", plan: "free" }); }); test("a signed-in caller's claims fill the fields in", async () => { const server = await FrontMcpInstance.createDirect(config); try { const result = await server.callTool("whoami", {}, { authContext: { user: { sub: "nour", tenant: "acme" } } }); expect(result.structuredContent).toEqual({ sub: "nour", tenantId: "acme", plan: "enterprise" }); } finally { await server.dispose(); } }); ``` The Playground's caller has no token, so its claims have no `tenant`, and the pipes fall back. In-process, the claims are `authContext.user`, as the second test shows. A pipe that does a lookup runs on every request, so keep it fast, or cache what it finds. Changed in 1.9: before, FrontMCP accepted `pipes` and never ran them, so the fields were always `undefined`. ### Reading the caller in a prompt A prompt has the same `this.auth` as a tool, so it can shape its messages for the caller: Initial call configuration: ```js { prompt: "handover_note", arguments: { id: "T-1" } } ``` ```ts handover.prompt.ts active import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "handover_note", description: "Write a handover note for a ticket", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class HandoverNote extends PromptContext { async execute({ id }: Record): Promise { const { user, isAnonymous, scopes } = this.auth; const from = isAnonymous ? "a colleague" : (user.name ?? user.sub); const text = `Write a handover note for ticket ${id}, from ${from}. Scopes: ${scopes.join(", ")}.`; return { messages: [{ role: "user", content: { type: "text", text } }] }; } } ``` ```ts handover.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { HandoverNote } from "./handover.prompt"; test("an anonymous caller is a colleague", async ({ mcp }) => { const result = await mcp.prompts.get("handover_note", { id: "T-1" }); expect(result.messages[0].content.text).toBe("Write a handover note for ticket T-1, from a colleague. Scopes: anonymous."); }); test("a signed-in caller is named", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, prompts: [HandoverNote] }); const result = await server.getPrompt("handover_note", { id: "T-1" }, { authContext: { user: { sub: "nour", name: "Nour Haddad" }, scopes: ["tickets:write"] } }); await server.dispose(); expect(result.messages[0].content).toMatchObject({ text: "Write a handover note for ticket T-1, from Nour Haddad. Scopes: tickets:write." }); }); ``` `PromptContext` also still has `this.authInfo`, the raw result of authentication, which is deprecated: its shape [differs between entry points](https://frontmcp.dev/reference/sdk/context#authinfo), and `claimsMapping` doesn't apply to it. To apply the rule of `isAnonymous` to a `sub` you hold, use `isAnonymousSubject(sub)`, exported by `@frontmcp/sdk`: `true` for a missing or empty `sub`, and for one that starts with `anon:`. Changed in 1.9.2: before, prompts had no `this.auth`, and `this.authInfo` was the way to read the caller. --- ## Troubleshooting ### `hasScope()` is `false` though the caller has the scope Check, in order: 1. **Is the call in-process?** `create()` and `createDirect()` give a caller only the scopes in `authContext.scopes`, and `connect()` none. See [Calling as a user](https://frontmcp.dev/reference/sdk/auth#calling-as-a-user-from-tests-and-scripts). 2. **Is it the exact string?** `hasScope()` doesn't match prefixes or wildcards: `tickets` isn't `tickets:read`. ### `this.auth.roles` is empty, though the token has roles Without a mapping, `this.auth.roles` reads only a top-level `roles` claim. Point `authorities.claimsMapping.roles` at the claim your provider uses, as in [Reading roles from another claim](https://frontmcp.dev/reference/sdk/auth#reading-roles-from-another-claim). In-process, put `roles` in `authContext.user`. ### `this.auth.user.sub` is `"direct"` The tool was called with `create()` or `createDirect()` without an `authContext`. FrontMCP treats that as a signed-in user called `direct`, with `isAnonymous: false`. Pass `authContext: { user: { sub } }` on each call, or check the scopes or roles you need rather than `isAnonymous`. ### `this.auth.user.sub` is `""` The tool was called through `connect()` without a `session.user`, or the code runs in a channel's `onEvent()`, which has no caller. Both count as anonymous. ### `this.auth.user.sub` is different on every call The caller is anonymous. Under MCP 2026-07-28 each request from a caller without a token gets a new `anon:` id, so it can't be used to recognize a returning caller, or to keep anything per user. Require a sign-in for tools that need to. ### `this.auth.mode` is `"authenticated"` in public mode FrontMCP sets `mode` to `"authenticated"` for callers without a token, in every auth mode, and for every signed-in caller. Only a caller holding an [anonymous token](https://frontmcp.dev/reference/sdk/auth#anonymous-tokens) gets `"public"`. Use `isAnonymous` to tell signed-in callers from others. ### A field from `authorities.pipes` is `undefined` Check, in order: 1. **Did the pipe throw?** FrontMCP logs `[FrontMcpAuth] pipe failed: ` and the error's message, leaves that pipe's fields out, and goes on with the request. The other pipes' fields are there. 2. **Is the claim there?** A pipe gets the caller's claims: a token's, `authContext.user` in-process, and for callers without a token only `iss`, `sub`, `name` and `scope`. With a `claimsResolver`, it gets the `claims` the resolver returns instead. Give a field a default for a claim that's missing, as `tenantId` does in [Adding fields with pipes](https://frontmcp.dev/reference/sdk/auth#adding-fields-with-pipes). --- # this.context > The request a call belongs to, with its id, trace, auth info and HTTP metadata, what each entry point fills in, and a store that lasts for the request. Source: https://frontmcp.dev/reference/sdk/context [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `this.context` is the `FrontMcpContext` of the request a call belongs to: the id FrontMCP gave it, its W3C trace, the raw result of authentication, parts of the HTTP request and the client's description of itself, plus a small store that lives as long as the request. Use it to tag logs and errors with the request, to join a distributed trace, and to hand values from a hook to a tool. Tools, resources, prompts, agents and jobs have it. A channel's `onEvent()` runs without it. ```ts const { requestId, traceContext, authInfo, metadata, sessionId } = this.context ``` --- ## Reference ### `this.context` Read `this.context` inside `execute()`. ```ts reopen-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "reopen_ticket", description: "Reopen a closed support ticket", inputSchema: { id: z.string() } }) export class ReopenTicket extends ToolContext { async execute({ id }: { id: string }) { try { return await reopen(id); } catch (err) { this.contextLogger.error(`reopen ${id} failed: ${String(err)}`); this.fail(new PublicMcpError(`Couldn't reopen ${id}. Give support this reference: ${this.context.requestId}`)); } } } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/context#usage) #### Properties | Property | Type | Description | | --- | --- | --- | | `requestId` | `string` | A UUID that FrontMCP gives each request. It isn't the JSON-RPC `id`, and the client never sees it unless you put it in a result. Tools called with [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool) share it. | | `traceContext` | `{ traceId, parentId, traceFlags, raw }` | The request's [W3C trace context](https://www.w3.org/TR/trace-context/). Read from the request's `traceparent` header when there is one, otherwise new. `raw` is the `traceparent` value to send on, which [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch) does for you. | | `authInfo` | `Partial` | The raw result of authentication: `token` (the caller's token), `clientId`, `scopes`, `expiresAt`, `user` (the token's claims) and `extra`. Its shape [differs per entry point](https://frontmcp.dev/reference/sdk/context#authinfo). [`this.auth`](https://frontmcp.dev/reference/sdk/auth) is the typed view of it. | | `metadata` | `{ userAgent?, contentType?, accept?, clientIp?, customHeaders }` | Parts of the HTTP request. `customHeaders` holds every `x-frontmcp-*` header. In-process, what `createDirect()`'s [`metadata` call option](https://frontmcp.dev/reference/sdk/create#call-options) passes, or only `customHeaders: {}`. | | `clientInfo` | `{ name, version } \| undefined` | The client's description of itself from this request's `_meta` (MCP 2026-07-28). Session clients send it once, at `initialize`, and it isn't here then: read [`this.clientInfo`](https://frontmcp.dev/reference/sdk/contexts#thisclientinfo-and-thisplatform), which covers both. | | `platformType` | `string \| undefined` | The AI platform FrontMCP guesses from `clientInfo.name`, like `"claude"` or `"cursor"`, or `"generic-mcp"`. | | `sessionId` | `string` | For session clients and in-process calls, the session's id (`direct:` and a UUID in-process). MCP 2026-07-28 has no sessions: each request gets `anon:` and a new UUID, whoever the caller is, or the value of an `mcp-session-id` header the client sent, unchecked. It's never the caller's id; use `this.auth.user.sub`. | | `verifiedSessionId` | `string \| undefined` | `sessionId` when the server verified it, as for in-process clients. A request is in a session only if it presented one, in its `mcp-session-id` header (or a legacy SSE client's `sessionId`), and the server accepted it: `undefined` for every 2026-07-28 request, and for a request that presented no session. `sessionIdPresentedBy(request)`, exported by `@frontmcp/sdk`, reads the id a request presents. | | `scopeId` | `string` | The id of the scope serving the request: `"root"` for a `@FrontMcp` server. | | `timestamp` | `number` | When the request started, in milliseconds since 1970. | | `config` | `{ forwardCallerTokenTo, forwardCustomHeadersTo, autoInjectTracingHeaders, requestTimeout }` | The server's [`fetch` options](https://frontmcp.dev/reference/sdk/fetch#server-options), with defaults filled in. | | `transport`, `sessionMetadata` | `undefined` here | Set for session clients: `transport` for the Node HTTP server's, which the Playground can't run, and for [`connect()`](https://frontmcp.dev/reference/sdk/connect)'s; `sessionMetadata` for the Node server's only. `transport.supportsElicit` and `transport.elicit()` are the low-level form of [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit): use that. | | `platformEnv` | `unknown` | On a Cloudflare Worker, the `env` the handler was called with, which [`this.workerEnv`](https://frontmcp.dev/reference/sdk/contexts#thisworkerenv) reads; in-process, the `workerEnv` given to [`create()`, the call](https://frontmcp.dev/reference/sdk/create#passing-a-workers-bindings) or [`connect()`](https://frontmcp.dev/reference/sdk/connect#parameters) (since 1.9.4). `undefined` where nothing passes one, as in the Playground's own calls. (New in 1.8.7.) | | `flow` | `{ name } \| undefined` | The innermost [flow](https://frontmcp.dev/reference/server/flows) running for the request where the code runs: `flow.name` is `"tools:call-tool"` in a tool, `"prompts:get-prompt"` in a prompt. For the tool that's running, use [`getRunningTool()`](https://frontmcp.dev/reference/sdk/context#getcallsurface-and-getrunningtool). (New in 1.9.2: before, nothing set it.) | | `scope` | `{ id, logger } \| undefined` | The scope of that flow: the same object as [`this.scope`](https://frontmcp.dev/reference/sdk/contexts#thisscope). (New in 1.9.2.) | #### Methods | Method | Description | | --- | --- | | `set(key, value)`, `get(key)`, `has(key)`, `delete(key)` | A key-value store for this request, with string or symbol keys. Hooks, the tool, and tools it calls with `this.callTool()` all see the same store; the next request starts empty. See [Passing a value from a hook to the tool](https://frontmcp.dev/reference/sdk/context#passing-a-value-from-a-hook-to-the-tool). | | `mark(name)` | Records the current time under `name`. `"init"` is recorded when the request starts. | | `elapsed(from?, to?)` | Milliseconds between two marks. `from` defaults to `"init"`, `to` to now. | | `getMarks()` | Every mark, as a `ReadonlyMap` of name to time. | | `getLogger(parent)` | A child of `parent` whose lines start with the first eight characters of `requestId` and of the trace id. [`this.contextLogger`](https://frontmcp.dev/reference/sdk/contexts#thislogger-and-thiscontextlogger) is one. | | `toLogContext()` | `{ requestId, traceId, parentId, sessionIdHash, scopeId, flowName, elapsed }`, for structured logs. `sessionIdHash` is 12 hex characters, so the session id itself doesn't reach your logs. `flowName` is `flow.name`. | | `fetch(input, init?)` | What [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch) calls. | #### What each entry point fills in | | Node HTTP server | [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), the Playground | [`connect()`](https://frontmcp.dev/reference/sdk/connect) | [`create()`, `createDirect()`](https://frontmcp.dev/reference/sdk/create) | | --- | --- | --- | --- | --- | | `requestId` | New per request | New per request | New per request | New per request | | `traceContext` | From `traceparent`, or new | From `traceparent`, or new | New | New, or from `metadata`'s `x-frontmcp-trace-id` | | `metadata` | User agent, content type, accept, client IP, `x-frontmcp-*` headers | The same; the client IP [from the runtime or a trusted proxy](https://frontmcp.dev/reference/sdk/create-fetch-handler#the-clients-address) | `{ customHeaders: {} }` | The call's `metadata`, or `{ customHeaders: {} }` | | `clientInfo`, `platformType` | From each 2026-07-28 request's `_meta` | From each request's `_meta` | `undefined` (`this.clientInfo` has `connect()`'s `clientInfo`) | `undefined` | | `sessionId` | The session's id, or `anon:…` per 2026-07-28 request | `anon:…`, new per request | `direct:…`, the same for the client's life | `direct:…`, plus a hash of the user when there's an `authContext.user` | | `authInfo` | From your auth mode | From your auth mode | `{ token, user, scopes, sessionId, transport }`, from `authToken` and `session` | `{ sessionId, user, scopes, clientId, extra }`, from `authContext` | [Seeing what each entry point fills in](https://frontmcp.dev/reference/sdk/context#seeing-what-each-entry-point-fills-in) shows the last three columns. The Node server column is what [Reading the Request](https://frontmcp.dev/learn/reading-the-request) found running `frontmcp dev`. #### `authInfo` `this.context.authInfo` is what the auth layer produced, before FrontMCP turns it into `this.auth`: | Caller | `authInfo` | | --- | --- | | `public` mode | `token: ""`, `clientId` and `user.sub` the caller's `anon:` id, `scopes` the mode's `anonymousScopes`, `["anonymous"]` by default, `user: { iss: "public", sub, name: "Anonymous", scope }`, and the same `user` in `extra`. (Changed in 1.9.2: before, `scopes` was `["public"]`.) | | `static` mode | `token: ""` (the key isn't passed on), `clientId` and `user.sub` `static:…`, `scopes` the mode's scopes | | `transparent` mode, with a token | `token` the caller's token, `clientId` the token's `sub`, `scopes` from its `scope` claim, `expiresAt` in milliseconds, `user` every claim | | `connect()` | `token` from `authToken`, `user` from `session.user`, `scopes` from `session.scopes` (left out without it), `sessionId`, and `transport`, the client's connection. No `clientId`. (Changed in 1.9.3: before, there was no way to give `scopes`.) | | `create()`, `createDirect()` | `user` from `authContext.user` (`{ iss: "direct", sub: "direct" }` without one), `scopes` from `authContext.scopes` (`[]` without them), `clientId` the user's `sub`, `extra` from `authContext.extra`, and `token` if you pass one | The token is what [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch#forwarding-the-callers-token-to-your-own-service) forwards to the origins you list. Don't put it in results or logs. #### Related members | Member | Description | | --- | --- | | `this.tryGetContext()` | Returns the context, or `undefined` where `this.context` would throw, as in a channel's `onEvent()`. | | `this.contextLogger` | A logger whose lines carry the request id and trace id. `protected`: use it inside the class. | | `FRONTMCP_CONTEXT` | The token for the context. In a tool, `this.get(FRONTMCP_CONTEXT)` is `this.context`. A `CONTEXT`-scoped provider can inject it: see [`@Provider`](https://frontmcp.dev/reference/sdk/provider). | #### `getCallSurface()` and `getRunningTool()` Two functions exported by `@frontmcp/sdk` describe the call the code runs in. Unlike `this.context`, they work anywhere in that call, including helper functions that have no `this`: | Function | Returns | | --- | --- | | `getCallSurface()` | `"mcp"` in a tool, resource or prompt a client asked for, a `createDirect()` call included, and in an agent's own `execute()`. `"agent"` in a tool an [agent](https://frontmcp.dev/reference/sdk/agent) calls, from its model or its `execute()`. `"job"` in a [job](https://frontmcp.dev/reference/sdk/job), a workflow's steps included, and in the tools it calls. `undefined` in a tool another tool runs with [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool), and outside any call. Its type, `CallSurface`, also allows `"cli"`, for a CLI build's own client, `"http-trigger"`, in a channel handling a [webhook](https://frontmcp.dev/reference/sdk/channel#sources) and the tools it calls (since 1.8.5; before, webhook sources were never served), and, since 1.9, `"webmcp"`, for an agent in the browser calling the page's tools: see [`registerTool()`](https://frontmcp.dev/reference/sdk/register-tool). | | `getRunningTool()` | `{ name, fullName }` of the tool whose code is running, like `{ name: "close_ticket", fullName: "help-desk:close_ticket" }`. A tool called with `this.callTool()` sees itself, and the caller sees itself again once it returns. `undefined` outside a tool, as in a resource or a prompt. | `surface` in [`availableWhen`](https://frontmcp.dev/reference/server/environment) is checked against the same value, so a tool whose `surface` leaves out `"agent"` or `"job"` can't be called by agents or jobs. See [Telling a client's call from an in-process one](https://frontmcp.dev/reference/sdk/context#telling-a-clients-call-from-an-in-process-one). Changed in 1.8.4: before, only MCP requests had a surface, and `getCallSurface()` was `undefined` in a prompt and in anything an agent or a job called. #### Where it's available | In | `this.context` | | --- | --- | | Tools, including the tools of an agent | The request's context. | | An agent's own `execute()` | The request's context, as in a tool. (Changed in 1.8.5: before, it threw `RequestContextNotAvailableError`.) | | Resources | The request's context. | | Tool hooks | `ctx.state.toolContext.context`, the same object the tool gets. | | Prompts | The request's context. (Changed in 1.9.2: before, `PromptContext` had no `context` getter.) | | Jobs | The context of the `execute_job` or `execute_workflow` request that started the run: its `requestId`, trace, caller and `metadata`. A run in the background gets a copy of it, with a `requestId` of its own. (Changed in 1.9.2: before, it threw `RequestContextNotAvailableError`.) | | Channels | Throws `RequestContextNotAvailableError`: a channel's `onEvent()` runs outside any request. | Where it throws, `this.tryGetContext()` returns `undefined`, and [`this.fetch()` adds no headers](https://frontmcp.dev/reference/sdk/contexts#where-there-is-no-request-context). See [Troubleshooting](https://frontmcp.dev/reference/sdk/context#requestcontext-not-available). #### Caveats - Everything in `metadata`, `clientInfo` and `platformType` came from the client, which can write anything there. Use them for formatting, logging and tracing, never to decide access. [Reading the Request](https://frontmcp.dev/learn/reading-the-request#arguments-client-info-and-headers-are-claims) explains why. - The store holds values for one request. To keep something between requests, use a [provider](https://frontmcp.dev/reference/sdk/provider). - `this.context` throws where there's no request, rather than returning `undefined`. Use `this.tryGetContext()` in code that runs in both. --- ## Usage ### Tagging logs and errors with the request id `requestId` identifies the request in your logs, and `this.contextLogger` puts it in every line it writes. Give it to the user in an error message, and support can find the failure. [Reading the Request](https://frontmcp.dev/learn/reading-the-request#tagging-logs-and-errors-with-the-request-id) walks through this example: Initial call configuration: ```js { tool: "reopen_ticket", arguments: { id: "T-2" } } ``` ```ts reopen-ticket.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "reopen_ticket", description: "Reopen a closed support ticket", inputSchema: { id: z.string() } }) export class ReopenTicket extends ToolContext { async execute({ id }: { id: string }) { try { if (id === "T-2") throw new Error("SQLITE_BUSY: database is locked"); return { id, status: "open" }; } catch (err) { this.contextLogger.error(`reopen ${id} failed: ${String(err)}`); this.fail(new PublicMcpError(`Couldn't reopen ${id}. Give support this reference: ${this.context.requestId}`, "STORE_UNAVAILABLE")); } } } ``` ```ts reopen.test.ts import { test, expect } from "@frontmcp/testing"; test("the message ends with the request id, new for each request", async ({ mcp }) => { const first = (await mcp.tools.call("reopen_ticket", { id: "T-2" })).text()!; const second = (await mcp.tools.call("reopen_ticket", { id: "T-2" })).text()!; expect(first).toMatch(/reference: [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); expect(first).not.toBe(second); expect(first).not.toContain("SQLITE_BUSY"); }); ``` The **Logs** tab shows the error line, with the first eight characters of the request id and of the trace id in brackets. ### Joining a trace A request with a `traceparent` header continues that trace: `traceContext.traceId` is the caller's, and `parentId` the caller's span. [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch) sends it on, so a trace follows the call through your services. Without the header, each request starts a trace of its own. Tracing with OpenTelemetry, and how a trace continues into the services your tools call, are in [Observability and telemetry](https://frontmcp.dev/reference/server/observability#following-a-request-across-services). Initial call configuration: ```js { tool: "trace_info", arguments: {} } ``` ```ts trace.tool.ts active import { App, Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "trace_info", description: "Show this request's trace. For debugging.", inputSchema: {} }) export class TraceInfo extends ToolContext { async execute() { const { traceId, parentId, traceFlags, raw } = this.context.traceContext; return { traceId, parentId, traceFlags, raw }; } } @App({ id: "help-desk", name: "Help Desk", tools: [TraceInfo] }) export class HelpDesk {} ``` ```ts trace.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDesk } from "./trace.tool"; test("without a traceparent, each request starts a trace", async ({ mcp }) => { const first = (await mcp.tools.call("trace_info", {})).json(); const second = (await mcp.tools.call("trace_info", {})).json(); expect(first.traceId).toMatch(/^[0-9a-f]{32}$/); expect(first.raw).toBe(`00-${first.traceId}-${first.parentId}-01`); expect(second.traceId).not.toBe(first.traceId); }); test("with a traceparent header, the request joins that trace", async () => { const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] }); const traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "trace_info", traceparent }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "trace_info", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }), }), ); const { result } = await response.json(); expect(result.structuredContent).toEqual({ traceId: "0af7651916cd43dd8448eb211c80319c", parentId: "b7ad6b7169203331", traceFlags: 1, raw: traceparent }); }); test("a traceparent in _meta isn't used", async ({ mcp }) => { const traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "trace_info", arguments: {}, _meta: { traceparent } } }); expect(response.result.structuredContent.traceId).not.toBe("0af7651916cd43dd8448eb211c80319c"); }); ``` FrontMCP reads the header, not the `traceparent` a client puts in `_meta`: that one is only echoed back. ### Passing a value from a hook to the tool The store lets a hook work something out once, for the tool to use. Here a plugin decides which support desk a request belongs to, from a header, and tools read it. A tool called with `this.callTool()` sees the same store, and the next request starts with an empty one: Initial call configuration: ```js { tool: "whoami", arguments: {} } ``` ```ts desk.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; export const DESK = Symbol("desk"); @Plugin({ name: "desk", description: "Works out which support desk a request belongs to" }) export class DeskPlugin extends DynamicPlugin { @ToolHook.Will("execute") async pickDesk(ctx: FlowCtxOf<"tools:call-tool">) { const context = ctx.state.toolContext?.context; if (!context || context.has(DESK)) return; // once per request context.set(DESK, context.metadata.customHeaders["x-frontmcp-desk"] ?? "general"); } } ``` ```ts tools.ts import { Tool, ToolContext } from "@frontmcp/sdk"; import { DESK } from "./desk.plugin"; @Tool({ name: "whoami", description: "Which desk is handling this request", inputSchema: {} }) export class WhoAmI extends ToolContext { async execute() { const inner = await this.callTool("desk_queue", {}); return { desk: this.context.get(DESK), queue: inner.structuredContent }; } } @Tool({ name: "desk_queue", description: "The desk's queue", inputSchema: {}, visibility: "internal" }) export class DeskQueue extends ToolContext { async execute() { return { desk: this.context.get(DESK), open: 7 }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { DeskPlugin } from "./desk.plugin"; import { DeskQueue, WhoAmI } from "./tools"; @App({ id: "help-desk", name: "Help Desk", tools: [WhoAmI, DeskQueue], plugins: [DeskPlugin] }) export class HelpDesk {} ``` ```ts desk.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDesk } from "./help-desk.app"; test("the Playground sends no desk header", async ({ mcp }) => { expect((await mcp.tools.call("whoami", {})).json()).toEqual({ desk: "general", queue: { desk: "general", open: 7 } }); }); test("the header's desk reaches both tools", async () => { const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] }); const call = (headers: Record) => handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "whoami", ...headers }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "whoami", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }), }), ).then(async (r) => (await r.json()).result.structuredContent); expect(await call({ "x-frontmcp-desk": "berlin" })).toEqual({ desk: "berlin", queue: { desk: "berlin", open: 7 } }); expect(await call({})).toEqual({ desk: "general", queue: { desk: "general", open: 7 } }); // a new request, a new store }); ``` Use a `Symbol` or a prefixed string as the key, so plugins don't overwrite each other's values. The hook runs again for the inner call, and `has()` keeps it from redoing the work. To give tools a typed service instead of a loose value, a [`CONTEXT`-scoped provider](https://frontmcp.dev/reference/sdk/provider) is the other way to share per-request state. ### Timing parts of a call `mark()` records a point in time, and `elapsed()` measures between marks. `"init"` is when the request started, so `elapsed()` with no arguments is how long the request has been running: Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts export.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @Tool({ name: "export_tickets", description: "Export every ticket as CSV", inputSchema: {} }) export class ExportTickets extends ToolContext { async execute() { const ctx = this.context; ctx.mark("query"); await sleep(40); // stands in for the database query ctx.mark("format"); await sleep(10); // stands in for building the CSV const timings = { queryMs: ctx.elapsed("query", "format"), formatMs: ctx.elapsed("format"), totalMs: ctx.elapsed() }; this.contextLogger.info(`export timings ${JSON.stringify(timings)}`); return { rows: 43, timings, marks: [...ctx.getMarks().keys()], startedAt: ctx.getMarks().get("init") === ctx.timestamp, log: ctx.toLogContext() }; } } ``` ```ts timing.test.ts import { test, expect } from "@frontmcp/testing"; test("marks measure the parts of the call", async ({ mcp }) => { const { timings, marks, startedAt, log } = (await mcp.tools.call("export_tickets", {})).json(); expect(marks).toEqual(["init", "query", "format"]); expect(startedAt).toBe(true); expect(Object.keys(log)).toEqual(["requestId", "traceId", "parentId", "sessionIdHash", "scopeId", "flowName", "elapsed"]); expect(log.flowName).toBe("tools:call-tool"); expect(log.sessionIdHash).toMatch(/^[0-9a-f]{12}$/); expect(timings.queryMs).toBeGreaterThanOrEqual(35); expect(timings.totalMs).toBeGreaterThanOrEqual(timings.queryMs + timings.formatMs); }); ``` `this.context.mark()` is a timing mark. `this.mark()`, on the context classes, is something else: it only sets [`this.activeStage`](https://frontmcp.dev/reference/sdk/contexts#thisrunid-thisactivestage-and-thismark). ### Telling a client's call from an in-process one [`getCallSurface()` and `getRunningTool()`](https://frontmcp.dev/reference/sdk/context#getcallsurface-and-getrunningtool) work in a plain function, so a helper that several tools share can record which tool it ran in, and who asked for it: a client, an agent, a job, or another tool. Here `close_ticket` notifies the customer through another tool, and both write to the audit log: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts audit.ts active import { getCallSurface, getRunningTool } from "@frontmcp/sdk"; /** One line for the audit log, from whichever tool calls it. */ export function auditEntry(action: string) { return { action, tool: getRunningTool()?.fullName, from: getCallSurface() ?? "in-process" }; } ``` ```ts tickets.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { auditEntry } from "./audit"; @Tool({ name: "notify_customer", description: "Email the customer about a ticket", inputSchema: { id: z.string() } }) export class NotifyCustomer extends ToolContext { async execute({ id }: { id: string }) { return { audit: [auditEntry(`emailed the customer of ${id}`)] }; } } @Tool({ name: "close_ticket", description: "Close a ticket and tell the customer", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const notified = await this.callTool("notify_customer", { id }); const { audit } = notified.structuredContent as { audit: object[] }; return { audit: [auditEntry(`closed ${id}`), ...audit] }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CloseTicket, NotifyCustomer } from "./tickets.tools"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket, NotifyCustomer] }) export class HelpDeskApp {} ``` ```ts audit.test.ts import { test, expect } from "@frontmcp/testing"; import { Agent, AgentContext, App, FrontMcpInstance, Job, JobContext, Prompt, PromptContext, getCallSurface, z } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; import { NotifyCustomer } from "./tickets.tools"; test("each entry names its tool, and only the client's call is from mcp", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.json()).toEqual({ audit: [ { action: "closed T-1", tool: "help-desk:close_ticket", from: "mcp" }, { action: "emailed the customer of T-1", tool: "help-desk:notify_customer", from: "in-process" }, ], }); }); test("a client calling notify_customer itself is from mcp", async ({ mcp }) => { const result = await mcp.tools.call("notify_customer", { id: "T-2" }); expect(result.json()).toEqual({ audit: [{ action: "emailed the customer of T-2", tool: "help-desk:notify_customer", from: "mcp" }] }); }); test("so is a call through createDirect()", async () => { const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); const result = await server.callTool("notify_customer", { id: "T-3" }); await server.dispose(); expect(result.structuredContent).toEqual({ audit: [{ action: "emailed the customer of T-3", tool: "help-desk:notify_customer", from: "mcp" }] }); }); test("an agent's calls are from agent, a job's from job, and a prompt is from mcp", async () => { @Agent({ name: "closer", inputSchema: {}, tools: [NotifyCustomer], // A scripted model: it calls notify_customer once, then answers with the tool's result. llm: { adapter: { async completion(prompt) { const last = prompt.messages[prompt.messages.length - 1]; if (last.role === "user") return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "1", name: "notify_customer", arguments: { id: "T-4" } }] }; return { content: last.content, finishReason: "stop" }; }, }, }, }) class Closer extends AgentContext {} @Job({ name: "nightly-notify", inputSchema: {}, outputSchema: { audit: z.array(z.any()) } }) class NightlyNotify extends JobContext { async execute() { return (await this.callTool("notify_customer", { id: "T-5" })).structuredContent as { audit: object[] }; } } @Prompt({ name: "surface", description: "Say where the prompt was asked from", arguments: [] }) class Surface extends PromptContext { async execute() { return { messages: [{ role: "user" as const, content: { type: "text" as const, text: String(getCallSurface()) } }] }; } } @App({ id: "help-desk", name: "Help Desk", tools: [NotifyCustomer], agents: [Closer], jobs: [NightlyNotify], prompts: [Surface] }) class Desk {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }); const byAgent = await server.callTool("invoke_closer", {}); const byJob = await server.callTool("execute_job", { name: "nightly-notify", input: {} }); const prompt = await server.getPrompt("surface"); await server.dispose(); expect(byAgent.structuredContent).toEqual({ audit: [{ action: "emailed the customer of T-4", tool: "agent:closer:notify_customer", from: "agent" }] }); expect(byJob.structuredContent).toMatchObject({ result: { audit: [{ action: "emailed the customer of T-5", tool: "help-desk:notify_customer", from: "job" }] } }); expect(prompt.messages).toEqual([{ role: "user", content: { type: "text", text: "mcp" } }]); }); ``` ### Seeing what each entry point fills in One tool, called through `createFetchHandler()` with headers, in three auth modes, then through `connect()` and `create()`: Initial call configuration: ```js { tool: "describe_request", arguments: {} } ``` ```ts help-desk.app.ts active import { App, FRONTMCP_CONTEXT, Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "describe_request", description: "Describe the request this call arrived in. For debugging.", inputSchema: {} }) export class DescribeRequest extends ToolContext { async execute() { const c = this.context; const { token, clientId, scopes, expiresAt } = c.authInfo; return { sessionId: c.sessionId, verifiedSessionId: c.verifiedSessionId ?? null, scopeId: c.scopeId, metadata: c.metadata, clientInfo: c.clientInfo ?? null, platformType: c.platformType ?? null, authInfo: { keys: Object.keys(c.authInfo).sort(), token: token ?? null, clientId: clientId ?? null, scopes: scopes ?? null, expiresAt: expiresAt ?? null }, config: c.config, flow: c.flow?.name ?? null, sameScope: c.scope === this.scope, transport: c.transport !== undefined, sessionMetadata: c.sessionMetadata !== undefined, sameAsToken: this.get(FRONTMCP_CONTEXT) === c, }; } } @App({ id: "help-desk", name: "Help Desk", tools: [DescribeRequest] }) export class HelpDesk {} ``` ```ts entry-points.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance, connect, create } from "@frontmcp/sdk"; import { DescribeRequest, HelpDesk } from "./help-desk.app"; import { SAM } from "./tokens"; const info = { name: "help-desk", version: "1.0.0" }; async function describeOverHttp(auth: object, headers: Record = {}) { const handler = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk], auth }); const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "user-agent": "desk-console/2.1", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "describe_request", "x-frontmcp-desk": "berlin", ...headers, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "describe_request", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { name: "claude-ai", version: "1.0" } }, }, }), }), ); return (await response.json()).result.structuredContent; } test("createFetchHandler(): headers, _meta and a per-request session id", async () => { const described = await describeOverHttp({ mode: "public" }); expect(described).toMatchObject({ sessionId: expect.stringMatching(/^anon:/), verifiedSessionId: null, scopeId: "root", metadata: { contentType: "application/json", accept: "application/json, text/event-stream", customHeaders: { "x-frontmcp-desk": "berlin" }, }, clientInfo: { name: "claude-ai", version: "1.0" }, platformType: "claude", authInfo: { keys: ["clientId", "expiresAt", "extra", "scopes", "token", "user"], token: "", clientId: expect.stringMatching(/^anon:/), scopes: ["anonymous"] }, config: { forwardCallerTokenTo: [], forwardCustomHeadersTo: [], autoInjectTracingHeaders: true, requestTimeout: 30000 }, flow: "tools:call-tool", sameScope: true, transport: false, sessionMetadata: false, sameAsToken: true, }); }); test("an mcp-session-id header the client sent is used as it is, unverified", async () => { const described = await describeOverHttp({ mode: "public" }, { "mcp-session-id": "chosen-by-the-client" }); expect(described).toMatchObject({ sessionId: "chosen-by-the-client", verifiedSessionId: null }); }); test("static mode: the key isn't passed on", async () => { const described = await describeOverHttp({ mode: "static", tokens: ["desk-key-1"] }, { authorization: "Bearer desk-key-1" }); expect(described.authInfo).toMatchObject({ token: "", clientId: expect.stringMatching(/^static:/), scopes: ["static"] }); expect(described.sessionId).toMatch(/^anon:/); // a placeholder, not the caller }); test("transparent mode: the caller's token, and expiresAt in milliseconds", async () => { const jwks = { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] }; const auth = { mode: "transparent", provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", providerConfig: { jwks } }; const described = await describeOverHttp(auth, { authorization: `Bearer ${SAM}` }); expect(described.authInfo).toMatchObject({ token: SAM, clientId: "sam", scopes: ["tickets:read"], expiresAt: 4102444800000 }); }); test("connect(): one session for the client's life, no headers", async () => { const client = await connect({ info, apps: [HelpDesk] }, { clientInfo: { name: "cursor", version: "1.4.0" }, session: { user: { sub: "nour" }, scopes: ["tickets:read"] } }); const first = ((await client.callTool("describe_request", {})) as { structuredContent: any }).structuredContent; const second = ((await client.callTool("describe_request", {})) as { structuredContent: any }).structuredContent; await client.close(); expect(first).toMatchObject({ sessionId: expect.stringMatching(/^direct:/), verifiedSessionId: first.sessionId, metadata: { customHeaders: {} }, clientInfo: null, platformType: null, authInfo: { keys: ["scopes", "sessionId", "transport", "user"], scopes: ["tickets:read"] }, transport: true, }); expect(second.sessionId).toBe(first.sessionId); }); test("create(): the calling code's authContext and metadata", async () => { const server = await create({ info, tools: [DescribeRequest] }); const result = await server.callTool("describe_request", {}, { authContext: { user: { sub: "nour" }, scopes: ["tickets:read"] }, metadata: { customHeaders: { "x-frontmcp-desk": "berlin" } }, }); await server.dispose(); expect(result.structuredContent).toMatchObject({ sessionId: expect.stringMatching(/^direct:[0-9a-f-]{36}:[0-9a-f]{64}$/), metadata: { customHeaders: { "x-frontmcp-desk": "berlin" } }, clientInfo: null, authInfo: { keys: ["clientId", "extra", "scopes", "sessionId", "user"], clientId: "nour", scopes: ["tickets:read"] }, flow: "tools:call-tool", }); }); ``` ```ts tokens.ts // An access token for "sam" (scope "tickets:read"), signed ahead of time with the test key above. export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw"; ``` The request sets a `User-Agent` header, but a browser doesn't let a script set one, so in this Playground `metadata.userAgent` is missing. From a real client, it's the client's `User-Agent`. `connect()` gives the tool its `clientInfo` too, but as [`this.clientInfo`](https://frontmcp.dev/reference/sdk/contexts#thisclientinfo-and-thisplatform), not in `this.context`. The Node server can't run in the Playground; its column in the table above comes from running `frontmcp dev`. ### Reading the request in a prompt A prompt has `this.context`, as a tool does. Here a prompt ends its text with the request id and the trace, for a note that support can trace back: Initial call configuration: ```js { prompt: "handover_note", arguments: { id: "T-1" } } ``` ```ts handover.prompt.ts active import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "handover_note", description: "Write a handover note for a ticket", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class HandoverNote extends PromptContext { async execute({ id }: Record): Promise { const { requestId, traceContext } = this.context; const text = `Write a handover note for ticket ${id}. End it with "ref ${requestId}, trace ${traceContext.traceId}".`; return { messages: [{ role: "user", content: { type: "text", text } }] }; } } ``` ```ts handover.test.ts import { test, expect } from "@frontmcp/testing"; test("the prompt gets the request's id and trace", async ({ mcp }) => { const text = (await mcp.prompts.get("handover_note", { id: "T-1" })).messages[0].content.text as string; expect(text).toMatch(/ref [0-9a-f-]{36}, trace [0-9a-f]{32}"\.$/); }); ``` Changed in 1.9.2: before, `PromptContext` had no `context` getter, and `this.get(FRONTMCP_CONTEXT)` was the way to the request. It still returns the same object. --- ## Troubleshooting ### `RequestContext not available` The full message is `RequestContext not available. Ensure execution runs within a request scope created by RequestContextStorage.run()`, from a `RequestContextNotAvailableError`. The code read `this.context` where FrontMCP has no request context: in a channel's `onEvent()`, which runs outside any request, even for an event a tool emitted: Initial call configuration: ```js { tool: "report_outage", arguments: { service: "login" } } ``` ```ts outages.ts active import { Channel, ChannelContext, Tool, ToolContext, z, type ChannelEventBus, type ChannelNotification } from "@frontmcp/sdk"; export const seen: { error?: string; ref?: string } = {}; @Channel({ name: "outages", description: "Service outages", source: { type: "app-event", event: "outage" } }) export class OutagesChannel extends ChannelContext { async onEvent(payload: unknown): Promise { const { service, ref } = payload as { service: string; ref: string }; try { this.context; // 🚩 throws in a channel } catch (err) { seen.error = (err as Error).message; } seen.ref = ref; // ✅ what it needs came in the payload return { content: `${service} is down (ref ${ref})` }; } } @Tool({ name: "report_outage", description: "Report that a service is down", inputSchema: { service: z.string() } }) export class ReportOutage extends ToolContext { async execute({ service }: { service: string }) { const { channelEventBus } = this.scope as unknown as { channelEventBus?: ChannelEventBus }; delete seen.error; delete seen.ref; channelEventBus?.emit("outage", { service, ref: this.context.requestId }); for (let i = 0; i < 100 && !seen.ref; i++) await new Promise((r) => setTimeout(r, 10)); // let onEvent() finish return { reported: service, sameRef: seen.ref === this.context.requestId, error: seen.error ?? null }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { OutagesChannel, ReportOutage } from "./outages"; @App({ id: "status", name: "Status", tools: [ReportOutage], channels: [OutagesChannel] }) class Status {} @FrontMcp({ info: { name: "status", version: "1.0.0" }, apps: [Status], channels: { enabled: true } }) export default class Server {} ``` ```ts channel.test.ts import { test, expect } from "@frontmcp/testing"; test("a channel has no request context", async ({ mcp }) => { expect((await mcp.tools.call("report_outage", { service: "login" })).json()).toEqual({ reported: "login", sameRef: true, error: "RequestContext not available. Ensure execution runs within a request scope created by RequestContextStorage.run().", }); }); ``` Pass what a channel needs, like a reference to log, in the event's payload. And use `this.tryGetContext()` in code that runs both in requests and elsewhere. [`this.auth`](https://frontmcp.dev/reference/sdk/auth) works in both: in a channel it's an empty caller. A job has the context of the request that started it since 1.9.2; before, it threw here too. ### `this.context.clientInfo` is `undefined` The client sent its info at `initialize`, as clients before MCP 2026-07-28 and `connect()` do, not in this request's `_meta`. `this.clientInfo` on a tool covers both, so read that. ### `this.context.metadata.clientIp` is `undefined` Behind `createFetchHandler()`, the IP comes from the runtime when it says who connected (Deno, Bun, Cloudflare Workers), and from `X-Forwarded-For` or `X-Real-IP` only when the environment variable `FRONTMCP_TRUST_PROXY` says the proxy can be trusted. With neither, and in the Playground, it's `undefined`. In-process calls never have one. [Reading the client's address](https://frontmcp.dev/reference/sdk/create-fetch-handler#reading-the-clients-address) shows each case. ### `this.context.sessionId` is different on every call Under MCP 2026-07-28 there are no sessions, so each request gets its own `anon:` placeholder, even from a signed-in caller. Don't use it to recognize a caller or to key per-user data: use `this.auth.user.sub`. ### A value I `set()` is gone on the next call The store belongs to one request. Keep values across requests in a [provider](https://frontmcp.dev/reference/sdk/provider), keyed by `this.auth.user.sub` when they belong to a user. --- # this.elicit > Ask the user for input in the middle of a tool call, and continue with their answer as typed data. Source: https://frontmcp.dev/reference/sdk/elicit [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `this.elicit()` asks the user a question while a tool is running. The client shows a form, and the tool continues with the answer. Use it to [confirm an action before taking it](https://frontmcp.dev/learn/asking-the-user), or to collect a detail the model can't know. To remember the answer, so the user isn't asked every time, see the [Approval plugin](https://frontmcp.dev/reference/plugins/approval#asking-the-user-then-approving). ```ts const answer = await this.elicit(message, requestedSchema, options?) ``` --- ## Reference ### `this.elicit(message, requestedSchema, options?)` Call `this.elicit()` inside a tool's `execute()`, and turn elicitation on for the server with `elicitation: { enabled: true }`. ```ts server.ts import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a ticket after the user confirms", inputSchema: { id: z.string() } }) class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() })); if (answer.status !== "accept" || !answer.content?.confirm) return { closed: false }; return { closed: true, id }; } } @App({ id: "desk", name: "Help desk", tools: [CloseTicket] }) class HelpDesk {} @FrontMcp({ info: { name: "Help desk", version: "1.0.0" }, apps: [HelpDesk], elicitation: { enabled: true } }) export default class Server {} ``` [See more examples below.](https://frontmcp.dev/reference/sdk/elicit#usage) #### Parameters | Parameter | Type | Description | | --- | --- | --- | | `message` | `string` | The question, shown above the form. Say what will happen, like "Close ticket T-1? The customer will be notified." | | `requestedSchema` | Zod object, or an object of Zod types | The form's fields. FrontMCP sends it as JSON Schema, with each `.describe()` as the field's description. Pass a `z.object()` to get typed `content`. | | `options` | `ElicitOptions` | Optional. See below. | `options`: | Option | Type | Description | | --- | --- | --- | | `mode` | `"form" \| "url"` | `"form"` (the default) asks for the fields in the client. `"url"` asks the client to send the user to a web page, `url`: see [Sending the user to a web page](https://frontmcp.dev/reference/sdk/elicit#sending-the-user-to-a-web-page). | | `ttl` | `number` | How long to wait for an answer, in milliseconds. Default `300000` (5 minutes). Only protocol versions before 2026-07-28 wait; see [how it travels](https://frontmcp.dev/reference/sdk/elicit#how-an-elicitation-travels). | | `url` | `string` | The page to send the user to, for URL mode. Required with `mode: "url"`: without it, `this.elicit()` throws `url is required when mode is "url"`, and the call fails with `INVALID_INPUT`. (New in 1.9.2.) | | `elicitationId` | `string` | An id for URL mode, sent with the request so the web page can say which request it completes. Pass your own, which your page and your tool can both use; left out, FrontMCP makes one up, `elicit-` and a random UUID. (Changed in 1.9.3: before, it was required, and the call failed with `INVALID_INPUT` without it.) | #### Returns A promise of an `ElicitResult`: | Field | Type | Description | | --- | --- | --- | | `status` | `"accept" \| "decline" \| "cancel"` | `accept`: the user submitted the form. `decline`: they said no. `cancel`: they dismissed it without choosing. An answer with no action counts as `cancel`. | | `content` | the schema's type | What the user entered. Present when `status` is `"accept"`, in form mode; a URL-mode answer has none. | #### Server options `@FrontMcp({ elicitation })` takes: | Option | Type | Description | | --- | --- | --- | | `enabled` | `boolean` | Default `false`. While it's off, every `this.elicit()` throws, and the tool call fails with `ELICITATION_DISABLED`. | | `redis` | `RedisOptions` | Where to keep pending elicitations for protocol versions before 2026-07-28. Defaults to the server's `redis`, else its `sqlite`, or memory. See [Several instances and encryption](https://frontmcp.dev/reference/sdk/elicit#several-instances-and-encryption). | Turning elicitation on also changes what clients see. For clients on protocol versions before 2026-07-28 that can't show forms, FrontMCP adds the `sendElicitationResult` tool to `tools/list`, for [its fallback](https://frontmcp.dev/reference/sdk/elicit#the-model-is-asked-to-call-sendelicitationresult). And every tool that declares an `outputSchema` advertises a `oneOf` of that schema and an `elicitationPending` result. #### How an elicitation travels Under MCP 2026-07-28 the server never sends a request to the client, and nothing waits on the server. Instead: 1. The tool runs until `this.elicit()`. FrontMCP stops it and answers the original request with `resultType: "input_required"`, the question in `inputRequests`, and a signed `requestState`: ```json { "resultType": "input_required", "inputRequests": { "elicitation-1": { "method": "elicitation/create", "params": { "message": "Close ticket T-1?", "requestedSchema": { "type": "object", "properties": { "confirm": { "type": "boolean" } } } } } }, "requestState": "eyJyIjp7fS…" } ``` 2. The client shows the form. It then sends the same request again, with a new JSON-RPC id, adding the answer under the same key and the `requestState` it got: ```json { "name": "close_ticket", "arguments": { "id": "T-1" }, "inputResponses": { "elicitation-1": { "action": "accept", "content": { "confirm": true } } }, "requestState": "eyJyIjp7fS…" } ``` 3. FrontMCP runs the tool again **from the top**. This time `this.elicit()` returns the answer, and the tool finishes. A second `this.elicit()` in the same run starts another round, with the key `elicitation-2`. Before 2026-07-28, FrontMCP sends an `elicitation/create` request to the client instead, and `this.elicit()` waits for the answer, up to `ttl`. #### Several instances and encryption Under 2026-07-28, any instance can serve any round: the question and the earlier answers travel in `requestState`, and nothing is stored. Every instance needs the same `VAULT_SECRET` or `JWT_SECRET` to read it ([caveats](https://frontmcp.dev/reference/sdk/elicit#caveats)). Before 2026-07-28, the tool waits on the instance that asked, holding the call's stream open, and the client sends its answer in a `POST` of its own, which a load balancer can send to another instance. So FrontMCP keeps each pending question in an elicitation store, under the session's id, where the instance that receives the answer can find it: | Store | Used when | An answer that reaches another instance | | --- | --- | --- | | Redis | `elicitation.redis` is set, else the server's [`redis`](https://frontmcp.dev/reference/deployment/redis) | Is passed on. That instance reads the question from Redis, under `pending:`, and publishes the answer on the channel `result:`, which the asking instance subscribed to. The tool goes on there, and the client gets `202` for its answer. | | SQLite | The server has [`sqlite`](https://frontmcp.dev/reference/deployment/sqlite) and no `redis` | Isn't. The answer is passed on inside one process: a second process that shares the file answers `202`, and the tool waits out `ttl` and fails with `ELICITATION_TIMEOUT`. | | Redis, from the environment | None of these, and `REDIS_URL` or `REDIS_HOST` is [set](https://frontmcp.dev/reference/deployment/redis#from-the-environment) | Isn't, in practice: sessions stay in memory, so the other instance answers `404` `session not found`. | | Memory | None of these | Isn't, for the same reason. | The startup log names the store, its `keyPrefix` (the `redis` option's, `mcp:` by default, or `mcp:elicit:`) and whether it encrypts: `Created elicitation store { type: 'redis', keyPrefix: 'mcp:', supportsPubSub: true, encrypted: true }`. For the answer to reach the store at all, the instance has to serve the session, which takes `redis` and the same `MCP_SESSION_SECRET` everywhere ([How a session moves between instances](https://frontmcp.dev/reference/deployment/high-availability#how-a-session-moves-between-instances)). Without Redis, give clients on older protocol versions affinity at the load balancer, so every request of a session reaches the instance that holds it. The [fallback](https://frontmcp.dev/reference/sdk/elicit#the-model-is-asked-to-call-sendelicitationresult) uses the same store: its question is kept under `fallback:`, and the instance that receives `sendElicitationResult` runs the tool again itself. The store needs publish and subscribe, so some places refuse it: - **Vercel KV** has no publish and subscribe. `elicitation: { enabled: true }` with Vercel KV as the only store fails with `ElicitationNotSupportedError: Vercel KV is not supported for elicitation stores`; give `elicitation.redis` a Redis ([Vercel](https://frontmcp.dev/reference/deployment/vercel#storage)). - **An edge isolate**, like a Cloudflare Worker, refuses a store in memory. Without one in Redis, the server doesn't start: every request answers `503` `SERVER_START_FAILED`, and the log says `ElicitationNotSupportedError: Elicitation requires distributed storage when running on Edge runtime`. That happens whatever protocol version the clients use, and a Worker can't reach Redis ([Cloudflare Workers](https://frontmcp.dev/reference/deployment/cloudflare-workers#storage)), so leave `elicitation` off on an edge isolate. **Encryption.** When `MCP_ELICITATION_SECRET`, `MCP_SESSION_SECRET` or `MCP_SERVER_SECRET` is set, checked in that order, FrontMCP encrypts what it stores: the question, with its message and schema; the fallback's record, with the tool's name and arguments; and each answer it publishes to another instance. It uses AES-256-GCM, with a key derived (HKDF-SHA256) from the secret and the session's id, so reading a record takes both, and each session's records have a key of their own. A stored question keeps only `sessionId`, `elicitId`, `createdAt` and `expiresAt` readable, next to `__encrypted: { alg: "A256GCM", iv, tag, data }`. There's no option for it: with none of the three variables set, the store holds plain JSON. Production requires `MCP_SESSION_SECRET` for clients on older protocol versions, so a production server that serves them encrypts their questions. Every instance needs the same secret. With different `MCP_ELICITATION_SECRET`s, the instance that received the answer logged `[EncryptedElicitationStore] Failed to decrypt pending record` and still answered the client `202`, and the tool on the asking instance failed with `ELICITATION_TIMEOUT` when `ttl` ran out. This was checked with FrontMCP 1.9.4 on Node: two instances sharing Valkey 8 in Docker, and a client on 2025-11-25 that opened its session and called the tool on one and sent its answer to the other: with no secret, with `MCP_SESSION_SECRET` on both, with different `MCP_ELICITATION_SECRET`s, and with the fallback. Also two instances without Redis, two with only `REDIS_URL`, and two processes sharing a SQLite file. The edge isolate was checked in Node with the global that marks one, as on [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler). #### Caveats - `this.elicit()` is a protected method of `ToolContext` (and `AgentContext`: see [Asking the user from an agent](https://frontmcp.dev/reference/sdk/agent#asking-the-user-from-an-agent)). Call it from inside the class. - **Code before `this.elicit()` runs once per round.** Put side effects, like writes and emails, after the last `this.elicit()`. - Under 2026-07-28, **FrontMCP checks an accepted form answer against the schema** and fails the call with `INVALID_INPUT` if it doesn't fit. Answers from clients on older protocol versions aren't checked, so parse `content` yourself if you serve them. An in-process [`connect()`](https://frontmcp.dev/reference/sdk/connect) client's are, though: one that doesn't fit fails the call with `INVALID_INPUT` and `Invalid elicitation result content`. See [Checking the answer](https://frontmcp.dev/reference/sdk/elicit#checking-the-answer). - Keep the schema to a flat object of strings, numbers, booleans and enums. That's what MCP clients render as a form. FrontMCP doesn't enforce it. - **Ask for everything in one form when you can.** Each question is another round trip, and runs `execute()` again; see [Asking more than once](https://frontmcp.dev/reference/sdk/elicit#asking-more-than-once). - **Under 2026-07-28, set `VAULT_SECRET` (or `JWT_SECRET`) to the same value on every instance.** FrontMCP signs `requestState` with `VAULT_SECRET`, else `JWT_SECRET`, else a random key each process makes for itself. With that random key, a round that lands on another instance counts as a changed state, and the tool starts over. In production, FrontMCP (since 1.8.7) logs a warning at startup when the key is per-process and `redis` or `transport.persistence` is set, and the log line for a rejected state carries a hint that names `VAULT_SECRET`. This was checked on a Node server. - `mode: "url"` needs `url`, and a client that declares `elicitation: { url: {} }`. (Changed in 1.9.2: before, FrontMCP sent no URL with the request under 2026-07-28, so the client had no page to open.) --- ## Usage ### Asking the user to confirm Ask before doing something the user might regret, and handle every `status`. The server here turns elicitation on itself; Playgrounds that call `this.elicit()` without a server do it for you. The **Call** tab shows the form the user would see. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; const Confirm = z.object({ confirm: z.boolean().describe("Close the ticket"), note: z.string().optional().describe("A note for the customer"), }); @Tool({ name: "close_ticket", description: "Close a support ticket. Asks the user to confirm first.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ticket ${id}? The customer will be notified.`, Confirm); if (answer.status !== "accept") return { closed: false, reason: `The user chose ${answer.status}` }; if (!answer.content?.confirm) return { closed: false, reason: "The user didn't confirm" }; return { closed: true, id, note: answer.content.note ?? "" }; } } ``` ```ts server.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; @App({ id: "desk", name: "Help desk", tools: [CloseTicket] }) class HelpDesk {} @FrontMcp({ info: { name: "Help desk", version: "1.0.0" }, apps: [HelpDesk], elicitation: { enabled: true } }) export class Server {} ``` ```ts close-ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("closes the ticket when the user confirms", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "accept", content: { confirm: true, note: "Fixed in 2.3" } })); const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.json()).toEqual({ closed: true, id: "T-1", note: "Fixed in 2.3" }); }); test("leaves it open when the user declines", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "decline" })); const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.json()).toEqual({ closed: false, reason: "The user chose decline" }); }); test("the form names the ticket", async ({ mcp }) => { let asked: any; mcp.onElicitation((request) => { asked = request; return { action: "cancel" }; }); await mcp.tools.call("close_ticket", { id: "T-7" }); expect(asked.message).toContain("T-7"); expect(asked.requestedSchema.required).toEqual(["confirm"]); }); ``` Answer the form with **Accept**, **Decline** or **Cancel**, then open the **Wire** tab: the first `tools/call` came back `input_required`, and your answer went out as a second `tools/call`. ### Checking the answer The answer comes from the client. Under 2026-07-28, FrontMCP checks an accepted answer against the schema you asked with before `this.elicit()` returns, fills in defaults, and drops fields the schema doesn't have. An answer that doesn't fit fails the call with `INVALID_INPUT`, and the text says which fields were wrong. Clients on older protocol versions aren't checked, so a tool that serves them should parse the answer too and fail with a message the model can read, as this one does. Initial call configuration: ```js { tool: "escalate_ticket", arguments: { id: "T-3" } } ``` ```ts escalate.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const Escalation = z.object({ team: z.enum(["billing", "engineering", "security"]).describe("Who should take the ticket"), reason: z.string().min(10).describe("Why it can't wait"), }); @Tool({ name: "escalate_ticket", description: "Escalate a ticket to another team. Asks the user which team, and why.", inputSchema: { id: z.string() }, }) export class EscalateTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Escalate ticket ${id}. Which team, and why?`, Escalation); if (answer.status !== "accept") return { escalated: false }; const parsed = Escalation.safeParse(answer.content); if (!parsed.success) this.fail(new PublicMcpError("The escalation form wasn't filled in: pick a team and give a reason of 10 characters or more.")); return { escalated: true, id, ...parsed.data }; } } ``` ```ts escalate.test.ts import { test, expect } from "@frontmcp/testing"; test("a valid answer escalates the ticket", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "accept", content: { team: "security", reason: "Customer data may be exposed" } })); const result = await mcp.tools.call("escalate_ticket", { id: "T-3" }); expect(result.json()).toEqual({ escalated: true, id: "T-3", team: "security", reason: "Customer data may be exposed" }); }); test("an answer that doesn't fit the schema is rejected", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "accept", content: { team: "sales", reason: "now" } })); const result = await mcp.tools.call("escalate_ticket", { id: "T-3" }); expect(result).toBeError("INVALID_INPUT"); expect(result).toHaveTextContent("does not match the requested schema"); }); ``` ### Keeping side effects after the question FrontMCP runs the tool again from the top for each round, so everything before `this.elicit()` runs twice. Here the counter stands in for a database write: ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; let auditEntries = 0; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { auditEntries++; // 🚩 runs before the question, so once per round const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() })); return { closed: answer.status === "accept" && answer.content?.confirm === true, auditEntries }; } } ``` ```ts replay.test.ts import { test, expect } from "@frontmcp/testing"; test("one call, two runs of execute()", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "accept", content: { confirm: true } })); const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.json()).toEqual({ closed: true, auditEntries: 2 }); }); ``` Move the write below the last `this.elicit()`, and it runs once. ### What goes over the wire This test plays a 2026-07-28 client by hand, so you can see both requests. The **Wire** tab shows the same exchange. ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() })); return { closed: answer.status === "accept" && answer.content?.confirm === true, id }; } } ``` ```ts wire.test.ts import { test, expect } from "@frontmcp/testing"; const closeT1 = (extra: Record = {}) => ({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "close_ticket", arguments: { id: "T-1" }, ...extra } }) as const; test("the first request comes back input_required", async ({ mcp }) => { const first = await mcp.raw.request(closeT1()); expect(first.result.resultType).toBe("input_required"); expect(first.result.inputRequests["elicitation-1"]).toMatchObject({ method: "elicitation/create", params: { message: "Close ticket T-1?" }, }); expect(first.result.requestState).toEqual(expect.any(String)); }); test("sending the answer back completes the call", async ({ mcp }) => { const first = await mcp.raw.request(closeT1()); const second = await mcp.raw.request( closeT1({ inputResponses: { "elicitation-1": { action: "accept", content: { confirm: true } } }, requestState: first.result.requestState, }), ); expect(second.result.resultType).toBe("complete"); expect(second.result.structuredContent).toEqual({ closed: true, id: "T-1" }); }); ``` ### Asking more than once Each `this.elicit()` in a run gets the next key: `elicitation-1`, `elicitation-2`, and so on. A later question can depend on an earlier answer, like choosing an agent from the team just picked. ```ts reassign.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; const agents = { billing: ["Sam", "Priya"], engineering: ["Nour", "Lee"] } as const; @Tool({ name: "reassign_ticket", description: "Move a ticket to another team and agent", inputSchema: { id: z.string() } }) export class ReassignTicket extends ToolContext { async execute({ id }: { id: string }) { const first = await this.elicit(`Which team should take ${id}?`, z.object({ team: z.enum(["billing", "engineering"]) })); if (first.status !== "accept" || !first.content) return { reassigned: false }; const team = first.content.team; const second = await this.elicit(`Which ${team} agent?`, z.object({ agent: z.enum(agents[team]) })); if (second.status !== "accept" || !second.content) return { reassigned: false }; return { reassigned: true, id, team, agent: second.content.agent }; } } ``` ```ts reassign.test.ts import { test, expect } from "@frontmcp/testing"; const reassign = (inputResponses?: Record, requestState?: string) => ({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "reassign_ticket", arguments: { id: "T-2" }, ...(inputResponses ? { inputResponses } : {}), ...(requestState ? { requestState } : {}) } }) as const; test("each question gets the next key", async ({ mcp }) => { const team = { action: "accept", content: { team: "billing" } }; const first = await mcp.raw.request(reassign()); expect(Object.keys(first.result.inputRequests)).toEqual(["elicitation-1"]); const second = await mcp.raw.request(reassign({ "elicitation-1": team })); expect(second.result.inputRequests["elicitation-2"].params.message).toBe("Which billing agent?"); const done = await mcp.raw.request(reassign({ "elicitation-1": team, "elicitation-2": { action: "accept", content: { agent: "Priya" } } })); expect(done.result.structuredContent).toEqual({ reassigned: true, id: "T-2", team: "billing", agent: "Priya" }); }); test("requestState carries the earlier answers", async ({ mcp }) => { const first = await mcp.raw.request(reassign()); const second = await mcp.raw.request(reassign({ "elicitation-1": { action: "accept", content: { team: "billing" } } }, first.result.requestState)); const done = await mcp.raw.request(reassign({ "elicitation-2": { action: "accept", content: { agent: "Priya" } } }, second.result.requestState)); expect(done.result.structuredContent).toEqual({ reassigned: true, id: "T-2", team: "billing", agent: "Priya" }); }); ``` A client can send every answer so far, as the first test does, or only the latest one with the `requestState` it was given, as the second does: the state carries the earlier answers. A `requestState` that was changed, or that comes back with different arguments, is ignored, and the tool asks its first question again. ### Sending the user to a web page Some answers don't belong in a form: a password, a payment, or signing in to another service. With `mode: "url"`, the client sends the user to a page of yours instead, with `url`, and FrontMCP adds an `elicitationId`, yours or one it makes up, so the page can tell which request it completes. The client needs to declare `elicitation: { url: {} }`: ```ts connect-billing.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "connect_billing", description: "Connect the help desk to the user's billing account", inputSchema: {} }) export class ConnectBilling extends ToolContext { async execute() { const state = "abc123"; // in a real server, one per request, kept to check the page's callback const answer = await this.elicit("Sign in to your billing account to connect it", z.object({}), { mode: "url", url: `https://billing.example/connect?state=${state}`, elicitationId: `billing-${state}`, }); return { connected: answer.status === "accept" }; } } ``` ```ts connect-payroll.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "connect_payroll", description: "Connect the help desk to the user's payroll account", inputSchema: {} }) export class ConnectPayroll extends ToolContext { async execute() { const answer = await this.elicit("Sign in to your payroll account to connect it", z.object({}), { mode: "url", url: "https://payroll.example/connect?state=def456", }); return { connected: answer.status === "accept" }; } } ``` ```ts url.test.ts import { test, expect } from "@frontmcp/testing"; const connect = (capabilities: Record, inputResponses?: Record) => ({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "connect_billing", arguments: {}, ...(inputResponses ? { inputResponses } : {}), _meta: { "io.modelcontextprotocol/clientCapabilities": capabilities }, }, }) as const; test("the client is asked to open the page", async ({ mcp }) => { const response = await mcp.raw.request(connect({ elicitation: { url: {} } })); expect(response.result.inputRequests["elicitation-1"].params).toMatchObject({ message: "Sign in to your billing account to connect it", mode: "url", url: "https://billing.example/connect?state=abc123", elicitationId: "billing-abc123", }); }); test("its answer runs the tool again", async ({ mcp }) => { const response = await mcp.raw.request(connect({ elicitation: { url: {} } }, { "elicitation-1": { action: "accept" } })); expect(response.result.structuredContent).toEqual({ connected: true }); }); test("without an `elicitationId`, FrontMCP makes one up", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "connect_payroll", arguments: {}, _meta: { "io.modelcontextprotocol/clientCapabilities": { elicitation: { url: {} } } }, }, }); expect(response.result.inputRequests["elicitation-1"].params.elicitationId).toMatch(/^elicit-[0-9a-f-]{36}$/); }); test("a client that only shows forms gets -32021", async ({ mcp }) => { const response = await mcp.raw.request(connect({ elicitation: { form: {} } })); expect(response).toHaveErrorCode(-32021); expect(response.error.data).toEqual({ requiredCapabilities: { elicitation: { url: {} } } }); }); ``` The answer only says that the user finished, or declined: whatever they did on the page reaches your server through the page, not through the client. So the page should keep what it learned under the `elicitationId` or the `state` it was given, for the tool to read on its next run. A URL-mode answer carries no `content`, as MCP specifies, so FrontMCP doesn't check it against the schema: pass `z.object({})`, and an accepted answer is `{ status: "accept" }`. `connect_payroll` leaves `elicitationId` out, and FrontMCP makes one up, as the third test shows. The tool never sees that id, though, so pass your own when the page reports back under it. (Changed in 1.9.3: before, an accepted URL-mode answer was checked against the schema, so `z.object({})` failed the call with `INVALID_INPUT` unless it was made `.optional()`, and a URL-mode elicitation without an `elicitationId` failed with `INVALID_INPUT`. Changed in 1.9.2: before, under 2026-07-28, FrontMCP sent no `url` or `elicitationId` with the request, so the client had no page to open.) --- ## Troubleshooting ### "Elicitation is disabled in server configuration" The server doesn't have `elicitation: { enabled: true }`, so `this.elicit()` threw and the call failed with `ELICITATION_DISABLED`. In production the message is shorter, "Elicitation is disabled on this server.", and the code is the same. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts server.ts import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() })); return { closed: answer.status === "accept" }; } } @App({ id: "desk", name: "Help desk", tools: [CloseTicket] }) class HelpDesk {} // 🚩 No `elicitation: { enabled: true }` @FrontMcp({ info: { name: "Help desk", version: "1.0.0" }, apps: [HelpDesk] }) export class Server {} ``` ### Error `-32021`: "This request requires the `elicitation` client capability" Under 2026-07-28, a client declares what it supports in each request's `_meta`, under `io.modelcontextprotocol/clientCapabilities`. This client didn't declare `elicitation`, so FrontMCP refused to ask it anything. `error.data.requiredCapabilities` says what was missing: `{ elicitation: { form: {} } }`, or `{ elicitation: { url: {} } }` for URL mode. ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() })); return { closed: answer.status === "accept" }; } } ``` ```ts capability.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; @App({ id: "desk", name: "Help desk", tools: [CloseTicket] }) class HelpDesk {} test("a client without the elicitation capability gets -32021", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "close_ticket", arguments: { id: "T-1" }, _meta: { "io.modelcontextprotocol/clientCapabilities": {} }, }, }); expect(response).toHaveErrorCode(-32021); expect(response.error.data).toEqual({ requiredCapabilities: { elicitation: { form: {} } } }); }); test("a request that names no protocol version gets ELICITATION_NOT_SUPPORTED instead", async () => { // No MCP-Protocol-Version header and no _meta: FrontMCP serves it as 2026-07-28 anyway. const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], elicitation: { enabled: true } }); const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "close_ticket", arguments: { id: "T-1" } } }), }), ); const { result } = await response.json(); expect(result).toMatchObject({ isError: true, _meta: { code: "ELICITATION_NOT_SUPPORTED" } }); }); ``` A request that names no protocol version at all, in a header or its `_meta`, like one from a client built for 2025-03-26, is served as 2026-07-28, but gets `ELICITATION_NOT_SUPPORTED` as a tool error instead, as the second test shows (changed in 1.8.5: before, it got `-32021` too). Declare `elicitation: { form: {} }` in the client's capabilities, with `url: {}` too if you use URL mode. If some clients can't show forms, give the tool an input that skips the question, like `confirmed: true`, so they can still use it. ### The model is asked to call `sendElicitationResult` That's FrontMCP's fallback for clients on protocol versions before 2026-07-28 that don't support elicitation. Instead of a form, the tool's result tells the model to ask the user, and carries the question in `_meta.elicitationPending` (`elicitId`, `message`, `schema`). The model then calls `sendElicitationResult` with: | Argument | Type | Description | | --- | --- | --- | | `elicitId` | `string` | The id from `elicitationPending`. | | `action` | `"accept" \| "decline" \| "cancel"` | What the user chose. | | `content` | any | The user's answer, for `accept`. | FrontMCP runs the original tool again with the same arguments, as the caller who answered, so `this.auth` is theirs: `this.elicit()` returns that answer, and the model gets the tool's final result. (Changed in 1.8.4: before, a `createDirect()` caller's re-run had no auth info, and a tool that read `this.auth` failed.) Only the caller who was asked can answer: from the same session, or, without one, as the same signed-in user. An answer from anyone else fails with `ELICITATION_NOT_OWNED` (`-32003`), and the question stays open for its owner. An unknown or expired `elicitId` fails too: ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() }, outputSchema: z.object({ closed: z.boolean(), by: z.string() }), }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() })); return { closed: answer.status === "accept", by: this.auth.user.sub }; } } ``` ```ts fallback.test.ts import { test, expect } from "@frontmcp/testing"; test("2026-07-28 clients don't see the fallback tool", async ({ mcp }) => { expect(await mcp.tools.list()).not.toContainTool("sendElicitationResult"); }); test("the output schema also allows the fallback result", async ({ mcp }) => { const tool = (await mcp.tools.list()).find((t) => t.name === "close_ticket"); expect(tool?.outputSchema?.oneOf).toHaveLength(2); expect(tool?.outputSchema?.oneOf?.[1]).toMatchObject({ required: ["elicitationPending"] }); }); test("an unknown elicitId is an error", async ({ mcp }) => { const result = await mcp.tools.call("sendElicitationResult", { elicitId: "elicit-123", action: "accept", content: { confirm: true } }); expect(result).toBeError(); expect(result).toHaveTextContent("No pending elicitation found for ID: elicit-123"); }); ``` ```ts owner.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; test("only the caller who was asked can answer, and the re-run is theirs", async () => { // createDirect()'s client can't show forms, so it gets the fallback. const server = await FrontMcpInstance.createDirect(config); const nour = { authContext: { user: { sub: "nour" } } }; const sam = { authContext: { user: { sub: "sam" } } }; const asked = await server.callTool("close_ticket", { id: "T-1" }, nour); const { elicitId } = (asked._meta as { elicitationPending: { elicitId: string } }).elicitationPending; const answer = { elicitId, action: "accept", content: { confirm: true } }; await expect(server.callTool("sendElicitationResult", answer, sam)).rejects.toThrow(`Elicitation "${elicitId}" was not requested by this caller`); expect((await server.callTool("sendElicitationResult", answer, nour)).structuredContent).toEqual({ closed: true, by: "nour" }); await server.dispose(); }); ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true } }; @FrontMcp(config) export default class Server {} ``` FrontMCP doesn't list `sendElicitationResult` to 2026-07-28 clients, or to older clients that declare elicitation support, though it still answers if called, as the last test shows. A 2026-07-28 client without the capability gets [`-32021`](https://frontmcp.dev/reference/sdk/elicit#error--32021-this-request-requires-the-elicitation-client-capability) rather than the fallback. ### "Elicitation request timed out" Only protocol versions before 2026-07-28 wait for the answer. The user didn't answer within `ttl`, so `this.elicit()` threw `ElicitationTimeoutError` and the call failed with `ELICITATION_TIMEOUT`. Raise `ttl` for long forms, and let the error end the call. On several instances, the answer may have reached one that couldn't pass it on, though the client got `202` for it: an instance without Redis, a second process on a SQLite file, or one with another `MCP_ELICITATION_SECRET`, whose log says `Failed to decrypt pending record`. See [Several instances and encryption](https://frontmcp.dev/reference/sdk/elicit#several-instances-and-encryption). ### The same side effect happens twice The code ran before `this.elicit()`, and FrontMCP ran the tool again for the answer. Move it after the last `this.elicit()`. See [Keeping side effects after the question](https://frontmcp.dev/reference/sdk/elicit#keeping-side-effects-after-the-question). --- # this.progress > Tell the client how far a long-running tool call has got, when the client asked for progress. Source: https://frontmcp.dev/reference/sdk/progress [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `this.progress()` sends a `notifications/progress` message while a tool is still running, so a client can show a progress bar instead of a spinner. The client has to [ask for progress](https://frontmcp.dev/learn/reporting-progress) by sending a `progressToken` with the request. Without one, `this.progress()` sends nothing. ```ts const sent = await this.progress(progress, total?, message?) ``` --- ## Reference ### `this.progress(progress, total?, message?)` Call `this.progress()` inside a tool's `execute()`, each time a meaningful part of the work is done. ```ts bulk-close.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "bulk_close", description: "Close several tickets", inputSchema: { ids: z.array(z.string()) } }) class BulkClose extends ToolContext { async execute({ ids }: { ids: string[] }) { for (const [i, id] of ids.entries()) { await closeTicket(id); await this.progress(i + 1, ids.length, `Closed ${id}`); } return { closed: ids }; } } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/progress#usage) #### Parameters | Parameter | Type | Description | | --- | --- | --- | | `progress` | `number` | How much is done so far. MCP requires each value to be higher than the last. | | `total` | `number` | Optional. How much there is in all, so clients can show a fraction. Leave it out when you don't know. | | `message` | `string` | Optional. What's happening now, like `"Closed T-2"`. | #### Returns A promise of a `boolean`: `true` if the notification was sent, `false` if the request had no `progressToken`. Nothing is thrown either way. #### What the client receives ```json { "jsonrpc": "2.0", "method": "notifications/progress", "params": { "progressToken": "progress-1", "progress": 2, "total": 3, "message": "Closed T-2" } } ``` `progressToken` is the token from the request. `total` and `message` are left out when you don't pass them. #### Asking for progress A client asks for progress per request, with a `progressToken` (a string or a number) in the request's `_meta`: ```json { "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": { "name": "bulk_close", "arguments": { "ids": ["T-1", "T-2", "T-3"] }, "_meta": { "progressToken": "progress-1" } } } ``` Over HTTP under MCP 2026-07-28, when a request carries a `progressToken` or an [`io.modelcontextprotocol/logLevel`](https://frontmcp.dev/reference/sdk/notify#asking-for-log-messages), and its `Accept` header includes `text/event-stream`, FrontMCP answers with a stream (`Content-Type: text/event-stream`). Each notification is an event, in the order the tool sent them, and the last event is the JSON-RPC response. A request without either gets a plain JSON response. Over stdio under MCP 2026-07-28, a request that asks for progress or log messages receives those notifications as JSON lines before the result. No `Accept` header is needed. New in 1.10.5; see [Revisions over stdio](https://frontmcp.dev/reference/server/protocol-versions#revisions-over-stdio). Protocol versions before 2026-07-28 use the same `progressToken`, and FrontMCP sends the notifications on the client's session. An in-process client from [`connect()`](https://frontmcp.dev/reference/sdk/connect#hearing-a-tools-progress) asks per call too: pass `{ onProgress }` as `callTool()`'s third argument, and it gets each update as `{ progress, total, message }`. See [Checking whether the client is listening](https://frontmcp.dev/reference/sdk/progress#checking-whether-the-client-is-listening). (Changed in 1.9.3: before, `callTool()` sent no progress token, so `this.progress()` always returned `false` under `connect()`.) #### Caveats - `this.progress()` is a protected method of `ToolContext` (and `AgentContext`). Call it from inside the class. - **It's opt-in.** Many clients never send a `progressToken`, so don't put anything the model needs only in a progress message. Put it in the result. - FrontMCP doesn't check that `progress` increases. That's up to you. - **A tool's progress on a session ignores the token `0`.** For a request on a session, before 2026-07-28, that sends `progressToken: 0`, a tool's `this.progress()` sends nothing and returns `false`; a token like `7` works. An agent's `this.progress()` sends on `0` (since 1.9.4), and under 2026-07-28 a tool's does too. This was checked on a Node server, since the Playground's client speaks 2026-07-28. - Under `frontmcp test`, the test client sends a `progressToken` only while a `collectProgress()` collector is active, so otherwise `this.progress()` returns `false`. The Playground's test client sends one with every `tools/call`. --- ## Usage ### Reporting progress through a list Report after each item, with the item count as `total`. This example turns on **Stream progress & logs** in the Call tab, which sends a `progressToken`. The notifications appear above the result, and the **Wire** tab shows the stream. Initial call configuration: ```js { tool: "bulk_close", arguments: { ids: ["T-1", "T-2", "T-3"] } } ``` ```ts bulk-close.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "bulk_close", description: "Close several tickets at once. Reports progress after each ticket.", inputSchema: { ids: z.array(z.string()).min(1).describe("Ticket ids, like T-1") }, }) export class BulkClose extends ToolContext { async execute({ ids }: { ids: string[] }) { for (const [i, id] of ids.entries()) { await new Promise((resolve) => setTimeout(resolve, 200)); // stands in for the real work await this.progress(i + 1, ids.length, `Closed ${id}`); } return { closed: ids }; } } ``` Untick **Stream progress & logs** and call again: the result is the same, and nothing is streamed. ### Choosing what to report ### Progress values ### Example: Steps with a total When the work has known steps, send the step number and the number of steps. Clients can show a percentage. Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts export.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; const steps = ["Loading tickets", "Formatting CSV", "Uploading"]; @Tool({ name: "export_tickets", description: "Export all tickets to CSV and upload the file", inputSchema: {} }) export class ExportTickets extends ToolContext { async execute() { for (const [i, step] of steps.entries()) { await this.progress(i, steps.length, step); } await this.progress(steps.length, steps.length, "Done"); return { url: "https://files.example.com/tickets.csv" }; } } ``` ### Example: No total When you can't know the total, leave it out and count up. Clients show activity without a percentage. Initial call configuration: ```js { tool: "scan_inbox", arguments: {} } ``` ```ts scan.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; const pages = [["mail-1", "mail-2"], ["mail-3"], []]; @Tool({ name: "scan_inbox", description: "Turn unread support emails into tickets", inputSchema: {} }) export class ScanInbox extends ToolContext { async execute() { let seen = 0; for (const page of pages) { if (!page.length) break; seen += page.length; await this.progress(seen, undefined, `${seen} emails read`); } return { created: seen }; } } ``` ### Checking whether the client is listening The return value says whether a notification went out. You rarely need it, but it shows the opt-in at work. The first test sends a request without a `progressToken`; the second uses the test client, which sends one; the third calls through `connect()`, without and then with `onProgress`. ```ts bulk-close.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "bulk_close", description: "Close several tickets", inputSchema: { ids: z.array(z.string()) } }) export class BulkClose extends ToolContext { async execute({ ids }: { ids: string[] }) { let sent = 0; for (const [i, id] of ids.entries()) { if (await this.progress(i + 1, ids.length, `Closed ${id}`)) sent++; } return { closed: ids, progressSent: sent }; } } ``` ```ts listening.test.ts import { test, expect } from "@frontmcp/testing"; import { App, connect } from "@frontmcp/sdk"; import { BulkClose } from "./bulk-close.tool"; @App({ id: "desk", name: "Desk", tools: [BulkClose] }) class Desk {} test("without a progressToken, nothing is sent", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "bulk_close", arguments: { ids: ["T-1", "T-2"] } }, }); expect(response.result.structuredContent).toEqual({ closed: ["T-1", "T-2"], progressSent: 0 }); }); test("with a progressToken, every call is sent", async ({ mcp }) => { const result = await mcp.tools.call("bulk_close", { ids: ["T-1", "T-2"] }); expect(result.json()).toEqual({ closed: ["T-1", "T-2"], progressSent: 2 }); }); test("a `connect()` client asks with `onProgress`", async () => { const client = await connect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }); const updates: unknown[] = []; try { expect((await client.callTool("bulk_close", { ids: ["T-1"] })).structuredContent).toEqual({ closed: ["T-1"], progressSent: 0 }); const result = await client.callTool("bulk_close", { ids: ["T-1", "T-2"] }, { onProgress: (update) => updates.push(update) }); expect(result.structuredContent).toEqual({ closed: ["T-1", "T-2"], progressSent: 2 }); expect(updates).toEqual([ { progress: 1, total: 2, message: "Closed T-1" }, { progress: 2, total: 2, message: "Closed T-2" }, ]); } finally { await client.close(); } }); ``` ### Testing progress In the Playground, `mcp.notifications.collectProgress().all` lists the progress notifications the test client received, in order. ```ts bulk-close.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "bulk_close", description: "Close several tickets", inputSchema: { ids: z.array(z.string()) } }) export class BulkClose extends ToolContext { async execute({ ids }: { ids: string[] }) { for (const [i, id] of ids.entries()) { await this.progress(i + 1, ids.length, `Closed ${id}`); } return { closed: ids }; } } ``` ```ts progress.test.ts import { test, expect } from "@frontmcp/testing"; test("reports one step per ticket, ending at the total", async ({ mcp }) => { await mcp.tools.call("bulk_close", { ids: ["T-1", "T-2", "T-3"] }); const updates = mcp.notifications.collectProgress().all; expect(updates.map((u) => [u.progress, u.total])).toEqual([[1, 3], [2, 3], [3, 3]]); expect(updates[2]).toMatchObject({ message: "Closed T-3" }); }); ``` --- ## Troubleshooting ### `this.progress()` returns `false`, and the client shows nothing The request didn't include a `progressToken`, so there was nowhere to send progress. The client decides this: in the Playground, tick **Stream progress & logs**; in your own client, set `_meta.progressToken` on the request; with [`connect()`](https://frontmcp.dev/reference/sdk/connect#hearing-a-tools-progress), pass `{ onProgress }` to `callTool()`. The tool still runs and returns its result. ### Progress goes backwards, or never reaches the end MCP requires every `progress` value to be higher than the one before, and FrontMCP sends what you pass without checking. Count up, and when you pass `total`, finish with `progress` equal to `total`. ### TypeScript says `progress` is protected `this.progress()` can only be called from inside the tool class. To report progress from a helper, pass it a callback: ```ts await importTickets(rows, (done) => this.progress(done, rows.length)); ``` ### `collectProgress().all` is empty under `frontmcp test` The call had no progress token, or the test checked before the updates arrived. Call `mcp.notifications.collectProgress()` before the call, so the test client sends a token, and wait with `waitForComplete()` before you check. See [Checking notifications](https://frontmcp.dev/reference/testing#checking-notifications). --- # this.notify > Send log messages to the client while a tool runs, at the levels the client asked for. Source: https://frontmcp.dev/reference/sdk/notify [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `this.notify()` sends a log message, a `notifications/message`, to the client while a tool is still running. Clients show these in a log or a debug panel, to explain what a long call is doing. The client chooses whether to receive them, and from which level up. Without that request, `this.notify()` sends nothing. ```ts const sent = await this.notify(message, level?) ``` --- ## Reference ### `this.notify(message, level?)` Call `this.notify()` inside a tool's `execute()`. ```ts bulk-close.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "bulk_close", description: "Close several tickets", inputSchema: { ids: z.array(z.string()) } }) class BulkClose extends ToolContext { async execute({ ids }: { ids: string[] }) { await this.notify(`Closing ${ids.length} tickets`); for (const id of ids) { await closeTicket(id); await this.notify(`Closed ${id}`, "debug"); } return { closed: ids }; } } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/notify#usage) #### Parameters | Parameter | Type | Description | | --- | --- | --- | | `message` | `string \| Record` | What to log. A string arrives as `{ "message": "..." }`; an object arrives as it is. | | `level` | `McpLoggingLevel` | Optional. How important the message is. Default `"info"`. | The eight levels, from lowest to highest: | Level | Use it for | | --- | --- | | `"debug"` | Detail that helps when something goes wrong. | | `"info"` | What the tool is doing. The default. | | `"notice"` | Something worth noticing, but normal. | | `"warning"` | Something unexpected that the tool worked around. | | `"error"` | Something failed. | | `"critical"` | A part of the system is failing. | | `"alert"` | Someone has to act now. | | `"emergency"` | The system is unusable. | #### Returns A promise of a `boolean`: `true` if the message was sent, `false` if the client didn't ask for log messages, or asked only for higher levels. Nothing is thrown either way. #### What the client receives ```json { "jsonrpc": "2.0", "method": "notifications/message", "params": { "level": "info", "logger": "bulk_close", "data": { "message": "Closing 3 tickets" } } } ``` `logger` is the tool's name, and `data` is your message. #### Asking for log messages Under MCP 2026-07-28, a client asks for log messages per request, with a minimum level in the request's `_meta`: ```json "_meta": { "io.modelcontextprotocol/logLevel": "info" } ``` FrontMCP sends the messages at that level and above, and drops the rest: | `logLevel` in the request | Levels that are sent | | --- | --- | | none | none | | `"debug"` | all eight | | `"info"` | everything except `debug` | | `"warning"` | `warning`, `error`, `critical`, `alert` and `emergency` | | `"error"` | `error`, `critical`, `alert` and `emergency` | The messages arrive in the response stream, before the result, as described for [`this.progress()`](https://frontmcp.dev/reference/sdk/progress#asking-for-progress). A `progressToken` alone doesn't turn log messages on. Before 2026-07-28, a client set one level for its whole session with `logging/setLevel`, and FrontMCP sent nothing until it did. Under 2026-07-28 that method is gone. #### Caveats - `this.notify()` is a protected method of `ToolContext` (and `AgentContext`). Call it from inside the class. - **It's opt-in.** Many clients won't ask for log messages, and models usually don't see them. Anything the model needs belongs in the result. - It isn't the server's log. For your own logs, use `this.logger` ([Logging](https://frontmcp.dev/reference/server/logging)), which writes to the server's output and never reaches the client. - Under `frontmcp test`, `this.notify()` returns `false` until the test sends `logging/setLevel`; the test client doesn't ask for log messages by itself. The Playground's test client asks for `"debug"` with every `tools/call`. --- ## Usage ### Logging what a tool is doing Log the steps a person watching the call would want to see. This example turns on **Stream progress & logs** in the Call tab, which asks for `"info"` and up: the `debug` lines are dropped, and the rest appear above the result. Initial call configuration: ```js { tool: "bulk_close", arguments: { ids: ["T-1", "T-2", "T-404"] } } ``` ```ts bulk-close.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const open = new Set(["T-1", "T-2", "T-3"]); @Tool({ name: "bulk_close", description: "Close several tickets at once. Skips ids that aren't open.", inputSchema: { ids: z.array(z.string()).min(1) }, }) export class BulkClose extends ToolContext { async execute({ ids }: { ids: string[] }) { await this.notify(`Closing ${ids.length} tickets`); const closed: string[] = []; for (const id of ids) { if (!open.has(id)) { await this.notify(`Skipped ${id}: it isn't open`, "warning"); continue; } await this.notify(`Closing ${id}`, "debug"); closed.push(id); } return { closed, skipped: ids.filter((id) => !closed.includes(id)) }; } } ``` ### Sending structured data Pass an object when a client might process the message, not only display it. It arrives as `data` without a `message` wrapper. Initial call configuration: ```js { tool: "import_tickets", arguments: {} } ``` ```ts import.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "import_tickets", description: "Import tickets from the old help desk", inputSchema: {} }) export class ImportTickets extends ToolContext { async execute() { const batches = [{ source: "legacy", rows: 120, failed: 0 }, { source: "legacy", rows: 80, failed: 2 }]; for (const [i, batch] of batches.entries()) { await this.notify({ batch: i + 1, ...batch }, batch.failed ? "warning" : "info"); } return { imported: 198, failed: 2 }; } } ``` ### Filtering by level Each test asks for a different minimum level, the way a client would, and the tool reports which of its eight messages were sent. ```ts levels.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; const levels = ["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"] as const; @Tool({ name: "log_every_level", description: "Log one message at each level", inputSchema: {} }) export class LogEveryLevel extends ToolContext { async execute() { const sent: string[] = []; for (const level of levels) { if (await this.notify(`A ${level} message`, level)) sent.push(level); } return { sent }; } } ``` ```ts levels.test.ts import { test, expect } from "@frontmcp/testing"; const callWith = (_meta: Record) => ({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "log_every_level", arguments: {}, _meta } }) as const; const callAt = (logLevel: string) => callWith({ "io.modelcontextprotocol/logLevel": logLevel }); test("no logLevel: nothing is sent", async ({ mcp }) => { const response = await mcp.raw.request(callWith({})); expect(response.result.structuredContent.sent).toEqual([]); }); test("a progressToken alone: nothing is sent", async ({ mcp }) => { const response = await mcp.raw.request(callWith({ progressToken: "progress-1" })); expect(response.result.structuredContent.sent).toEqual([]); }); test("\"info\": everything except debug", async ({ mcp }) => { const response = await mcp.raw.request(callAt("info")); expect(response.result.structuredContent.sent).toEqual(["info", "notice", "warning", "error", "critical", "alert", "emergency"]); }); test("\"warning\": warning and above", async ({ mcp }) => { const response = await mcp.raw.request(callAt("warning")); expect(response.result.structuredContent.sent).toEqual(["warning", "error", "critical", "alert", "emergency"]); }); test("\"error\": error and above", async ({ mcp }) => { const response = await mcp.raw.request(callAt("error")); expect(response.result.structuredContent.sent).toEqual(["error", "critical", "alert", "emergency"]); }); test("\"debug\": all eight", async ({ mcp }) => { const response = await mcp.raw.request(callAt("debug")); expect(response.result.structuredContent.sent).toHaveLength(8); }); ``` ### Testing log messages In the Playground, `mcp.notifications.collect().received` lists every notification the test client received, in order, with its `method` and `params`. ```ts bulk-close.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "bulk_close", description: "Close several tickets", inputSchema: { ids: z.array(z.string()) } }) export class BulkClose extends ToolContext { async execute({ ids }: { ids: string[] }) { await this.notify(`Closing ${ids.length} tickets`); for (const id of ids) await this.notify(`Closed ${id}`, "debug"); return { closed: ids }; } } ``` ```ts notify.test.ts import { test, expect } from "@frontmcp/testing"; test("logs a summary, then one debug line per ticket", async ({ mcp }) => { await mcp.tools.call("bulk_close", { ids: ["T-1", "T-2"] }); const logs = mcp.notifications.collect().received.filter((n) => n.method === "notifications/message"); expect(logs.map((n) => n.params)).toEqual([ { level: "info", logger: "bulk_close", data: { message: "Closing 2 tickets" } }, { level: "debug", logger: "bulk_close", data: { message: "Closed T-1" } }, { level: "debug", logger: "bulk_close", data: { message: "Closed T-2" } }, ]); }); ``` --- ## Troubleshooting ### `this.notify()` returns `false`, and the client shows nothing Either the request had no `io.modelcontextprotocol/logLevel`, or the message's level is below it. In the Playground, tick **Stream progress & logs** in the Call tab. In your own client, add the level to the request's `_meta`. ### `debug` messages don't appear in the Call tab The Call tab asks for `"info"` and up, like many clients. The tests ask for `"debug"`, so they receive everything. See [Filtering by level](https://frontmcp.dev/reference/sdk/notify#filtering-by-level). ### Error `-32601`: "logging/setLevel was removed in protocol 2026-07-28" A client sent `logging/setLevel`, which 2026-07-28 removed. Send the level with each request instead: ```ts ping.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "ping", description: "Log a message and reply", inputSchema: {} }) export class Ping extends ToolContext { async execute() { return { logged: await this.notify("pong") }; } } ``` ```ts set-level.test.ts import { test, expect } from "@frontmcp/testing"; test("logging/setLevel is gone", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "logging/setLevel", params: { level: "info" } }); expect(response).toHaveErrorCode(-32601); expect(response.error.message).toContain("removed in protocol 2026-07-28"); }); test("the level goes in each request instead", async ({ mcp }) => { const response = await mcp.raw.request({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "ping", arguments: {}, _meta: { "io.modelcontextprotocol/logLevel": "info" } }, }); expect(response.result.structuredContent).toEqual({ logged: true }); }); ``` ### My log lines appear in the server's output, not the client `this.logger` and `console.log` write to the server's own output. Use `this.notify()` for messages meant for the client. ### `collect().received` is empty under `frontmcp test` The test client doesn't ask for log messages by itself, and they can arrive after `tools.call()` returns. Send `logging/setLevel` before the call, and wait with `waitFor()` before you check. See [Checking notifications](https://frontmcp.dev/reference/testing#checking-notifications). --- # this.get > Get a provider, like a ticket store or your configuration, inside a tool, resource or prompt. Source: https://frontmcp.dev/reference/sdk/get [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `this.get()` hands a tool, resource or prompt the instance of a [provider](https://frontmcp.dev/reference/sdk/provider): a shared service like a ticket store, an API client or your configuration. You name the provider by its token, usually its class, and FrontMCP returns the instance for the current call. `this.tryGet()` does the same, but returns `undefined` when there's no such provider. ```ts const service = this.get(token) const maybe = this.tryGet(token) ``` --- ## Reference ### `this.get(token)` Call `this.get()` in a class that extends `ToolContext`, `ResourceContext` or `PromptContext`. The provider must be registered where that class can see it: see [where FrontMCP looks](https://frontmcp.dev/reference/sdk/get#where-frontmcp-looks). ```ts get-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @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 }) { const store = this.get(TicketStore); return store.find(id); } } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/get#usage) #### Parameters | Parameter | Type | Description | | --- | --- | --- | | `token` | `Token` | Which provider to get: a `@Provider` class, or the `provide` of a factory, which can be a class, an abstract class, a symbol or a string. | #### Returns The provider's instance, typed from the token: `this.get(TicketStore)` is a `TicketStore`, and `this.get(DeskConfig)` for an abstract class `DeskConfig` is a `DeskConfig`. A string or symbol token carries no type, so the result is `unknown` until you pass one: `this.get(SLA_HOURS)`. Which instance you get depends on the provider's scope, and on where it's registered: | Provider | What `this.get()` returns | | --- | --- | | `ProviderScope.GLOBAL` (the default), in `@App({ providers })` | The same instance on every call, in every tool, resource and prompt of that app. Another app that registers the same class gets an instance of its own. | | `ProviderScope.GLOBAL`, in `@FrontMcp({ providers })` | The same instance on every call, in every app. | | `ProviderScope.CONTEXT` | The same instance for the whole request, however often you call `this.get()`, and a new one for the next request. (Clients on MCP versions before 2026-07-28 keep one per session: see [scopes](https://frontmcp.dev/reference/sdk/provider#scopes).) | #### Throws `ProviderNotAvailableError`, with `code` `PROVIDER_NOT_AVAILABLE`, when no provider for `token` is registered where the class can see it. The message names the class, `Provider "TicketStore" is not available: not found in local or parent registries`, or says `Provider "[ref]"` for a string or symbol token. Unless you catch it, the error ends the request. What the client gets depends on where it happened: | In a | The client gets | | --- | --- | | Tool | An `isError` result with `_meta.code` `TOOL_EXECUTION_ERROR`. In development its text is `Tool "get_ticket" execution failed: Provider "TicketStore" is not available: …`, in production "Internal FrontMCP error" and an error ID. | | Resource | JSON-RPC error `-32603`. In development its message is `Resource "tickets://T-1" read failed: Provider "TicketStore" is not available: …`, in production "Internal FrontMCP error" and an error ID. | | Prompt | JSON-RPC error `-32603`. In development its message is `Prompt execution failed: Provider "TicketStore" is not available: …`, in production "Internal FrontMCP error" and an error ID. | #### `this.tryGet(token)` The same lookup, with the same parameter. It returns the instance, or `undefined` if there's no provider for `token`, and never throws. Each miss writes a warning to the server's log: `Failed to get provider …`. #### Where FrontMCP looks For each call, FrontMCP looks for the token in this order and returns the first match: 1. The request's own instances: the `CONTEXT` providers the class can see, and [`FRONTMCP_CONTEXT`](https://frontmcp.dev/reference/sdk/get#built-in-tokens). 2. The providers of the class's app, in `@App({ providers })`. 3. The server's providers, in `@FrontMcp({ providers })`. Nothing else. A provider registered in another app isn't found, and an app provider with the same token as a server provider [takes its place](https://frontmcp.dev/reference/sdk/get#one-instance-per-request-app-or-server) in that app. #### Built-in tokens | Token | What `this.get()` returns | | --- | --- | | `FRONTMCP_CONTEXT` | The current request's `FrontMcpContext`: its `requestId`, `traceContext`, `authInfo` and HTTP `metadata`. In tools, resources, prompts, agents and jobs, [`this.context`](https://frontmcp.dev/reference/sdk/context) is the same object. (Changed in 1.9.2: before, `PromptContext` had no `this.context`, so prompts [used the token](https://frontmcp.dev/reference/sdk/get#reading-the-request-in-a-prompt).) | #### Caveats - A provider must be **registered** in a `providers` array. Decorating the class with `@Provider` isn't enough. - The token is matched exactly. Another class with the same name, or `"sla_hours"` for `"SLA_HOURS"`, is a different token. Export tokens as constants, or use classes, so a typo fails to compile instead. - `this.tryGet()` hides every lookup failure, so a mistyped token looks the same as a provider that isn't registered. Use `this.get()` for anything the class can't work without. - FrontMCP builds a new tool, resource or prompt object for every call, after the providers are ready. So `this.get()` also works in a field initializer, like `private store = this.get(TicketStore)`, and a `CONTEXT` provider got that way still belongs to the current request. - Providers don't have `this.get()`. They receive other providers through constructor injection or a factory's `inject` (see [`@Provider`](https://frontmcp.dev/reference/sdk/provider#constructor-injection)). --- ## Usage ### Getting a provider in a tool Both tools get the same `TicketStore`, so a ticket `close_ticket` closes is closed for `get_ticket` too. Exported providers are registered in the example's app for you; in a project, list them in `@App({ providers })`. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-2" } } ``` ```ts tickets.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const store = this.get(TicketStore); store.close(id); return store.find(id); } } @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 }) { return this.get(TicketStore).find(id); } } ``` ```ts ticket-store.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "TicketStore" }) export class TicketStore { private tickets = new Map([ ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }], ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }], ]); find(id: string) { return this.tickets.get(id) ?? { id, title: "(no such ticket)", status: "unknown" }; } close(id: string) { const ticket = this.tickets.get(id); if (ticket) ticket.status = "closed"; } } ``` ```ts tickets.test.ts import { test, expect } from "@frontmcp/testing"; test("get_ticket sees what close_ticket did", async ({ mcp }) => { await mcp.tools.call("close_ticket", { id: "T-2" }); const result = await mcp.tools.call("get_ticket", { id: "T-2" }); expect(result.json()).toEqual({ id: "T-2", title: "Invoice total is wrong", status: "closed" }); }); ``` ### Getting a provider in a resource or a prompt `ResourceContext` and `PromptContext` have the same `this.get()`. ### Other entries ### Example: Resource Initial call configuration: ```js { resource: "tickets://T-1" } ``` ```ts ticket.resource.ts active import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json" }) export class Ticket extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = this.get(TicketStore).find(id); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] }; } } ``` ```ts ticket-store.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "TicketStore" }) export class TicketStore { find(id: string) { return { id, title: "Cannot log in", status: "open" }; } } ``` ### Example: Prompt Initial call configuration: ```js { prompt: "summarize_ticket", arguments: { id: "T-1" } } ``` ```ts summarize.prompt.ts active import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Prompt({ name: "summarize_ticket", description: "Summarize a ticket for a handover", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class SummarizeTicket extends PromptContext { async execute({ id }: Record): Promise { const ticket = this.get(TicketStore).find(id); const text = `Summarize ticket ${ticket.id}, "${ticket.title}" (${ticket.status}), for the next agent on shift.`; return { messages: [{ role: "user", content: { type: "text", text } }] }; } } ``` ```ts ticket-store.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "TicketStore" }) export class TicketStore { find(id: string) { return { id, title: "Cannot log in", status: "open" }; } } ``` ### One instance per request, app or server What `this.get()` returns depends on the provider's scope and on where it's registered. Each tab's test shows which instances are shared. ### Scope and registration ### Example: Per request A `CONTEXT` provider is shared by every `this.get()` in one request, and replaced for the next. `ProviderScope.GLOBAL` here would count 2, then 4. Initial call configuration: ```js { tool: "triage_ticket", arguments: { id: "T-1" } } ``` ```ts triage.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { StepCounter } from "./step-counter"; @Tool({ name: "triage_ticket", description: "Triage a support ticket", inputSchema: { id: z.string() } }) export class TriageTicket extends ToolContext { async execute({ id }: { id: string }) { this.get(StepCounter).count++; // checked the ticket this.get(StepCounter).count++; // set its priority return { id, steps: this.get(StepCounter).count }; } } ``` ```ts step-counter.ts import { Provider, ProviderScope } from "@frontmcp/sdk"; @Provider({ name: "StepCounter", scope: ProviderScope.CONTEXT }) export class StepCounter { count = 0; } ``` ```ts step-counter.test.ts import { test, expect } from "@frontmcp/testing"; test("each request starts with a new instance", async ({ mcp }) => { expect((await mcp.tools.call("triage_ticket", { id: "T-1" })).json()).toEqual({ id: "T-1", steps: 2 }); expect((await mcp.tools.call("triage_ticket", { id: "T-2" })).json()).toEqual({ id: "T-2", steps: 2 }); }); ``` ### Example: Per app Two apps register the same class, so each gets its own instance. The ticket the desk app closed is still open in the reports app. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts server.ts active import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { this.get(TicketStore).close(id); return { closed: id }; } } @Tool({ name: "count_open", description: "Count open tickets", inputSchema: {} }) export class CountOpen extends ToolContext { async execute() { return { open: this.get(TicketStore).openCount() }; } } @App({ id: "desk", name: "Desk", tools: [CloseTicket], providers: [TicketStore] }) class Desk {} @App({ id: "reports", name: "Reports", tools: [CountOpen], providers: [TicketStore] }) class Reports {} @FrontMcp({ info: { name: "Help desk", version: "1.0.0" }, apps: [Desk, Reports] }) export class Server {} ``` ```ts ticket-store.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "TicketStore" }) export class TicketStore { private open = new Set(["T-1", "T-2"]); close(id: string) { this.open.delete(id); } openCount() { return this.open.size; } } ``` ```ts apps.test.ts import { test, expect } from "@frontmcp/testing"; test("the reports app has its own TicketStore", async ({ mcp }) => { await mcp.tools.call("close_ticket", { id: "T-1" }); expect((await mcp.tools.call("count_open", {})).json()).toEqual({ open: 2 }); }); ``` ### Example: Per server Register the provider on the server instead, and every app gets the same instance. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts server.ts active import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { this.get(TicketStore).close(id); return { closed: id }; } } @Tool({ name: "count_open", description: "Count open tickets", inputSchema: {} }) export class CountOpen extends ToolContext { async execute() { return { open: this.get(TicketStore).openCount() }; } } @App({ id: "desk", name: "Desk", tools: [CloseTicket] }) class Desk {} @App({ id: "reports", name: "Reports", tools: [CountOpen] }) class Reports {} @FrontMcp({ info: { name: "Help desk", version: "1.0.0" }, apps: [Desk, Reports], providers: [TicketStore] }) export class Server {} ``` ```ts ticket-store.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "TicketStore" }) export class TicketStore { private open = new Set(["T-1", "T-2"]); close(id: string) { this.open.delete(id); } openCount() { return this.open.size; } } ``` ```ts apps.test.ts import { test, expect } from "@frontmcp/testing"; test("both apps share the server's TicketStore", async ({ mcp }) => { await mcp.tools.call("close_ticket", { id: "T-1" }); expect((await mcp.tools.call("count_open", {})).json()).toEqual({ open: 1 }); }); ``` ### Example: Overridden in one app An app provider with the same token as a server provider wins inside that app. Here VIP tickets get a shorter reply deadline than the server's default. Initial call configuration: ```js { tool: "vip_reply_due", arguments: {} } ``` ```ts server.ts active import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk"; export abstract class DeskConfig { abstract replyWithinHours: number; } @Tool({ name: "reply_due", description: "Hours we have to reply to a ticket", inputSchema: {} }) export class ReplyDue extends ToolContext { async execute() { return { hours: this.get(DeskConfig).replyWithinHours }; } } @Tool({ name: "vip_reply_due", description: "Hours we have to reply to a VIP ticket", inputSchema: {} }) export class VipReplyDue extends ToolContext { async execute() { return { hours: this.get(DeskConfig).replyWithinHours }; } } @App({ id: "desk", name: "Desk", tools: [ReplyDue] }) class Desk {} @App({ id: "vip", name: "VIP desk", tools: [VipReplyDue], providers: [{ provide: DeskConfig, name: "VipDeskConfig", inject: () => [], useFactory: () => ({ replyWithinHours: 4 }) }], }) class Vip {} @FrontMcp({ info: { name: "Help desk", version: "1.0.0" }, apps: [Desk, Vip], providers: [{ provide: DeskConfig, name: "DeskConfig", inject: () => [], useFactory: () => ({ replyWithinHours: 24 }) }], }) export class Server {} ``` ```ts override.test.ts import { test, expect } from "@frontmcp/testing"; test("the vip app gets its own DeskConfig", async ({ mcp }) => { expect((await mcp.tools.call("vip_reply_due", {})).json()).toEqual({ hours: 4 }); }); test("other apps get the server's", async ({ mcp }) => { expect((await mcp.tools.call("reply_due", {})).json()).toEqual({ hours: 24 }); }); ``` ### Using a provider only if it's registered `this.tryGet()` lets a tool work with or without a provider. Here a deployment can register an `SlaPolicy`; without one, every ticket gets the default of 24 hours. Add `SlaPolicy` to the app's `providers`, and a high-priority ticket is due in 4. Initial call configuration: ```js { tool: "reply_due", arguments: { priority: "high" } } ``` ```ts reply-due.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { SlaPolicy } from "./sla-policy"; @Tool({ name: "reply_due", description: "Hours we have to reply to a ticket of this priority", inputSchema: { priority: z.enum(["low", "normal", "high"]) }, }) export class ReplyDue extends ToolContext { async execute({ priority }: { priority: "low" | "normal" | "high" }) { const policy = this.tryGet(SlaPolicy); return { hours: policy?.hoursFor(priority) ?? 24, policy: policy ? "custom" : "default" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { ReplyDue } from "./reply-due.tool"; @App({ id: "help-desk", name: "Help desk", tools: [ReplyDue], providers: [] }) export class HelpDesk {} ``` ```ts sla-policy.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "SlaPolicy" }) export class SlaPolicy { hoursFor(priority: "low" | "normal" | "high") { return { low: 48, normal: 24, high: 4 }[priority]; } } ``` ```ts reply-due.test.ts import { test, expect } from "@frontmcp/testing"; test("without SlaPolicy, the default applies", async ({ mcp }) => { const result = await mcp.tools.call("reply_due", { priority: "high" }); expect(result.json()).toEqual({ hours: 24, policy: "default" }); }); ``` The Logs tab shows the warning `this.tryGet()` writes for the missing provider. ### Tokens that aren't classes A factory can register any value under an abstract class, a symbol or a string. An abstract class gives `this.get()` a type to return; a symbol or string doesn't, so pass the type yourself. ### Tokens ### Example: Abstract class Initial call configuration: ```js { tool: "reply_due", arguments: {} } ``` ```ts help-desk.app.ts active import { App, Tool, ToolContext } from "@frontmcp/sdk"; export abstract class DeskConfig { abstract replyWithinHours: number; } @Tool({ name: "reply_due", description: "Hours we have to reply to a ticket", inputSchema: {} }) export class ReplyDue extends ToolContext { async execute() { const config = this.get(DeskConfig); // typed as DeskConfig return { hours: config.replyWithinHours }; } } @App({ id: "help-desk", name: "Help desk", tools: [ReplyDue], providers: [{ provide: DeskConfig, name: "DeskConfig", inject: () => [], useFactory: () => ({ replyWithinHours: 24 }) }], }) export class HelpDesk {} ``` ### Example: Symbol Initial call configuration: ```js { tool: "reply_due", arguments: {} } ``` ```ts help-desk.app.ts active import { App, Tool, ToolContext } from "@frontmcp/sdk"; export const REPLY_WITHIN_HOURS = Symbol("REPLY_WITHIN_HOURS"); @Tool({ name: "reply_due", description: "Hours we have to reply to a ticket", inputSchema: {} }) export class ReplyDue extends ToolContext { async execute() { const hours = this.get(REPLY_WITHIN_HOURS); // unknown without return { hours }; } } @App({ id: "help-desk", name: "Help desk", tools: [ReplyDue], providers: [{ provide: REPLY_WITHIN_HOURS, name: "REPLY_WITHIN_HOURS", inject: () => [], useFactory: () => 24 }], }) export class HelpDesk {} ``` ### Reading the request in a prompt A prompt reads the request with `this.context`, as a tool does. `FRONTMCP_CONTEXT` is the token for the same object, so `this.get(FRONTMCP_CONTEXT)` returns it too: that's how prompts read it before 1.9.2, when `PromptContext` had no `this.context`, and that code still works. Here the handover note carries the request id, to find the request in the logs later. Initial call configuration: ```js { prompt: "handover_note", arguments: { id: "T-1" } } ``` ```ts handover.prompt.ts import { FRONTMCP_CONTEXT, Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "handover_note", description: "Write a handover note for a ticket", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class HandoverNote extends PromptContext { async execute({ id }: Record): Promise { const { requestId } = this.context; const sameRequest = this.get(FRONTMCP_CONTEXT) === this.context; const text = `Write a handover note for ticket ${id}. End it with "ref ${requestId}".`; return { description: `Same request object: ${sameRequest}`, messages: [{ role: "user", content: { type: "text", text } }] }; } } ``` ```ts handover.test.ts import { test, expect } from "@frontmcp/testing"; test("each request has its own id", async ({ mcp }) => { const text = (result: { messages: any[] }) => result.messages[0].content.text as string; const first = text(await mcp.prompts.get("handover_note", { id: "T-1" })); const second = text(await mcp.prompts.get("handover_note", { id: "T-1" })); expect(first).toMatch(/ref [0-9a-f-]{36}"\.$/); expect(first).not.toBe(second); }); test("the token and this.context are the same object", async ({ mcp }) => { expect((await mcp.prompts.get("handover_note", { id: "T-1" })).raw.description).toBe("Same request object: true"); }); ``` --- ## Troubleshooting ### `Provider "Directory" is not available: not found in local or parent registries` No provider with that token is registered where the tool, resource or prompt can see it. If it's registered somewhere, it's probably in **another app**: apps don't see each other's providers. Initial call configuration: ```js { tool: "whoami", arguments: {} } ``` ```ts server.ts active import { App, FrontMcp, Provider, Tool, ToolContext } from "@frontmcp/sdk"; @Provider({ name: "Directory" }) export class Directory { onDuty() { return { team: "EMEA support", lead: "Nour" }; } } @Tool({ name: "whoami", description: "Which support team is on duty", inputSchema: {} }) export class WhoAmI extends ToolContext { async execute() { return this.get(Directory).onDuty(); } } @App({ id: "desk", name: "Desk", tools: [WhoAmI] }) class Desk {} // 🚩 Directory is registered in the billing app, not in the desk app @App({ id: "billing", name: "Billing", providers: [Directory] }) class Billing {} @FrontMcp({ info: { name: "Help desk", version: "1.0.0" }, apps: [Desk, Billing] }) export class Server {} ``` Register it in the app that uses it, or in `@FrontMcp({ providers })` to share one instance with every app. If it isn't registered anywhere, see [the same error on `@Provider`](https://frontmcp.dev/reference/sdk/provider#provider-ticketstore-is-not-available-not-found-in-local-or-parent-registries). ### `Provider "[ref]" is not available` The token is a string or a symbol, so the message can't name it. Look for a typo, or for a symbol created twice: two `Symbol("SLA")` calls make two different tokens. Initial call configuration: ```js { tool: "reply_due", arguments: {} } ``` ```ts help-desk.app.ts active import { App, Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "reply_due", description: "Hours we have to reply to a ticket", inputSchema: {} }) export class ReplyDue extends ToolContext { async execute() { return { hours: this.get("REPLY_WITHIN_HOUR") }; // 🚩 missing the S } } @App({ id: "help-desk", name: "Help desk", tools: [ReplyDue], providers: [{ provide: "REPLY_WITHIN_HOURS", name: "REPLY_WITHIN_HOURS", inject: () => [], useFactory: () => 24 }], }) export class HelpDesk {} ``` Define the token once, export it, and import it everywhere, or use an abstract class, which also gives `this.get()` a type. ### `this.tryGet()` returns `undefined` for a provider I registered `this.tryGet()` returns `undefined` for any lookup that fails, so check the same things as for [the error above](https://frontmcp.dev/reference/sdk/get#provider-directory-is-not-available-not-found-in-local-or-parent-registries): that the provider is registered in this app or on the server, and that the token is the same value. The server log has the reason, in a warning that starts `Failed to get provider`. While you're debugging, switch to `this.get()` to get the error in the result. ### Two tools see different data from the same provider They're in different apps, and each app registered the provider, so each has its own instance. Register it once, in `@FrontMcp({ providers })`. See [Per app](https://frontmcp.dev/reference/sdk/get#one-instance-per-request-app-or-server). If the provider is `CONTEXT`-scoped, each request gets a new instance anyway: use `GLOBAL` for state that lasts. ### TypeScript says `Type 'unknown' is not assignable to type 'number'` The token is a string or a symbol, which carries no type. Pass the type, `this.get(REPLY_WITHIN_HOURS)`, or register the value under an abstract class instead. --- # this.fetch > Call other HTTP services from a tool or resource, with the request's tracing headers and a default timeout. Source: https://frontmcp.dev/reference/sdk/fetch [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `this.fetch()` is the web platform's `fetch()`, with two additions for [calling other services](https://frontmcp.dev/learn/calling-other-services) from inside a request: it sends the request's trace and id along, so the call can be followed across services, and it gives up after 30 seconds unless you choose another timeout. It doesn't send the caller's token or headers anywhere unless you list the service. Everything else, from the options to the `Response` you get back, is plain `fetch()`. ```ts const response = await this.fetch(input, init?) ``` --- ## Reference ### `this.fetch(input, init?)` Call `this.fetch()` inside the `execute()` of a tool or a resource. ```ts get-invoice.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_invoice", description: "Get a customer's invoice from the billing service", inputSchema: { id: z.string().describe("Invoice id, like INV-7") }, annotations: { readOnlyHint: true, openWorldHint: true }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { const response = await this.fetch(`https://billing.example/invoices/${id}`); if (!response.ok) this.fail(new PublicMcpError(`There's no invoice ${id}.`)); return await response.json(); } } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/fetch#usage) #### Parameters | Parameter | Type | Description | | --- | --- | --- | | `input` | `string \| URL \| Request` | What to fetch. Use an absolute URL. A `Request` works too, with its own headers, unless `init` has [`headers` of its own](https://frontmcp.dev/reference/sdk/fetch#the-service-says-401-though-the-request-has-an-authorization-header). | | `init` | `RequestInit` | Optional. The same options as `fetch()`: `method`, `headers`, `body`, `signal` and the rest. | Three options behave differently from plain `fetch()`: | Option | In `this.fetch()` | | --- | --- | | `headers` | Sent as you give them. FrontMCP [adds its own](https://frontmcp.dev/reference/sdk/fetch#what-it-adds) only where you haven't set a header of the same name. | | `signal` | Replaces FrontMCP's 30-second timeout. Without one, FrontMCP adds its own signal that aborts after 30 seconds, [`requestTimeout`](https://frontmcp.dev/reference/sdk/fetch#server-options) if you set one. | | `credentials` | A string (`"include"`, `"omit"`, `"same-origin"`) is passed on. The types also accept `{ provider: "…" }`, which adds the headers the server's auth-provider registry gives for that provider. FrontMCP doesn't register such a registry itself, so out of the box [the option is dropped, nothing is added, and a warning is logged](https://frontmcp.dev/reference/sdk/fetch#the-service-gets-no-credentials-from-credentialsprovider). | #### Returns A promise of a standard `Response`. Like `fetch()`, it resolves for every HTTP status, 404 and 500 included, so check `response.ok` before you use the body. #### What it adds | Header | Value | Sent | | --- | --- | --- | | `traceparent` | The request's [W3C trace context](https://www.w3.org/TR/trace-context/), `this.context.traceContext.raw`. | Unless `autoInjectTracingHeaders` is `false`. A `traceparent` header the client sent is passed on as it arrived; otherwise each request starts a new trace. With [tracing](https://frontmcp.dev/reference/server/observability#spans) on, the same trace with this request's own `GET` span as the parent, so the service you call records its work under it. (Changed in 1.9.3: before, tracing passed on the request's own.) | | `x-request-id` | `this.context.requestId`, new for every request. | Unless `autoInjectTracingHeaders` is `false`. | | `Authorization` | `Bearer`, then the token the caller authenticated with, `this.context.authInfo.token`. | Only to origins in [`forwardCallerTokenTo`](https://frontmcp.dev/reference/sdk/fetch#server-options), which is empty by default. | | `x-frontmcp-*` | Every header of the client's request whose name starts with `x-frontmcp-`. | Only to origins in `forwardCustomHeadersTo`, which is empty by default. | A header you set yourself is never replaced. While a request carries the caller's token or headers, or asks for `credentials: { provider }`, `this.fetch()` doesn't follow redirects (`redirect: "manual"`), so a redirect can't take them to an origin you didn't list; the 3xx response comes back to you instead. An anonymous caller has no token, so a listed origin gets no `Authorization` header from it. #### Server options `@FrontMcp({ fetch })` sets how `this.fetch()` behaves for the whole server: ```ts main.ts @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], fetch: { forwardCallerTokenTo: ["https://tickets-api.example.com"], requestTimeout: 10_000 }, }) export default class Server {} ``` | Option | Default | What it does | | --- | --- | --- | | `forwardCallerTokenTo` | `[]` | Origins that get `Authorization: Bearer` and the caller's token. Write each as a URL; only its origin counts (scheme, host and port), so `https://tickets-api.example.com/v2` covers every path on that host, and `https://tickets-api.example.com:8443` is another origin. | | `forwardCustomHeadersTo` | `[]` | Origins that get the request's `x-frontmcp-*` headers. | | `requestTimeout` | `30000` | Milliseconds before a request without a `signal` is aborted. | | `autoInjectTracingHeaders` | `true` | Whether to send `traceparent` and `x-request-id`. | An entry that isn't a URL, like `"tickets-api.example.com"`, stops the server from starting with `Invalid URL`. #### Errors `this.fetch()` rejects when `fetch()` would: | Error | When | | --- | --- | | `TypeError`: "fetch failed" in Node, "Failed to fetch" in browsers | The service couldn't be reached: the name didn't resolve, the connection was refused, TLS failed, or, in a browser, CORS blocked the response. | | `TypeError`: "Failed to parse URL from …" | In Node, the URL is relative or malformed. | | `DOMException` named `TimeoutError`: "The operation was aborted due to timeout" | FrontMCP's 30 seconds ran out. Your own `AbortSignal.timeout()` gives the same error name, with the runtime's own message. | | `DOMException` named `AbortError` | A signal you passed was aborted. | If you don't catch the error, the request fails. A tool call gets an `isError` result with `_meta.code` `TOOL_EXECUTION_ERROR`, reading `Tool "get_invoice" execution failed: fetch failed` in development and "Internal FrontMCP error" in production. A resource read fails with error `-32603`: `Resource "invoices://INV-7" read failed: fetch failed`, and "Internal FrontMCP error" in production. #### Caveats - `this.fetch()` is available in `ToolContext`, `ResourceContext` and `PromptContext`. Prompts have it since 1.9.2; before, a prompt called `this.get(FRONTMCP_CONTEXT).fetch()`, which still does the same thing. - It's still `fetch()`: no retries, no base URL, no JSON parsing, no caching. - Change the 30 seconds for the whole server with [`requestTimeout`](https://frontmcp.dev/reference/sdk/fetch#server-options), or for one request by passing a `signal`. A signal that never aborts, like a plain `AbortController`'s, means no timeout at all. - A `Request` keeps its headers and gets the default timeout. Pass `init.headers` with it, though, and those replace every header the `Request` had, as with `fetch()`. - **The caller's token stays on your server** unless you list a service in `forwardCallerTokenTo`. An anonymous caller has no token, and a listed origin gets no `Authorization` header for it (before 1.9.2 it got `Authorization: Bearer` with nothing after it). - [`create()`](https://frontmcp.dev/reference/sdk/create#keeping-the-servers-options) keeps the `fetch` option, as `createDirect()` does, so a call with an `authContext` token sends it to the origins in `forwardCallerTokenTo`: see [forwarding the caller's token](https://frontmcp.dev/reference/sdk/fetch#forwarding-the-callers-token-to-your-own-service). (Changed in 1.9.3: before, `create()` dropped `fetch`.) - The request is made by the server's runtime. In Node, CORS doesn't apply. In the Playground, which runs in your browser, a real service must allow CORS. --- ## Usage ### Calling an HTTP API Check `response.ok` and fail with a message the model can act on, then return the body. Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-7" } } ``` ```ts get-invoice.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_invoice", description: "Get a customer's invoice from the billing service", inputSchema: { id: z.string().describe("Invoice id, like INV-7") }, annotations: { readOnlyHint: true, openWorldHint: true }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { const response = await this.fetch(`https://billing.example/invoices/${id}`); if (response.status === 404) this.fail(new PublicMcpError(`There's no invoice ${id}. Invoice ids look like INV-7.`)); if (!response.ok) this.fail(new PublicMcpError(`The billing service answered HTTP ${response.status}. Try again later.`)); return await response.json(); } } ``` ```ts billing-api.ts // Stands in for the billing service at https://billing.example, so this example runs // without a network. It replaces the global fetch() for that host only, and this.fetch() // ends in the global fetch(). In a project, you don't need it. const invoices: Record = { "INV-7": { id: "INV-7", customer: "Acme", total: 120, status: "paid" }, }; const passThrough = globalThis.fetch; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const url = input instanceof Request ? input.url : String(input); if (!url.startsWith("https://billing.example/")) return passThrough(input, init); const request = new Request(input, init); const { pathname } = new URL(request.url); if (request.method === "GET" && pathname.startsWith("/invoices/")) { const invoice = invoices[pathname.slice("/invoices/".length)]; return invoice ? Response.json(invoice) : Response.json({ error: "no such invoice" }, { status: 404 }); } if (request.method === "POST" && pathname === "/refunds") { if (request.headers.get("authorization") !== "Bearer sk_test_desk") { return Response.json({ error: "bad API key" }, { status: 401 }); } const { invoice, amount } = await request.json(); return Response.json({ id: "RF-1", invoice, amount }, { status: 201 }); } return Response.json({ error: "not found" }, { status: 404 }); }; ``` ```ts get-invoice.test.ts import { test, expect } from "@frontmcp/testing"; test("returns the invoice", async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id: "INV-7" }); expect(result.json()).toEqual({ id: "INV-7", customer: "Acme", total: 120, status: "paid" }); }); test("a 404 becomes an error the model can read", async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id: "INV-404" }); expect(result).toBeError(); expect(result.text()).toBe("There's no invoice INV-404. Invoice ids look like INV-7."); }); ``` The **billing-api.ts** tab stands in for the billing service, so the example runs without a network. It replaces the global `fetch()` for `https://billing.example` only, and `this.fetch()` ends in the global `fetch()`, so the tool's request lands there. ### Seeing what `this.fetch()` adds This tool calls the same service twice, once with the global `fetch()` and once with `this.fetch()`, and the service answers with the headers it got. Only `this.fetch()` sends the request's trace and id, and a signal for the timeout. There's no `Authorization`: the server doesn't list any service in its `fetch` options. Initial call configuration: ```js { tool: "compare_fetch", arguments: {} } ``` ```ts compare-fetch.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "compare_fetch", description: "Show what this.fetch() adds to a request", inputSchema: {} }) export class CompareFetch extends ToolContext { async execute() { const plain = await (await fetch("https://echo.example/")).json(); const withContext = await (await this.fetch("https://echo.example/")).json(); return { requestId: this.context.requestId, traceparent: this.context.traceContext.raw, plain, withContext, }; } } ``` ```ts echo-service.ts // Stands in for a service at https://echo.example that answers with the headers it // received, and whether the request came with an AbortSignal. const passThrough = globalThis.fetch; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const url = input instanceof Request ? input.url : String(input); if (!url.startsWith("https://echo.example/")) return passThrough(input, init); const headers: Record = {}; new Request(input, init).headers.forEach((value, name) => (headers[name] = value)); return Response.json({ headers, signal: init?.signal instanceof AbortSignal }); }; ``` ```ts compare-fetch.test.ts import { test, expect } from "@frontmcp/testing"; test("this.fetch() sends the request's trace and id, with a signal", async ({ mcp }) => { const { requestId, traceparent, withContext } = (await mcp.tools.call("compare_fetch", {})).json(); expect(withContext).toEqual({ headers: { traceparent, "x-request-id": requestId }, signal: true }); expect(traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/); }); test("the global fetch() sends neither", async ({ mcp }) => { const { plain } = (await mcp.tools.call("compare_fetch", {})).json(); expect(plain).toEqual({ headers: {}, signal: false }); }); ``` ### Sending JSON with your own credentials Pass `method`, `headers` and `body` as you would to `fetch()`. A service's API key is your server's secret, not the caller's, so keep it in a provider and set `Authorization` yourself. FrontMCP keeps your header as it is. Initial call configuration: ```js { tool: "refund_invoice", arguments: { invoice: "INV-7", amount: 20 } } ``` ```ts refund-invoice.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { BillingConfig } from "./billing-config"; @Tool({ name: "refund_invoice", description: "Refund part or all of a customer's invoice", inputSchema: { invoice: z.string().describe("Invoice id, like INV-7"), amount: z.number().positive() }, annotations: { destructiveHint: true, openWorldHint: true }, }) export class RefundInvoice extends ToolContext { async execute({ invoice, amount }: { invoice: string; amount: number }) { const billing = this.get(BillingConfig); const response = await this.fetch(`${billing.baseUrl}/refunds`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${billing.apiKey}` }, body: JSON.stringify({ invoice, amount }), }); if (!response.ok) this.fail(new PublicMcpError(`The billing service refused the refund (HTTP ${response.status}).`)); return await response.json(); } } ``` ```ts billing-config.ts import { Provider } from "@frontmcp/sdk"; @Provider({ name: "BillingConfig" }) export class BillingConfig { baseUrl = "https://billing.example"; apiKey = "sk_test_desk"; // in a project, read it from the environment } ``` ```ts billing-api.ts // Stands in for the billing service at https://billing.example, so this example runs // without a network. It replaces the global fetch() for that host only, and this.fetch() // ends in the global fetch(). In a project, you don't need it. const invoices: Record = { "INV-7": { id: "INV-7", customer: "Acme", total: 120, status: "paid" }, }; const passThrough = globalThis.fetch; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const url = input instanceof Request ? input.url : String(input); if (!url.startsWith("https://billing.example/")) return passThrough(input, init); const request = new Request(input, init); const { pathname } = new URL(request.url); if (request.method === "GET" && pathname.startsWith("/invoices/")) { const invoice = invoices[pathname.slice("/invoices/".length)]; return invoice ? Response.json(invoice) : Response.json({ error: "no such invoice" }, { status: 404 }); } if (request.method === "POST" && pathname === "/refunds") { if (request.headers.get("authorization") !== "Bearer sk_test_desk") { return Response.json({ error: "bad API key" }, { status: 401 }); } const { invoice, amount } = await request.json(); return Response.json({ id: "RF-1", invoice, amount }, { status: 201 }); } return Response.json({ error: "not found" }, { status: 404 }); }; ``` ```ts refund-invoice.test.ts import { test, expect } from "@frontmcp/testing"; test("the refund is created with the server's API key", async ({ mcp }) => { const result = await mcp.tools.call("refund_invoice", { invoice: "INV-7", amount: 20 }); expect(result.json()).toEqual({ id: "RF-1", invoice: "INV-7", amount: 20 }); }); ``` Change `apiKey` in **billing-config.ts** and call the tool again: the service answers 401, and the tool fails with a message. ### Forwarding the caller's token to your own service The caller's token was issued for your MCP server. Send it on only to a service of your own that accepts the same tokens, and list that service's origin in `forwardCallerTokenTo`. Every other host still gets nothing. ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { CheckUpstreams } from "./check-upstreams.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [CheckUpstreams] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], // Your own tickets API trusts the same identity provider as this server. fetch: { forwardCallerTokenTo: ["https://tickets-api.example"] }, }; @FrontMcp(config) export default class Server {} ``` ```ts check-upstreams.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "check_upstreams", description: "Show which services receive the caller's token", inputSchema: {} }) export class CheckUpstreams extends ToolContext { async execute() { const ownApi = await (await this.fetch("https://tickets-api.example/me")).json(); const thirdParty = await (await this.fetch("https://echo.example/")).json(); return { ownApi: ownApi.headers.authorization ?? null, thirdParty: thirdParty.headers.authorization ?? null }; } } ``` ```ts echo-service.ts // Stands in for two services, https://tickets-api.example and https://echo.example, // that answer with the headers they received. const passThrough = globalThis.fetch; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const url = input instanceof Request ? input.url : String(input); if (!/^https:\/\/(tickets-api|echo)\.example\//.test(url)) return passThrough(input, init); const headers: Record = {}; new Request(input, init).headers.forEach((value, name) => (headers[name] = value)); return Response.json({ headers }); }; ``` ```ts forward.test.ts import { test, expect } from "@frontmcp/testing"; import { create, FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; import { CheckUpstreams } from "./check-upstreams.tool"; test("only the listed origin gets the caller's token", async () => { // The Playground has no sign-in, so this runs the server in-process as a signed-in user. const server = await FrontMcpInstance.createDirect(config); const result = await server.callTool("check_upstreams", {}, { authContext: { user: { sub: "nour" }, token: "mcp-token-for-nour" } }); await server.dispose(); expect(result.structuredContent).toEqual({ ownApi: "Bearer mcp-token-for-nour", thirdParty: null }); }); test("an anonymous caller sends no token, even to the listed origin", async ({ mcp }) => { const result = await mcp.tools.call("check_upstreams", {}); expect(result.json()).toEqual({ ownApi: null, thirdParty: null }); }); test("create() takes the fetch option too", async () => { const server = await create({ info: config.info, tools: [CheckUpstreams], fetch: config.fetch }); const result = await server.callTool("check_upstreams", {}, { authContext: { user: { sub: "nour" }, token: "mcp-token-for-nour" } }); await server.dispose(); expect(result.structuredContent).toEqual({ ownApi: "Bearer mcp-token-for-nour", thirdParty: null }); }); ``` Only list a service that is meant to accept these tokens, such as one whose audience your identity provider includes. A third-party API never is: give it its own credentials, [as above](https://frontmcp.dev/reference/sdk/fetch#sending-json-with-your-own-credentials). ### Setting a timeout Without a `signal`, `this.fetch()` waits up to 30 seconds, which is a long time for a model to wait. Pass `AbortSignal.timeout()` to give up sooner, and turn the `TimeoutError` into a message the model can act on. The reports service here takes 3 seconds to answer. Initial call configuration: ```js { tool: "weekly_report", arguments: {} } ``` ```ts weekly-report.tool.ts active import { PublicMcpError, Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "weekly_report", description: "How many tickets were opened and closed this week", inputSchema: {} }) export class WeeklyReport extends ToolContext { async execute() { let response: Response; try { response = await this.fetch("https://reports.example/weekly", { signal: AbortSignal.timeout(500) }); } catch (e) { if (e instanceof DOMException && e.name === "TimeoutError") { this.fail(new PublicMcpError("The reports service didn't answer within half a second. Try again later.")); } throw e; } return await response.json(); } } ``` ```ts reports-service.ts // Stands in for a slow reporting service at https://reports.example: it takes 3 seconds // to answer, and stops when the request's signal aborts, as a real fetch() does. const passThrough = globalThis.fetch; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const url = input instanceof Request ? input.url : String(input); if (!url.startsWith("https://reports.example/")) return passThrough(input, init); const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined); await new Promise((resolve, reject) => { const timer = setTimeout(resolve, 3000); signal?.addEventListener("abort", () => { clearTimeout(timer); reject(signal.reason); }); }); return Response.json({ week: 38, opened: 41, closed: 37 }); }; ``` ```ts weekly-report.test.ts import { test, expect } from "@frontmcp/testing"; test("gives up after half a second", async ({ mcp }) => { const started = Date.now(); const result = await mcp.tools.call("weekly_report", {}); expect(result).toBeError(); expect(result.text()).toBe("The reports service didn't answer within half a second. Try again later."); expect(Date.now() - started).toBeLessThan(2000); }); ``` Change the timeout to `5000` and the report comes back after 3 seconds. > **Pitfall: Don't call this.fail() inside the try** > > `this.fail()` ends the call by throwing, so a `catch` around it catches it too. Fail in the `catch` block, or after the `try`, as above. See [`this.fail`](https://frontmcp.dev/reference/sdk/fail#a-tool-call-succeeds-after-thisfail). ### When the service can't be reached A service that's down or unreachable makes `this.fetch()` reject with a `TypeError`. Uncaught, the model gets "Internal FrontMCP error" in production, which tells it nothing, so catch it and say what happened. `billing.invalid` never resolves: the `.invalid` domain is reserved for names that don't exist. Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-7" } } ``` ```ts get-invoice.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_invoice", description: "Get a customer's invoice from the billing service", inputSchema: { id: z.string().describe("Invoice id, like INV-7") }, }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { let response: Response; try { response = await this.fetch(`https://billing.invalid/invoices/${id}`); } catch (e) { if (e instanceof TypeError) { this.fail(new PublicMcpError("The billing service can't be reached right now. Try again in a few minutes.")); } throw e; } if (!response.ok) this.fail(new PublicMcpError(`There's no invoice ${id}.`)); return await response.json(); } } ``` ```ts get-invoice.test.ts import { test, expect } from "@frontmcp/testing"; test("an unreachable service becomes a readable error", async ({ mcp }) => { const result = await mcp.tools.call("get_invoice", { id: "INV-7" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("PUBLIC_ERROR"); expect(result.text()).toBe("The billing service can't be reached right now. Try again in a few minutes."); }); ``` ### Fetching from a resource or a prompt Resources and prompts have `this.fetch()` too, with the same headers and timeout. A `PublicMcpError` reaches the client with its message from both, whether you throw it or pass it to `this.fail()` (see [`this.fail`](https://frontmcp.dev/reference/sdk/fail#in-resources-and-prompts)). ### Other entries ### Example: Resource Initial call configuration: ```js { resource: "invoices://INV-7" } ``` ```ts invoice.resource.ts active import { PublicMcpError, ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; @ResourceTemplate({ name: "invoice", uriTemplate: "invoices://{id}", mimeType: "application/json" }) export class Invoice extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const response = await this.fetch(`https://billing.example/invoices/${id}`); if (!response.ok) throw new PublicMcpError(`There's no invoice ${id}.`); return { contents: [{ uri, mimeType: "application/json", text: await response.text() }] }; } } ``` ```ts billing-api.ts // Stands in for the billing service at https://billing.example, so this example runs // without a network. It replaces the global fetch() for that host only, and this.fetch() // ends in the global fetch(). In a project, you don't need it. const invoices: Record = { "INV-7": { id: "INV-7", customer: "Acme", total: 120, status: "paid" }, }; const passThrough = globalThis.fetch; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const url = input instanceof Request ? input.url : String(input); if (!url.startsWith("https://billing.example/")) return passThrough(input, init); const request = new Request(input, init); const { pathname } = new URL(request.url); if (request.method === "GET" && pathname.startsWith("/invoices/")) { const invoice = invoices[pathname.slice("/invoices/".length)]; return invoice ? Response.json(invoice) : Response.json({ error: "no such invoice" }, { status: 404 }); } if (request.method === "POST" && pathname === "/refunds") { if (request.headers.get("authorization") !== "Bearer sk_test_desk") { return Response.json({ error: "bad API key" }, { status: 401 }); } const { invoice, amount } = await request.json(); return Response.json({ id: "RF-1", invoice, amount }, { status: 201 }); } return Response.json({ error: "not found" }, { status: 404 }); }; ``` ### Example: Prompt Initial call configuration: ```js { prompt: "explain_invoice", arguments: { id: "INV-7" } } ``` ```ts explain-invoice.prompt.ts active import { Prompt, PromptContext, PublicMcpError, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "explain_invoice", description: "Explain a customer's invoice in plain words", arguments: [{ name: "id", description: "Invoice id, like INV-7", required: true }], }) export class ExplainInvoice extends PromptContext { async execute({ id }: Record): Promise { const response = await this.fetch(`https://billing.example/invoices/${id}`); if (!response.ok) throw new PublicMcpError(`There's no invoice ${id}.`); const invoice = await response.json(); const text = `Explain this invoice to the customer in two sentences:\n${JSON.stringify(invoice)}`; return { messages: [{ role: "user", content: { type: "text", text } }] }; } } ``` ```ts billing-api.ts // Stands in for the billing service at https://billing.example, so this example runs // without a network. It replaces the global fetch() for that host only, and this.fetch() // ends in the global fetch(). In a project, you don't need it. const invoices: Record = { "INV-7": { id: "INV-7", customer: "Acme", total: 120, status: "paid" }, }; const passThrough = globalThis.fetch; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const url = input instanceof Request ? input.url : String(input); if (!url.startsWith("https://billing.example/")) return passThrough(input, init); const request = new Request(input, init); const { pathname } = new URL(request.url); if (request.method === "GET" && pathname.startsWith("/invoices/")) { const invoice = invoices[pathname.slice("/invoices/".length)]; return invoice ? Response.json(invoice) : Response.json({ error: "no such invoice" }, { status: 404 }); } if (request.method === "POST" && pathname === "/refunds") { if (request.headers.get("authorization") !== "Bearer sk_test_desk") { return Response.json({ error: "bad API key" }, { status: 401 }); } const { invoice, amount } = await request.json(); return Response.json({ id: "RF-1", invoice, amount }, { status: 201 }); } return Response.json({ error: "not found" }, { status: 404 }); }; ``` --- ## Troubleshooting ### `Tool "get_invoice" execution failed: fetch failed` `this.fetch()` rejected and nothing caught it. "fetch failed" is Node's message for a service it couldn't reach; browsers, like the Playground, say "Failed to fetch". In production the model gets "Internal FrontMCP error" instead. Check the URL and that the service is up, then [catch the error](https://frontmcp.dev/reference/sdk/fetch#when-the-service-cant-be-reached) and fail with a message of your own. Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-7" } } ``` ```ts get-invoice.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_invoice", description: "Get a customer's invoice", inputSchema: { id: z.string() } }) export class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { const response = await this.fetch(`https://billing.invalid/invoices/${id}`); // 🚩 nothing catches a failure return await response.json(); } } ``` ### `The operation was aborted due to timeout` The service didn't answer within FrontMCP's default of 30 seconds, so the call was aborted: a tool call fails with `Tool "…" execution failed: The operation was aborted due to timeout`. Pass a `signal` with the time you want, and [turn the timeout into a message](https://frontmcp.dev/reference/sdk/fetch#setting-a-timeout). ### `Failed to parse URL from /invoices/INV-7` The URL is relative. In Node there's no page to resolve it against, so `fetch()` rejects. Build absolute URLs from a base URL in your configuration, as `refund_invoice` does [with a provider](https://frontmcp.dev/reference/sdk/fetch#sending-json-with-your-own-credentials). (In a browser, a relative URL resolves against the page, so the same code fetches from the wrong server instead of failing.) ### The service says 401, though the `Request` has an `Authorization` header `this.fetch()` was given a `Request` object and `init.headers` next to it. As with `fetch()`, headers in `init` replace every header the `Request` had, the API key included: Initial call configuration: ```js { tool: "refund_invoice", arguments: { invoice: "INV-7", amount: 20 } } ``` ```ts refund-invoice.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "refund_invoice", description: "Refund part or all of a customer's invoice", inputSchema: { invoice: z.string(), amount: z.number().positive() }, }) export class RefundInvoice extends ToolContext { async execute({ invoice, amount }: { invoice: string; amount: number }) { const request = new Request("https://billing.example/refunds", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer sk_test_desk" }, body: JSON.stringify({ invoice, amount }), }); const response = await this.fetch(request, { headers: { "idempotency-key": `refund-${invoice}-${amount}` } }); // 🚩 replaces the headers above if (!response.ok) this.fail(new PublicMcpError(`The billing service refused the refund (HTTP ${response.status}).`)); return await response.json(); } } ``` ```ts billing-api.ts // Stands in for the billing service at https://billing.example, so this example runs // without a network. It replaces the global fetch() for that host only, and this.fetch() // ends in the global fetch(). In a project, you don't need it. const invoices: Record = { "INV-7": { id: "INV-7", customer: "Acme", total: 120, status: "paid" }, }; const passThrough = globalThis.fetch; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const url = input instanceof Request ? input.url : String(input); if (!url.startsWith("https://billing.example/")) return passThrough(input, init); const request = new Request(input, init); const { pathname } = new URL(request.url); if (request.method === "GET" && pathname.startsWith("/invoices/")) { const invoice = invoices[pathname.slice("/invoices/".length)]; return invoice ? Response.json(invoice) : Response.json({ error: "no such invoice" }, { status: 404 }); } if (request.method === "POST" && pathname === "/refunds") { if (request.headers.get("authorization") !== "Bearer sk_test_desk") { return Response.json({ error: "bad API key" }, { status: 401 }); } const { invoice, amount } = await request.json(); return Response.json({ id: "RF-1", invoice, amount }, { status: 201 }); } return Response.json({ error: "not found" }, { status: 404 }); }; ``` Put every header in one place: ```ts // 🚩 The Request's headers are replaced await this.fetch(new Request(url, { method: "POST", headers, body }), { headers: { "idempotency-key": key } }); // ✅ Your headers are sent, with FrontMCP's added await this.fetch(url, { method: "POST", headers: { ...headers, "idempotency-key": key }, body }); ``` ### The service gets no credentials from `credentials.provider` The types accept `credentials: { provider: "billing" }`, and `this.fetch()` asks the server's auth-provider registry for that provider's headers. FrontMCP doesn't register one itself, and `@frontmcp/sdk` doesn't export the token to register one under, so the option is removed, nothing is added in its place, and the server logs a warning (look in the **Logs** tab): ```text fetch(): credentials.provider was given, but no auth providers are configured; sent without them ``` The request still doesn't follow redirects, as with any credential: Initial call configuration: ```js { tool: "ping_billing", arguments: {} } ``` ```ts ping-billing.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "ping_billing", description: "Check what the billing service receives", inputSchema: {} }) export class PingBilling extends ToolContext { async execute() { const response = await this.fetch("https://echo.example/", { credentials: { provider: "billing" } }); return await response.json(); } } ``` ```ts echo-service.ts // Stands in for a service at https://echo.example that answers with the headers it // received, and how the request asked for redirects to be handled. const passThrough = globalThis.fetch; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const url = input instanceof Request ? input.url : String(input); if (!url.startsWith("https://echo.example/")) return passThrough(input, init); const headers: Record = {}; new Request(input, init).headers.forEach((value, name) => (headers[name] = value)); return Response.json({ headers, redirect: init?.redirect ?? "follow" }); }; ``` ```ts ping-billing.test.ts import { test, expect } from "@frontmcp/testing"; test("no credential is added", async ({ mcp }) => { const { headers } = (await mcp.tools.call("ping_billing", {})).json(); expect(Object.keys(headers).sort()).toEqual(["traceparent", "x-request-id"]); }); test("redirects aren't followed", async ({ mcp }) => { expect((await mcp.tools.call("ping_billing", {})).json().redirect).toBe("manual"); }); ``` Set the header yourself from a provider, as in [Sending JSON with your own credentials](https://frontmcp.dev/reference/sdk/fetch#sending-json-with-your-own-credentials). ### The service gets no `Authorization` header, though the caller signed in `this.fetch()` doesn't send the caller's token to a service unless its origin is in [`forwardCallerTokenTo`](https://frontmcp.dev/reference/sdk/fetch#server-options). Send the service's own credentials, as in [Sending JSON with your own credentials](https://frontmcp.dev/reference/sdk/fetch#sending-json-with-your-own-credentials), or, for a service of your own that accepts the same tokens, [list it](https://frontmcp.dev/reference/sdk/fetch#forwarding-the-callers-token-to-your-own-service). --- # this.fail > End a tool call with an error the model can read, and choose the code it carries. Source: https://frontmcp.dev/reference/sdk/fail [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `this.fail()` ends a tool call with an error result instead of a return value. The model reads that result like any other, so a clear message lets it recover: try another id, ask the user, or stop. Pass a `PublicMcpError`, and its message reaches the model in development and in production alike. ```ts this.fail(error) ``` --- ## Reference ### `this.fail(error)` Call `this.fail()` inside a tool's `execute()`. Nothing after it runs. ```ts close-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = this.get(TicketStore).find(id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); if (ticket.status === "closed") this.fail(new PublicMcpError(`Ticket ${id} is already closed.`, "TICKET_CLOSED")); return this.get(TicketStore).close(id); } } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/fail#usage) #### Parameters | Parameter | Type | Description | | --- | --- | --- | | `error` | `Error` | What went wrong. Use a `PublicMcpError`, or one of [its subclasses](https://frontmcp.dev/reference/sdk/fail#error-classes), for a message the model should read. Any other error is [hidden in production](https://frontmcp.dev/reference/sdk/fail#what-the-client-receives). | #### Returns `never`. `this.fail()` throws to end `execute()`, and TypeScript knows it: after `if (!ticket) this.fail(…)`, `ticket` is no longer `undefined`. #### What the client receives In a tool, `this.fail()` produces a normal `tools/call` result, with `isError: true`: ```json { "content": [{ "type": "text", "text": "There's no ticket T-9." }], "isError": true, "_meta": { "errorId": "err_4418a34f3368d9dc", "code": "PUBLIC_ERROR", "timestamp": "2026-09-24T09:12:03.512Z", "stack": "PublicMcpError: There's no ticket T-9.\n at GetTicket.execute (…)" } } ``` `errorId` is new for every error, and `stack` is only sent in development. (Under MCP 2026-07-28, `_meta` also carries the server's `io.modelcontextprotocol/serverInfo`.) The text and the code depend on what you pass: | You pass | `_meta.code` | Text in development | Text in production | | --- | --- | --- | --- | | `new PublicMcpError(message)` | `PUBLIC_ERROR` | `message` | `message` | | `new PublicMcpError(message, code)` | `code` | `message` | `message` | | Another [public error class](https://frontmcp.dev/reference/sdk/fail#error-classes) | its code | its message | its message | | `new InternalMcpError(message, code?)` | `INTERNAL_ERROR`, or `code` | `message` | `Internal FrontMCP error. Please contact support with error ID: err_…` | | Any other error, like `new Error(message)` | `SERVER_ERROR` | `message`, then `Original error:` and the stack | `Internal FrontMCP error. Please contact support with error ID: err_…` | Development means `NODE_ENV` isn't `production`. The Playground always runs in development; [see what production sends](https://frontmcp.dev/reference/sdk/fail#what-production-hides). #### Error classes These are exported from `@frontmcp/sdk`. All but `InternalMcpError` are public: their message reaches the model in production. They're the ones you'll use with `this.fail()`; [Error classes](https://frontmcp.dev/reference/sdk/error-classes) lists every class FrontMCP exports, and what each one looks like to a client. | Class | `_meta.code` | Text | | --- | --- | --- | | `PublicMcpError(message, code?, statusCode?)` | `PUBLIC_ERROR`, or `code` | `message`. `statusCode` (default `400`) isn't part of a tool result. | | `InvalidInputError(message?, details?)` | `INVALID_INPUT` | `message` (default "Invalid input: validation failed"), then `Details:` and `details` as JSON, if given. It's the code FrontMCP uses when arguments don't match `inputSchema`. | | `RateLimitError(retryAfter?)` | `RATE_LIMIT_EXCEEDED` | "Rate limit exceeded", then ". Retry after 60 seconds" when `retryAfter` is `60`. | | `QuotaExceededError(quotaType?)` | `QUOTA_EXCEEDED` | "usage quota exceeded", with `quotaType` in place of "usage". | | `UnauthorizedError(message?)` | `UNAUTHORIZED` | `message`, default "Unauthorized". | | `ResourceNotFoundError(uri)` | `RESOURCE_NOT_FOUND` | "Resource not found: " and the URI. | | `InternalMcpError(message, code?)` | `INTERNAL_ERROR`, or `code` | `message` in development only. | Every one of them has an `errorId` and a `code`, and is an `instanceof McpError`. #### In resources and prompts `ResourceContext` and `PromptContext` have `this.fail()` too, and it works the same way: a public error keeps its message and code, and any other error is hidden in production. What changes is the shape. `resources/read` and `prompts/get` answer with a JSON-RPC error instead of a result, and the code that a tool puts in `_meta.code` is in `error.data.code`: ```json { "code": -32602, "message": "There's no ticket T-9.", "data": { "errorId": "err_fe0e63b17b92dc8f", "code": "PUBLIC_ERROR" } } ``` | You pass | `code` | `message` | `data.code` | | --- | --- | --- | --- | | `new PublicMcpError(message, code?)` | `-32602` | `message` | `PUBLIC_ERROR`, or `code` | | `new UnauthorizedError(message?)` | `-32001` | `message` | `UNAUTHORIZED` | | Any other error, like `new Error(message)` | `-32603` | `message` in development, `Internal FrontMCP error. Please contact support with error ID: err_…` in production | `SERVER_ERROR` | Throwing a public error gives the same result. A thrown error that isn't public is wrapped: its message follows `Resource "tickets://T-9" read failed: ` or `Prompt execution failed: `, and `data.code` is `RESOURCE_READ_ERROR` or `PROMPT_EXECUTION_FAILED`. #### Caveats - **`this.fail()` works by throwing.** A `try`/`catch` around it catches it, as a `FlowControl`, and the call carries on. See [A tool call succeeds after `this.fail()`](https://frontmcp.dev/reference/sdk/fail#a-tool-call-succeeds-after-thisfail). - **Throwing works too.** A `PublicMcpError` thrown from `execute()`, or from a provider it calls, reaches the client as if you had passed it to `this.fail()`. Only errors that aren't public come out differently: `TOOL_EXECUTION_ERROR` when thrown, `SERVER_ERROR` through `this.fail()`. See [Failing vs throwing](https://frontmcp.dev/reference/sdk/fail#failing-vs-throwing). - **Every failure is in the [server log](https://frontmcp.dev/reference/server/logging#finding-the-line-for-an-error-id), with its error ID.** A public error is logged as a warning with the tool's name, the error ID and the code; any other error as an error, with its original message and stack. So an ID a user reports from production can be looked up. - `this.fail()` is `protected`: call it from inside the tool class. Code elsewhere, like a provider, [throws a `PublicMcpError` instead](https://frontmcp.dev/reference/sdk/fail#errors-from-providers-and-other-code). - In a resource or a prompt, [`this.respond()`](https://frontmcp.dev/reference/sdk/respond#ending-early-everywhere-else) ends the call with a result, as `this.fail()` ends it with an error: the value is sent as a returned one would be, an object as JSON text and a string as text. (Changed in 1.9.3: before, it failed with `Prompt output not found`, or `Resource output not found`.) An `execute()` that returns nothing still fails that way. --- ## Usage ### Failing with a message the model can read Say what went wrong and what to do instead. The call ends there, and the model gets your message as the result. Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-9" } } ``` ```ts get-ticket.tool.ts active 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 id", inputSchema: { id: z.string().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; } } ``` ```ts get-ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("an unknown id fails with a public error", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-9" }); expect(result).toBeError(); expect(result.raw._meta?.code).toBe("PUBLIC_ERROR"); expect(result.text()).toBe("There's no ticket T-9. Search by title with search_tickets."); }); test("a known id returns the ticket", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result.json()).toEqual({ id: "T-1", title: "Cannot log in", status: "open" }); }); ``` ### Choosing an error code `_meta.code` lets a client, or your tests, tell failures apart without parsing the text. Give `PublicMcpError` a code of your own, or use a class that has one. ### Error codes ### Example: Your own code Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-2" } } ``` ```ts close-ticket.tool.ts active 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: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket 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}.`, "TICKET_NOT_FOUND")); if (ticket.status === "closed") this.fail(new PublicMcpError(`Ticket ${id} is already closed.`, "TICKET_CLOSED")); ticket.status = "closed"; return ticket; } } ``` ```ts close-ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("closing a closed ticket fails with TICKET_CLOSED", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-2" }); expect(result.raw._meta?.code).toBe("TICKET_CLOSED"); expect(result.text()).toBe("Ticket T-2 is already closed."); }); test("an unknown ticket fails with TICKET_NOT_FOUND", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-9" }); expect(result.raw._meta?.code).toBe("TICKET_NOT_FOUND"); }); ``` ### Example: Invalid input For a rule the schema can't express, like "in the future", fail with `InvalidInputError`: the same code as a schema failure, so the model treats it the same way. The second argument is added to the message as JSON. Initial call configuration: ```js { tool: "schedule_callback", arguments: { id: "T-1", at: "2020-01-06T09:00:00Z" } } ``` ```ts schedule-callback.tool.ts active import { InvalidInputError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "schedule_callback", description: "Schedule a phone call back to the customer who opened a ticket", inputSchema: { id: z.string(), at: z.string().datetime().describe("When to call, as an ISO date-time") }, }) export class ScheduleCallback extends ToolContext { async execute({ id, at }: { id: string; at: string }) { if (Date.parse(at) <= Date.now()) { this.fail(new InvalidInputError("`at` must be in the future.", { field: "at", received: at })); } return { id, callbackAt: at }; } } ``` ```ts schedule-callback.test.ts import { test, expect } from "@frontmcp/testing"; test("a time in the past is INVALID_INPUT, with the details", async ({ mcp }) => { const result = await mcp.tools.call("schedule_callback", { id: "T-1", at: "2020-01-06T09:00:00Z" }); expect(result.raw._meta?.code).toBe("INVALID_INPUT"); expect(result.text()).toBe('`at` must be in the future.\nDetails: {\n "field": "at",\n "received": "2020-01-06T09:00:00Z"\n}'); }); ``` ### Example: Rate limit `RateLimitError` writes the message for you, including when to try again. Initial call configuration: ```js { tool: "escalate_ticket", arguments: { id: "T-1" } } ``` ```ts escalate-ticket.tool.ts active import { RateLimitError, Tool, ToolContext, z } from "@frontmcp/sdk"; const escalations = new Map(); @Tool({ name: "escalate_ticket", description: "Escalate a ticket to the on-call engineer", inputSchema: { id: z.string() } }) export class EscalateTicket extends ToolContext { async execute({ id }: { id: string }) { const count = escalations.get(id) ?? 0; if (count >= 2) this.fail(new RateLimitError(3600)); escalations.set(id, count + 1); return { id, escalation: count + 1 }; } } ``` ```ts escalate-ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("the third escalation of a ticket is rate limited", async ({ mcp }) => { await mcp.tools.call("escalate_ticket", { id: "T-1" }); await mcp.tools.call("escalate_ticket", { id: "T-1" }); const third = await mcp.tools.call("escalate_ticket", { id: "T-1" }); expect(third.raw._meta?.code).toBe("RATE_LIMIT_EXCEEDED"); expect(third.text()).toBe("Rate limit exceeded. Retry after 3600 seconds"); }); ``` ### Failing vs throwing For a `PublicMcpError`, `throw` and `this.fail()` give the same result: `find_ticket` throws, `get_ticket` fails, and the model reads the same message and code from both. They differ for errors that aren't public. `sync_ticket` throws a plain `Error`, which arrives as `TOOL_EXECUTION_ERROR`, with `Tool "sync_ticket" execution failed:` before the message; passed to `this.fail()`, the same error would be `SERVER_ERROR`. Production hides both. Initial call configuration: ```js { tool: "find_ticket", arguments: { id: "T-9" } } ``` ```ts tickets.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [{ id: "T-1", title: "Cannot log in", status: "open" }]; @Tool({ name: "find_ticket", description: "Get one support ticket by id", inputSchema: { id: z.string() } }) export class FindTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.find((t) => t.id === id); if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`); return ticket; } } @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 }) { const ticket = tickets.find((t) => t.id === id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); return ticket; } } @Tool({ name: "sync_ticket", description: "Copy a ticket to the CRM", inputSchema: { id: z.string() } }) export class SyncTicket extends ToolContext { async execute({ id }: { id: string }) { throw new Error(`connect ECONNREFUSED 10.0.4.12:5432 while syncing ${id}`); // 🚩 not public } } ``` ```ts fail-vs-throw.test.ts import { test, expect } from "@frontmcp/testing"; test("throwing a public error keeps its message and code", async ({ mcp }) => { const result = await mcp.tools.call("find_ticket", { id: "T-9" }); expect(result.raw._meta?.code).toBe("PUBLIC_ERROR"); expect(result.text()).toBe("There's no ticket T-9."); }); test("failing with it gives the same result", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-9" }); expect(result.raw._meta?.code).toBe("PUBLIC_ERROR"); expect(result.text()).toBe("There's no ticket T-9."); }); test("throwing a plain Error gives TOOL_EXECUTION_ERROR", async ({ mcp }) => { const result = await mcp.tools.call("sync_ticket", { id: "T-1" }); expect(result.raw._meta?.code).toBe("TOOL_EXECUTION_ERROR"); expect(result.text()).toMatch(/^Tool "sync_ticket" execution failed: connect ECONNREFUSED/); }); ``` ### Errors from providers and other code Providers and helpers can't call `this.fail()`, so they throw. A `PublicMcpError` they throw passes through the tool unchanged, and a subclass of it can carry its own code, so the tool doesn't need a `try`/`catch`: Initial call configuration: ```js { tool: "reopen_ticket", arguments: { id: "T-9" } } ``` ```ts reopen-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { TicketStore } from "./ticket-store"; @Tool({ name: "reopen_ticket", description: "Reopen a closed support ticket", inputSchema: { id: z.string() } }) export class ReopenTicket extends ToolContext { async execute({ id }: { id: string }) { return this.get(TicketStore).reopen(id); } } ``` ```ts ticket-store.ts import { Provider, PublicMcpError } from "@frontmcp/sdk"; export class TicketNotFoundError extends PublicMcpError { constructor(id: string) { super(`There's no ticket ${id}.`, "TICKET_NOT_FOUND", 404); } } @Provider({ name: "TicketStore" }) export class TicketStore { private tickets = new Map([["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }]]); reopen(id: string) { const ticket = this.tickets.get(id); if (!ticket) throw new TicketNotFoundError(id); ticket.status = "open"; return ticket; } } ``` ```ts reopen-ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("the provider's error reaches the client", async ({ mcp }) => { const result = await mcp.tools.call("reopen_ticket", { id: "T-9" }); expect(result.raw._meta?.code).toBe("TICKET_NOT_FOUND"); expect(result.text()).toBe("There's no ticket T-9."); }); test("a known ticket is reopened", async ({ mcp }) => { const result = await mcp.tools.call("reopen_ticket", { id: "T-2" }); expect(result.json()).toMatchObject({ id: "T-2", status: "open" }); }); ``` Anything else a provider throws, like a database driver's error, is wrapped as `TOOL_EXECUTION_ERROR` and hidden in production, like the plain `Error` in [Failing vs throwing](https://frontmcp.dev/reference/sdk/fail#failing-vs-throwing). ### What production hides The Call tab shows what a plain `Error` gives in development: its message and stack, which can include hostnames, queries and secrets. With `NODE_ENV=production`, FrontMCP sends an error ID instead. The Playground can't switch to production, so the test formats the same errors with `createErrorHandler()`. Its `handle()` uses the function that formats `tools/call` errors, and `isDevelopment: false` is what `NODE_ENV=production` means to it. Initial call configuration: ```js { tool: "sync_ticket", arguments: { id: "T-1" } } ``` ```ts sync-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "sync_ticket", description: "Copy a ticket to the CRM", inputSchema: { id: z.string() } }) export class SyncTicket extends ToolContext { async execute({ id }: { id: string }) { this.fail(new Error(`connect ECONNREFUSED 10.0.4.12:5432 while syncing ${id}`)); // 🚩 not public } } ``` ```ts production.test.ts import { test, expect } from "@frontmcp/testing"; import { createErrorHandler, PublicMcpError } from "@frontmcp/sdk"; const production = createErrorHandler({ isDevelopment: false }); const development = createErrorHandler({ isDevelopment: true }); test("a plain Error becomes an error ID in production", () => { const error = new Error("connect ECONNREFUSED 10.0.4.12:5432"); const result = production.handle(error); expect(result._meta?.code).toBe("SERVER_ERROR"); expect(result.content[0].text).toBe(`Internal FrontMCP error. Please contact support with error ID: ${result._meta?.errorId}`); expect(development.handle(error).content[0].text).toContain("ECONNREFUSED 10.0.4.12:5432"); }); test("a PublicMcpError reads the same in production", () => { const error = new PublicMcpError("The CRM is down for maintenance. Try again after 18:00 UTC."); expect(production.handle(error).content[0].text).toBe("The CRM is down for maintenance. Try again after 18:00 UTC."); }); test("only development sends the stack", () => { const error = new PublicMcpError("The CRM is down."); expect(production.handle(error)._meta).not.toHaveProperty("stack"); expect(development.handle(error)._meta).toHaveProperty("stack"); }); ``` Say what the model can do about it in a `PublicMcpError`, and keep the details for your logs. ### Failing in a resource or a prompt `this.fail()` works in resources and prompts too. The client gets a JSON-RPC error with your message, and your code in `data.code` ([see the table](https://frontmcp.dev/reference/sdk/fail#in-resources-and-prompts)). ### Other entries ### Example: Resource Initial call configuration: ```js { resource: "tickets://T-9" } ``` ```ts ticket.resource.ts active import { PublicMcpError, ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; const tickets = [{ id: "T-1", title: "Cannot log in", status: "open" }]; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json" }) export class Ticket extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const ticket = tickets.find((t) => t.id === id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`, "TICKET_NOT_FOUND")); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] }; } } ``` ```ts ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("the error has the message, and the code in data", async ({ mcp }) => { const result = await mcp.resources.read("tickets://T-9"); expect(result.error).toMatchObject({ code: -32602, message: "There's no ticket T-9.", data: { code: "TICKET_NOT_FOUND" } }); }); ``` ### Example: Prompt Initial call configuration: ```js { prompt: "summarize_ticket", arguments: { id: "T-9" } } ``` ```ts summarize-ticket.prompt.ts active import { Prompt, PromptContext, PublicMcpError, type GetPromptResult } from "@frontmcp/sdk"; const tickets = [{ id: "T-1", title: "Cannot log in", status: "open" }]; @Prompt({ name: "summarize_ticket", description: "Summarize a ticket for a handover", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class SummarizeTicket extends PromptContext { async execute({ id }: Record): Promise { const ticket = tickets.find((t) => t.id === id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`, "TICKET_NOT_FOUND")); return { messages: [{ role: "user", content: { type: "text", text: `Summarize: ${ticket.title} (${ticket.status})` } }] }; } } ``` ```ts summarize-ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("the error has the message, and the code in data", async ({ mcp }) => { const result = await mcp.prompts.get("summarize_ticket", { id: "T-9" }); expect(result.error).toMatchObject({ code: -32602, message: "There's no ticket T-9.", data: { code: "TICKET_NOT_FOUND" } }); }); ``` --- ## Troubleshooting ### "Internal FrontMCP error. Please contact support with error ID: err_…" The server runs in production, and the call failed with an error that isn't public: a plain `Error` passed to `this.fail()` (`_meta.code` `SERVER_ERROR`), an `InternalMcpError`, or any other error thrown from `execute()` (`TOOL_EXECUTION_ERROR`). Look the ID up in the server log: FrontMCP logs every failure with its ID, and for these errors, the original message and stack. Fail with a [`PublicMcpError`](https://frontmcp.dev/reference/sdk/fail#failing-with-a-message-the-model-can-read) for anything the model should read. ### `Tool "get_ticket" execution failed: …`, with `_meta.code` `TOOL_EXECUTION_ERROR` `execute()` threw an error that isn't public, like a plain `Error`, a `TypeError` from a library, or an `InternalMcpError`, so FrontMCP wrapped it. If the message is for the model, throw or fail with a `PublicMcpError` instead. See [Failing vs throwing](https://frontmcp.dev/reference/sdk/fail#failing-vs-throwing). If the error comes from a provider, [throw a `PublicMcpError` there](https://frontmcp.dev/reference/sdk/fail#errors-from-providers-and-other-code). ### `_meta.code` is `SERVER_ERROR` The tool called `this.fail()` with an error that isn't an `McpError`, like `new Error(…)` or a `TypeError` from a library. FrontMCP wraps it as a server error, which shows the stack in development and hides everything in production. Use `PublicMcpError` if the message is for the model. ### A tool call succeeds after `this.fail()` `this.fail()` throws to end `execute()`, and a `try`/`catch` around it caught that. Here the tool reports the store as unavailable, although the real problem is an unknown ticket: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-9" } } ``` ```ts close-ticket.tool.ts active import { FlowControl, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [{ id: "T-1", title: "Cannot log in", status: "open" }]; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { try { const ticket = tickets.find((t) => t.id === id); if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); // 🚩 caught below ticket.status = "closed"; return { closed: true, id }; } catch (e) { return { closed: false, reason: "The ticket store is unavailable.", caught: e instanceof FlowControl }; } } } ``` ```ts close-ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("the catch swallowed the failure", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-9" }); expect(result).toBeSuccessful(); expect(result.json()).toEqual({ closed: false, reason: "The ticket store is unavailable.", caught: true }); }); ``` Call `this.fail()` outside the `try`, or let `FlowControl` through: ```ts } catch (e) { if (e instanceof FlowControl) throw e; // ✅ this.fail() ends the call return { closed: false, reason: "The ticket store is unavailable." }; } ``` ### `Resource "…" read failed: …` or `Prompt execution failed: …` A resource or a prompt threw an error that isn't public, so FrontMCP wrapped it: code `-32603`, with `data.code` `RESOURCE_READ_ERROR` or `PROMPT_EXECUTION_FAILED`. In production the message is only "Internal FrontMCP error" and an error ID. Initial call configuration: ```js { resource: "tickets://T-1" } ``` ```ts ticket.resource.ts import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json" }) export class Ticket extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }): Promise<{ contents: { uri: string; text: string }[] }> { throw new Error(`connect ECONNREFUSED 10.0.4.12:5432 while reading ${id}`); // 🚩 not public } } ``` ```ts ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("the error is wrapped as a read failure", async ({ mcp }) => { const result = await mcp.resources.read("tickets://T-1"); expect(result.error).toMatchObject({ code: -32603, message: 'Resource "tickets://T-1" read failed: connect ECONNREFUSED 10.0.4.12:5432 while reading T-1', data: { code: "RESOURCE_READ_ERROR" }, }); }); ``` If the client should read the message, fail with a `PublicMcpError`, as in [Failing in a resource or a prompt](https://frontmcp.dev/reference/sdk/fail#failing-in-a-resource-or-a-prompt). ### TypeScript says `Property 'fail' is protected` `this.fail()` was called from outside the tool class, for example from a provider that was handed the tool. Throw a `PublicMcpError` there instead: it [reaches the client as it is](https://frontmcp.dev/reference/sdk/fail#errors-from-providers-and-other-code). --- # this.callTool > Call another tool of the same server from inside a tool, resource, prompt, agent or job, as the same caller, and handle what it returns or throws. Source: https://frontmcp.dev/reference/sdk/call-tool [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `this.callTool()` runs another tool of your server from inside a call, the way a client's `tools/call` would: the arguments are validated, `authorities` are checked, hooks run, and the result is formatted and checked against `outputSchema`. It runs as the same caller, in the same request, and resolves to the tool's result. Use it to build one tool out of others, to start a job from a tool, or to have one [agent hand a task to another](https://frontmcp.dev/learn/agents-that-call-agents). When the tool fails, `this.callTool()` throws instead of returning an error result. ```ts const result = await this.callTool(name, args?, opts?) ``` --- ## Reference ### `this.callTool(name, args?, opts?)` Call `this.callTool()` inside the `execute()` of a tool, a resource, a prompt, an agent or a job. ```ts close-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a ticket and tell the customer", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = await closeTicket(id); const sent = await this.callTool("email_customer", { to: ticket.customer, subject: `${id} is closed` }); return { id, status: "closed", email: sent.structuredContent }; } } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/call-tool#usage) #### Parameters | Parameter | Type | Description | | --- | --- | --- | | `name` | `string` | The tool's name, as in `tools/list`. Tools of every app on the server can be called, including [`hidden` and `internal`](https://frontmcp.dev/reference/sdk/call-tool#calling-a-tool-clients-cant-call) ones. `":"` and `"."` work too, and so does the name with `-` for `_`, like `get-ticket`. A client's `tools/call` finds the same forms. (Changed in 1.9.2: before, the dotted form answered `not found`. Changed in 1.9.3: before, a client's `tools/call` answered `TOOL_NOT_FOUND` for it.) | | `args` | `Record` | Optional, `{}` by default. The arguments, validated against the tool's `inputSchema` as a client's would be: defaults are filled in and unknown fields dropped. | | `opts.signal` | `AbortSignal` | Optional. Becomes the called tool's [`this.signal`](https://frontmcp.dev/reference/sdk/contexts#thissignal), so aborting it stops a tool that listens to it. | | `opts.progressToken` | `string \| number` | Optional. Sent as the inner call's `_meta.progressToken`. Clients on MCP versions before 2026-07-28 need it for the called tool's progress to reach them. Under 2026-07-28 you don't: the called tool's `this.progress()` and `this.notify()` already [go to the client of the outer call](https://frontmcp.dev/reference/sdk/call-tool#progress-from-the-called-tool). | #### Returns A promise of the tool's `CallToolResult`, the same object a client would get: `content`, and `structuredContent` when the tool returns an object, built as [`@Tool`](https://frontmcp.dev/reference/sdk/tool#returning-results) describes. Read the value from `structuredContent`. It never resolves to an error result: every failure [throws](https://frontmcp.dev/reference/sdk/call-tool#throws). #### Throws | When the called tool | `this.callTool()` throws | If you don't catch it, the client gets | | --- | --- | --- | | Fails with `this.fail(error)` | A `FlowControl` whose `message` is `""`, with your error as `originalError` | The called tool's error, as if the calling tool had failed with it | | Throws a `PublicMcpError` | That error | That error: its message and its code | | Throws any other error | `ToolExecutionError`: `Tool "email_customer" execution failed: `, code `TOOL_EXECUTION_ERROR` | `Tool "close_ticket" execution failed: Tool "email_customer" execution failed: ` in development, "Internal FrontMCP error" in production | | Doesn't exist | `ToolNotFoundError`: `Tool "email_customer" not found`, code `TOOL_NOT_FOUND` | That message and code | | Gets arguments that don't match its `inputSchema` | `InvalidInputError`: "Invalid tool input", code `INVALID_INPUT`, with `validationErrors` | "Invalid tool input", then the details, code `INVALID_INPUT` | | Returns a value that doesn't match its `outputSchema` | `InvalidOutputError`: "Tool output validation failed (…)", code `INVALID_OUTPUT` | That message and code, as if the calling tool had returned it. In production the message is hidden. (Changed in 1.8.7: before, it came after `Tool "close_ticket" execution failed:`, with code `TOOL_EXECUTION_ERROR`.) | | Is refused by its `authorities` | `AuthorityDeniedError`: `Access denied to Tool "desk:admin_only": …`, code `AUTHORITY_DENIED` | That message and code | | Is over its `rateLimit` | `RateLimitError`: "Rate limit exceeded. Retry after N seconds", code `RATE_LIMIT_EXCEEDED` | That message and code | `FlowControl` and the [error classes](https://frontmcp.dev/reference/sdk/error-classes) are exported from `@frontmcp/sdk`, except `AuthorityDeniedError`: check its `code` instead. See [Handling a failure](https://frontmcp.dev/reference/sdk/call-tool#handling-a-failure-from-the-called-tool). #### What the called tool sees - **The same caller.** `this.auth` and `this.context.authInfo` are the calling tool's, so `authorities` are checked against the person who made the outer call. - **The same request.** `this.context` is the same object: the same `requestId` and trace, and values you [`set()`](https://frontmcp.dev/reference/sdk/context#passing-a-value-from-a-hook-to-the-tool) in the calling tool can be read in the called one. - **Its own call otherwise.** Its `this.signal`, its input and output, its hooks (a plugin's `Will("execute")` runs for it too), and its [`rateLimit`](https://frontmcp.dev/reference/sdk/call-tool#rate-limit-exceeded-from-a-tool-the-client-didnt-call), which counts the inner call like any other. `globalConcurrency` doesn't count inner calls: see [Guard options](https://frontmcp.dev/reference/sdk/guard). #### Where it works | In | `this.callTool()` | | --- | --- | | Tools, resources | Works. | | Agents | Works in the agent's own `execute()`, including another agent's `invoke_`, on the `"agent"` surface: a tool whose `availableWhen.surface` leaves out `"agent"` answers `Tool "…" not found`. In a tool listed in `@Agent({ tools })`, only the agent's own tools can be found. See [`@Agent`](https://frontmcp.dev/reference/sdk/agent). | | Jobs | Works, as the caller who started the run, on the `"job"` surface, so the same goes for a tool whose `surface` leaves out `"job"`. | | Channels | Works, and the tool it calls sees an anonymous caller (`anon:…`), though the channel's own `this.auth` is empty: see [Context classes](https://frontmcp.dev/reference/sdk/contexts#where-there-is-no-request-context). While `onEvent()` handles a [webhook](https://frontmcp.dev/reference/sdk/channel#sources), on the `"http-trigger"` surface. | | Prompts | Works, as the prompt's caller. (New in 1.9.2: before, `PromptContext` didn't have it.) | #### Caveats - An inner call's input error reaches the model as "Invalid tool input" with the inner tool's details, although the arguments the model sent were fine. When you build the arguments from the model's input, catch the error and fail with a message about what the model sent. - The called tool doesn't appear anywhere in the client's view: the client only sees the outer call, its notifications and its result. --- ## Usage ### Building a tool out of other tools `close_ticket` closes the ticket, then calls `email_customer`. The email tool is `internal`, so clients can't call it or see it, but other tools can. Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts tickets.tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = new Map([ ["T-1", { id: "T-1", customer: "dana@acme.example", status: "open" }], ["T-2", { id: "T-2", customer: "lee@globex.example", status: "open" }], ]); @Tool({ name: "close_ticket", description: "Close a support ticket and email the customer", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id)!; ticket.status = "closed"; const sent = await this.callTool("email_customer", { to: ticket.customer, subject: `Your ticket ${id} is closed` }); return { id, status: ticket.status, email: sent.structuredContent }; } } @Tool({ name: "email_customer", description: "Send an email to a customer", inputSchema: { to: z.string().email(), subject: z.string() }, outputSchema: { messageId: z.string(), to: z.string() }, visibility: "internal", }) export class EmailCustomer extends ToolContext { async execute({ to }: { to: string; subject: string }) { return { messageId: "msg-1042", to }; } } ``` ```ts close-ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("close_ticket includes the email tool's result", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.json()).toEqual({ id: "T-1", status: "closed", email: { messageId: "msg-1042", to: "dana@acme.example" } }); }); test("clients can't call the email tool themselves", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["close_ticket"]); const result = await mcp.tools.call("email_customer", { to: "x@y.example", subject: "hi" }); expect(result.raw._meta?.code).toBe("TOOL_NOT_FOUND"); }); ``` `sent` is the email tool's whole `CallToolResult`. Its value is in `structuredContent`, already checked against the email tool's `outputSchema`. ### Handling a failure from the called tool If you don't catch it, a failure in the called tool ends the outer call with the same error. That's often right. When the outer tool can do something better, catch it. A tool that failed with `this.fail()` arrives as a `FlowControl`, with the error it failed with in `originalError`: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-2" } } ``` ```ts tickets.tools.ts active import { FlowControl, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = new Map([ ["T-1", { id: "T-1", customer: "dana@acme.example", status: "open" }], ["T-2", { id: "T-2", customer: "lee@globex.example", status: "open" }], ["T-3", { id: "T-3", customer: "kim@initech.example", status: "open" }], ["T-4", { id: "T-4", customer: "ada@umbrella.example", status: "open" }], ]); /** The error the called tool failed with. */ function reason(err: unknown) { const error = err instanceof FlowControl ? (err as FlowControl & { originalError?: unknown }).originalError : err; return error as { message?: string; code?: string }; } @Tool({ name: "close_ticket", description: "Close a support ticket and email the customer", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id)!; ticket.status = "closed"; try { await this.callTool("email_customer", { to: ticket.customer, subject: `Your ticket ${id} is closed` }); return { id, status: ticket.status, emailed: true }; } catch (err) { // The ticket is closed either way; tell the model the email didn't go out, and why. return { id, status: ticket.status, emailed: false, why: reason(err).message }; } } } @Tool({ name: "email_customer", description: "Send an email to a customer", inputSchema: { to: z.string(), subject: z.string() }, visibility: "internal" }) export class EmailCustomer extends ToolContext { async execute({ to }: { to: string; subject: string }) { if (to.endsWith("@globex.example")) this.fail(new PublicMcpError("globex.example is refusing our mail.", "MAIL_REFUSED")); if (to.endsWith("@initech.example")) throw new Error("SMTP timeout"); if (to.endsWith("@umbrella.example")) throw new PublicMcpError("umbrella.example has no mailbox for ada.", "NO_MAILBOX"); return { to }; } } @Tool({ name: "close_ticket_strict", description: "Close a ticket; fail if the customer can't be emailed", inputSchema: { id: z.string() } }) export class CloseTicketStrict extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets.get(id)!; await this.callTool("email_customer", { to: ticket.customer, subject: `Your ticket ${id} is closed` }); ticket.status = "closed"; return { id, status: ticket.status }; } } ``` ```ts failures.test.ts import { test, expect } from "@frontmcp/testing"; test("a caught failure: the outer tool decides what to answer", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-2" }); expect(result.json()).toEqual({ id: "T-2", status: "closed", emailed: false, why: "globex.example is refusing our mail." }); }); test("a thrown public error arrives as it is", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-4" }); expect(result.json().why).toBe("umbrella.example has no mailbox for ada."); }); test("a thrown error that isn't public arrives wrapped", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-3" }); expect(result.json().why).toBe('Tool "email_customer" execution failed: SMTP timeout'); }); test("an uncaught failure: the client gets the called tool's error", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket_strict", { id: "T-2" }); expect(result).toBeError("MAIL_REFUSED"); expect(result.text()).toBe("globex.example is refusing our mail."); }); ``` Errors that the called tool throws, rather than passes to `this.fail()`, arrive as they are, or as a `ToolExecutionError` when they aren't public. `reason()` handles both. ### Calling a tool clients can't call `visibility: "hidden"` leaves a tool out of `tools/list`, and `"internal"` also refuses calls from clients. So does an [`availableWhen`](https://frontmcp.dev/reference/server/environment) whose `surface` leaves out `"mcp"`: MCP clients neither see nor call the tool. From a tool or a resource, `this.callTool()` reaches all three, because it runs the called tool in-process, with no surface. That makes such tools a place for helpers that several tools share but the model shouldn't use directly. An agent's or a job's `this.callTool()` is different: it calls on the `"agent"` or `"job"` surface, so a tool whose `surface` leaves that caller out isn't found. `authorities` still apply, to the same caller: Initial call configuration: ```js { tool: "weekly_digest", arguments: {} } ``` ```ts digest.tools.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "weekly_digest", description: "Summarize this week's tickets", inputSchema: {} }) export class WeeklyDigest extends ToolContext { async execute() { const counts = await this.callTool("count_tickets", {}); const escalations = await this.callTool("count_escalations", {}); return { week: "2026-W39", ...(counts.structuredContent as { open: number; closed: number }), ...(escalations.structuredContent as { escalated: number }), }; } } @Tool({ name: "count_tickets", description: "Count tickets by status", inputSchema: {}, visibility: "internal" }) export class CountTickets extends ToolContext { async execute() { return { open: 12, closed: 31 }; } } @Tool({ name: "count_escalations", description: "Count escalated tickets", inputSchema: {}, availableWhen: { surface: ["agent"] } }) export class CountEscalations extends ToolContext { async execute() { return { escalated: 2 }; } } @Tool({ name: "purge_closed", description: "Delete closed tickets", inputSchema: {}, visibility: "internal", authorities: "admin" }) export class PurgeClosed extends ToolContext { async execute() { return { purged: 31 }; } } @Tool({ name: "weekly_cleanup", description: "Purge closed tickets, for admins", inputSchema: {} }) export class WeeklyCleanup extends ToolContext { async execute() { return (await this.callTool("purge_closed", {})).structuredContent as { purged: number }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { CountEscalations, CountTickets, PurgeClosed, WeeklyCleanup, WeeklyDigest } from "./digest.tools"; @App({ id: "desk", name: "Help Desk", tools: [WeeklyDigest, CountTickets, CountEscalations, PurgeClosed, WeeklyCleanup] }) class Desk {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [Desk], authorities: { claimsMapping: { roles: "roles" }, profiles: { admin: { roles: { any: ["admin"] } } } }, }; @FrontMcp(config) export default class Server {} ``` ```ts internal.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance, Job, JobContext, z } from "@frontmcp/sdk"; import { CountEscalations } from "./digest.tools"; import { config } from "./main"; test("tools clients can't call, called by another tool", async ({ mcp }) => { expect((await mcp.tools.call("weekly_digest", {})).json()).toEqual({ week: "2026-W39", open: 12, closed: 31, escalated: 2 }); }); test("clients neither see nor call them", async ({ mcp }) => { const names = (await mcp.tools.list()).map((tool) => tool.name); expect(names).not.toContain("count_tickets"); expect(names).not.toContain("count_escalations"); expect(await mcp.tools.call("count_tickets", {})).toBeError("TOOL_NOT_FOUND"); expect(await mcp.tools.call("desk.count_tickets", {})).toBeError("TOOL_NOT_FOUND"); expect(await mcp.tools.call("count_escalations", {})).toBeError("TOOL_NOT_FOUND"); }); test("the called tool's authorities are checked against the same caller", async ({ mcp }) => { const result = await mcp.tools.call("weekly_cleanup", {}); expect(result).toBeError("AUTHORITY_DENIED"); expect(result.text()).toBe(`Access denied to Tool "desk:purge_closed": profile:admin: roles.any: user has none of 'admin'`); }); test("called by an admin, it works", async () => { // createDirect() runs the server in-process, as the user you name. const server = await FrontMcpInstance.createDirect(config); const result = await server.callTool("weekly_cleanup", {}, { authContext: { user: { sub: "nour", roles: ["admin"] } } }); await server.dispose(); expect(result.structuredContent).toEqual({ purged: 31 }); }); test("🚩 a job calls on the `job` surface, so it can't reach an agent-only tool", async () => { @Job({ name: "nightly-digest", inputSchema: {}, outputSchema: { escalated: z.number() } }) class NightlyDigest extends JobContext { async execute() { return (await this.callTool("count_escalations", {})).structuredContent as { escalated: number }; } } @App({ id: "desk", name: "Help Desk", tools: [CountEscalations], jobs: [NightlyDigest] }) class Desk {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }); await expect(server.callTool("execute_job", { name: "nightly-digest", input: {} })).rejects.toThrow('Tool "count_escalations" not found'); await server.dispose(); }); ``` The Playground's caller is anonymous, with no roles, so `weekly_cleanup` is refused on its way to `purge_closed`. The last test calls it as an admin, and it works. ### Progress from the called tool Under MCP 2026-07-28, the called tool's progress and log notifications reach the client of the outer call, on the same stream, with the outer call's `progressToken`. The client never learns another tool was involved: Initial call configuration: ```js { tool: "export_all", arguments: {} } ``` ```ts export.tools.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "export_all", description: "Export every ticket and every customer", inputSchema: {} }) export class ExportAll extends ToolContext { async execute() { const tickets = await this.callTool("export_tickets", {}); await this.progress(2, 2, "Exported customers"); return { files: ["tickets.csv", "customers.csv"], tickets: tickets.structuredContent }; } } @Tool({ name: "export_tickets", description: "Export every ticket", inputSchema: {}, visibility: "internal" }) export class ExportTickets extends ToolContext { async execute() { await this.progress(1, 2, "Exported tickets"); return { rows: 43 }; } } ``` ```ts progress.test.ts import { test, expect } from "@frontmcp/testing"; test("both tools' progress reaches the client", async ({ mcp }) => { await mcp.tools.call("export_all", {}); const updates = mcp.notifications.collectProgress().all; expect(updates.map((u) => u.message)).toEqual(["Exported tickets", "Exported customers"]); }); ``` ### Giving the called tool less time `opts.signal` becomes the called tool's `this.signal`. Pass `AbortSignal.timeout()` to give an inner call a time budget of its own, shorter than the outer call's, and answer without it when it runs out. It only stops a tool that listens to its signal: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts tickets.tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get a support ticket, with its history when it's quick to load", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const ticket = { id, title: "Cannot log in" }; try { const history = await this.callTool("load_history", { id }, { signal: AbortSignal.timeout(100) }); return { ...ticket, history: history.structuredContent }; } catch { return { ...ticket, history: null, note: "The history took too long to load." }; } } } @Tool({ name: "load_history", description: "Load a ticket's history from the archive", inputSchema: { id: z.string() }, visibility: "internal" }) export class LoadHistory extends ToolContext { async execute({ id }: { id: string }) { // Stands in for a slow archive query that stops when its signal is aborted. await new Promise((resolve, reject) => { const timer = setTimeout(resolve, 2000); this.signal?.addEventListener("abort", () => { clearTimeout(timer); reject(this.signal?.reason); }); }); return { id, events: 12 }; } } ``` ```ts budget.test.ts import { test, expect } from "@frontmcp/testing"; test("the ticket comes back without the history, after 100 ms", async ({ mcp }) => { const started = Date.now(); const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result.json()).toEqual({ id: "T-1", title: "Cannot log in", history: null, note: "The history took too long to load." }); expect(Date.now() - started).toBeLessThan(1000); }); ``` ### Starting a job from a tool Jobs run through the `execute_job` tool, so a tool can start one with `this.callTool()`. With `background: true` the call returns at once with the run's id: Initial call configuration: ```js { tool: "start_export", arguments: { customer: "acme" } } ``` ```ts export.ts active import { Job, JobContext, Tool, ToolContext, z } from "@frontmcp/sdk"; @Job({ name: "export-tickets", description: "Export a customer's tickets", inputSchema: { customer: z.string() }, outputSchema: { rows: z.number() } }) export class ExportTicketsJob extends JobContext { async execute({ customer }: { customer: string }) { this.log(`Exporting ${customer}`); return { rows: 43 }; } } @Tool({ name: "start_export", description: "Start exporting a customer's tickets in the background", inputSchema: { customer: z.string() } }) export class StartExport extends ToolContext { async execute({ customer }: { customer: string }) { const run = await this.callTool("execute_job", { name: "export-tickets", input: { customer }, background: true }); const { runId, state } = run.structuredContent as { runId: string; state: string }; return { customer, runId, state }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { ExportTicketsJob, StartExport } from "./export"; @App({ id: "help-desk", name: "Help Desk", tools: [StartExport], jobs: [ExportTicketsJob] }) export class HelpDeskApp {} ``` ```ts start.test.ts import { test, expect } from "@frontmcp/testing"; test("the tool returns the run's id", async ({ mcp }) => { const result = await mcp.tools.call("start_export", { customer: "acme" }); expect(result.json()).toEqual({ customer: "acme", runId: expect.any(String), state: "pending" }); }); ``` [Nightly Ticket Report](https://frontmcp.dev/examples/nightly-ticket-report) starts a workflow the same way, with `execute_workflow`. ### Calling a tool from a resource, a prompt or an agent `ResourceContext`, `PromptContext` and `AgentContext` have the same `this.callTool()`. An agent can call a tool before or after its model loop, or hand the whole task to another agent's `invoke_`, as in [Agents That Call Agents](https://frontmcp.dev/learn/agents-that-call-agents). ### Other callers ### Example: Resource Initial call configuration: ```js { resource: "tickets://T-1" } ``` ```ts ticket.ts active import { ResourceContext, ResourceTemplate, 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 }) { return { id, title: "Cannot log in", status: "open" }; } } @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json" }) export class Ticket extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { const result = await this.callTool("get_ticket", { id }); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(result.structuredContent) }] }; } } ``` ```ts ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("the resource serves the tool's result", async ({ mcp }) => { const text = (await mcp.resources.read("tickets://T-1")).text()!; expect(JSON.parse(text)).toEqual({ id: "T-1", title: "Cannot log in", status: "open" }); }); ``` ### Example: Prompt Initial call configuration: ```js { prompt: "handover_note", arguments: { id: "T-1" } } ``` ```ts handover.prompt.ts active import { Prompt, PromptContext, 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 }) { return { id, title: "Cannot log in", status: "open" }; } } @Prompt({ name: "handover_note", description: "Write a handover note for a ticket", arguments: [{ name: "id", required: true }] }) export class HandoverNote extends PromptContext { async execute({ id }: Record) { const ticket = (await this.callTool("get_ticket", { id })).structuredContent as { title: string; status: string }; return `Write a handover note for ticket ${id}, "${ticket.title}", which is ${ticket.status}.`; } } ``` ```ts handover.test.ts import { test, expect } from "@frontmcp/testing"; test("the prompt reads the ticket through the tool", async ({ mcp }) => { const result = await mcp.prompts.get("handover_note", { id: "T-1" }); expect(result.messages[0].content.text).toBe('Write a handover note for ticket T-1, "Cannot log in", which is open.'); }); ``` ### Example: Agent Initial call configuration: ```js { tool: "invoke_triage", arguments: { ticketId: "T-1" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority for a support ticket", systemInstructions: "You triage tickets for a help desk. Answer high, normal or low, and say why in one sentence.", inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") }, llm: { adapter: model }, }) export class Triage extends AgentContext { async execute(input: { ticketId: string }) { const ticket = await this.callTool("get_ticket", { id: input.ticketId }); // before the loop const answer = (await super.execute(input)) as { response: string }; return { ...answer, ticket: ticket.structuredContent }; } } ``` ```ts ticket.tool.ts import { 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 }) { return { id, title: "Cannot log in", status: "open" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; import { GetTicket } from "./ticket.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], agents: [Triage] }) export class HelpDeskApp {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion() { return { content: "High: the customer can't work.", finishReason: "stop" }; }, }; ``` ```ts triage.test.ts import { test, expect } from "@frontmcp/testing"; test("the agent adds the ticket it read to its answer", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" }); expect(result.json()).toEqual({ response: "High: the customer can't work.", ticket: { id: "T-1", title: "Cannot log in", status: "open" }, }); }); ``` --- ## Troubleshooting ### `Tool "…" not found` No tool the caller can reach has that name. `this.callTool()` finds a tool by its name, by `":"` or `"."`, and with `-` in place of `_`, and so does a client's `tools/call`. A name with another app's id isn't found: Initial call configuration: ```js { tool: "try_names", arguments: {} } ``` ```ts tickets.tools.ts active import { 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 }) { return { id, status: "open" }; } } @Tool({ name: "try_names", description: "Call get_ticket by each form of its name", inputSchema: {} }) export class TryNames extends ToolContext { async execute() { const found: Record = {}; for (const name of ["get_ticket", "desk:get_ticket", "desk.get_ticket", "get-ticket", "billing:get_ticket"]) { try { await this.callTool(name, { id: "T-1" }); found[name] = "found"; } catch (err) { found[name] = (err as Error).message; } } return found; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket, TryNames } from "./tickets.tools"; @App({ id: "desk", name: "Help Desk", tools: [GetTicket, TryNames] }) export class Desk {} ``` ```ts names.test.ts import { test, expect } from "@frontmcp/testing"; test("the name, appId:name, appId.name and the dashed name work", async ({ mcp }) => { expect((await mcp.tools.call("try_names", {})).json()).toEqual({ get_ticket: "found", "desk:get_ticket": "found", "desk.get_ticket": "found", "get-ticket": "found", "billing:get_ticket": 'Tool "billing:get_ticket" not found', }); }); test("a client's tools/call finds the same names", async ({ mcp }) => { for (const name of ["desk:get_ticket", "desk.get_ticket"]) { expect((await mcp.tools.call(name, { id: "T-1" })).json()).toEqual({ id: "T-1", status: "open" }); } expect(await mcp.tools.call("billing.get_ticket", { id: "T-1" })).toBeError("TOOL_NOT_FOUND"); }); ``` Check the spelling against the tool's `name` option: hidden and internal tools aren't in `tools/list`, but `this.callTool()` finds them by name. A client's call finds a hidden tool, by any of these names, but never an internal one. From a tool that belongs to an agent (in `@Agent({ tools })`), only that agent's own tools can be found: call the other tool from the agent's `execute()` instead. ### The caught error's `message` is empty The called tool failed with `this.fail()`, and what you caught is the `FlowControl` that carries its error. Read `originalError`, as `reason()` does in [Handling a failure](https://frontmcp.dev/reference/sdk/call-tool#handling-a-failure-from-the-called-tool). The other failures arrive as errors of their own, with a `message` and a `code`: Initial call configuration: ```js { tool: "try_failures", arguments: {} } ``` ```ts tickets.tools.ts active import { FlowControl, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by id", inputSchema: { id: z.string() }, outputSchema: { id: z.string(), status: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { if (id === "T-9") this.fail(new PublicMcpError(`There's no ticket ${id}.`, "NO_TICKET")); if (id === "T-0") return { id, status: 0 } as unknown as { id: string; status: string }; // doesn't match outputSchema return { id, status: "open" }; } } @Tool({ name: "relay_ticket", description: "Read a ticket through get_ticket, and pass on whatever fails", inputSchema: { id: z.string() } }) export class RelayTicket extends ToolContext { async execute({ id }: { id: string }) { return (await this.callTool("get_ticket", { id })).structuredContent; // not caught } } @Tool({ name: "try_failures", description: "Show how each kind of failure arrives", inputSchema: {} }) export class TryFailures extends ToolContext { async execute() { const attempts: [string, Record][] = [ ["get_ticket", { id: "T-9" }], // fails with this.fail() ["get_ticket", { id: "T-0" }], // bad output ["get_ticket", { ticket: "T-1" }], // bad arguments ["get_tikcet", { id: "T-1" }], // no such tool ]; const caught = []; for (const [name, args] of attempts) { try { await this.callTool(name, args); } catch (err) { const original = err instanceof FlowControl ? (err as FlowControl & { originalError?: PublicMcpError }).originalError : undefined; const { message, code } = err as Error & { code?: string }; caught.push({ isFlowControl: err instanceof FlowControl, message, code: code ?? null, original: original ? { message: original.message, code: original.code } : null }); } } return { caught }; } } ``` ```ts failures.test.ts import { test, expect } from "@frontmcp/testing"; test("how each failure arrives", async ({ mcp }) => { const { caught } = (await mcp.tools.call("try_failures", {})).json(); expect(caught).toEqual([ { isFlowControl: true, message: "", code: null, original: { message: "There's no ticket T-9.", code: "NO_TICKET" } }, { isFlowControl: false, message: "Tool output validation failed (output does not match outputSchema at status)", code: "INVALID_OUTPUT", original: null }, { isFlowControl: false, message: "Invalid tool input", code: "INVALID_INPUT", original: null }, { isFlowControl: false, message: 'Tool "get_tikcet" not found', code: "TOOL_NOT_FOUND", original: null }, ]); }); test("uncaught, an invalid output reaches the client as it is", async ({ mcp }) => { const result = await mcp.tools.call("relay_ticket", { id: "T-0" }); expect(result).toBeError("INVALID_OUTPUT"); expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at status)"); }); ``` ### `Rate limit exceeded` from a tool the client didn't call The called tool has a `rateLimit` or `concurrency` limit, and calls through `this.callTool()` count toward it like calls from clients: Initial call configuration: ```js { tool: "ticket_summary", arguments: { id: "T-1" } } ``` ```ts tickets.tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "lookup_customer", description: "Look a customer up in the CRM", inputSchema: { id: z.string() }, rateLimit: { maxRequests: 1, windowMs: 60_000 }, }) export class LookupCustomer extends ToolContext { async execute({ id }: { id: string }) { return { id, name: "Acme Corp" }; } } @Tool({ name: "ticket_summary", description: "Summarize a ticket with its customer", inputSchema: { id: z.string() } }) export class TicketSummary extends ToolContext { async execute({ id }: { id: string }) { const customer = await this.callTool("lookup_customer", { id: "C-7" }); return { id, customer: customer.structuredContent }; } } ``` ```ts limits.test.ts import { test, expect } from "@frontmcp/testing"; test("the second summary is refused by lookup_customer's limit", async ({ mcp }) => { expect(await mcp.tools.call("ticket_summary", { id: "T-1" })).toBeSuccessful(); const second = await mcp.tools.call("ticket_summary", { id: "T-2" }); expect(second).toBeError("RATE_LIMIT_EXCEEDED"); expect(second.text()).toMatch(/^Rate limit exceeded\. Retry after \d+ seconds$/); }); ``` Raise the limit, or catch the `RateLimitError` in the outer tool and answer without the inner call. The limits and their keys are in [Guard options](https://frontmcp.dev/reference/sdk/guard). ### `Access denied to Tool "desk:purge_closed"` The called tool has `authorities`, and they're checked against the caller of the outer tool, who doesn't pass them. `this.callTool()` doesn't run with more rights than the caller has. Give the outer tool the same `authorities`, so callers who can't use it don't see it, or check `this.auth` before calling. See [Calling a tool clients can't call](https://frontmcp.dev/reference/sdk/call-tool#calling-a-tool-clients-cant-call). ### The model gets "Invalid tool input" for arguments that were fine The outer tool built arguments that the called tool's `inputSchema` rejects, and the error went to the client as it was, so the model reads it as its own mistake: Initial call configuration: ```js { tool: "reassign_ticket", arguments: { id: "T-1", to: "Sam" } } ``` ```ts tickets.tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "reassign_ticket", description: "Move a ticket to another agent", inputSchema: { id: z.string(), to: z.string() } }) export class ReassignTicket extends ToolContext { async execute({ id, to }: { id: string; to: string }) { // 🚩 set_assignee wants a lowercase agent id, and gets the name the model wrote return (await this.callTool("set_assignee", { id, agent: to })).structuredContent as { id: string; agent: string }; } } @Tool({ name: "set_assignee", description: "Set a ticket's assignee", inputSchema: { id: z.string(), agent: z.string().regex(/^[a-z]+$/) }, visibility: "internal", }) export class SetAssignee extends ToolContext { async execute({ id, agent }: { id: string; agent: string }) { return { id, agent }; } } ``` ```ts invalid.test.ts import { test, expect } from "@frontmcp/testing"; test("🚩 the inner call's input error reaches the model", async ({ mcp }) => { const result = await mcp.tools.call("reassign_ticket", { id: "T-1", to: "Sam" }); expect(result).toBeError("INVALID_INPUT"); expect(result.text()).toContain("Invalid tool input"); }); ``` Check the arguments the model sent against what the inner tool needs, in the outer tool's `inputSchema` or in `execute()` before calling, and fail with a message about the outer tool's own input. Here, `to: z.string().regex(/^[a-z]+$/).describe("Agent id, like sam")` would let the model fix its call. --- # this.respond > End a tool call early with a result you built yourself, answer a call from a hook, and what respond() does in resources, prompts, jobs and agents. Source: https://frontmcp.dev/reference/sdk/respond [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `this.respond()` ends a tool call on the spot with a result you pass. It's lower level than `return`: the value you pass *is* the `tools/call` result, sent as it is, so it has to be a complete result, with `content`. FrontMCP doesn't build the text block, fill in `structuredContent` or check `outputSchema` for you. Use it when you need a result of an exact shape. In a hook, the flow's `ctx.respond()` answers a call without running the tool at all, which is how a cache works. In resources, prompts, jobs and agents, `respond(value)` ends `execute()` as `return value` would. ```ts this.respond(result) ``` --- ## Reference ### `this.respond(result)` Call `this.respond()` inside a tool's `execute()`. Nothing after it runs. ```ts list-tickets.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "list_tickets", description: "List a customer's open tickets", inputSchema: { customer: z.string() } }) export class ListTickets extends ToolContext { async execute({ customer }: { customer: string }) { const tickets = await openTicketsOf(customer); if (tickets.length === 0) { this.respond({ content: [{ type: "text", text: `${customer} has no open tickets.` }] }); } return { customer, tickets }; } } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/respond#usage) #### Parameters | Parameter | Type | Description | | --- | --- | --- | | `result` | `CallToolResult` | The whole result the client gets: `content` (required by MCP), and optionally `structuredContent`, `isError` and `_meta`. TypeScript accepts any value here, so nothing stops you from passing a plain object by mistake: see [the result has no `content`](https://frontmcp.dev/reference/sdk/respond#the-result-has-no-content). | #### Returns `never`. It throws to end `execute()`, like [`this.fail()`](https://frontmcp.dev/reference/sdk/fail). #### What the client receives `result`, exactly as you passed it. Under MCP 2026-07-28 FrontMCP adds `resultType: "complete"` and its `serverInfo` in `_meta`, as it does to every result; nothing else changes. Compare that with returning the same object: | In `execute()` | The client gets | | --- | --- | | `return { id: "T-1" }` | `content: [{ type: "text", text: '{"id":"T-1"}' }]` and `structuredContent: { id: "T-1" }`, checked against `outputSchema` | | `this.respond({ id: "T-1" })` | `{ id: "T-1" }` as the whole result: no `content`, no `structuredContent`, not checked | | `this.respond({ content: [...], structuredContent: {...} })` | That result, as it is. `structuredContent` isn't checked against `outputSchema` either. | `Did("execute")` hooks still run after `this.respond()`, and see `result` as the tool's output. #### Where it works | In | What `respond(value)` does | Use instead | | --- | --- | --- | | A tool's `execute()` | Ends the call. `value` is the whole result. | `return`, for everything except hand-built results | | A hook, as the flow's `ctx.respond(value)` | Ends the call. The tool doesn't run, and `Did("execute")` hooks are skipped. `value` is the whole result; one without `content` gets `content: []`. | | | A hook, as `ctx.state.toolContext.respond(value)` | Ends the call like `ctx.respond(value)`, but sends `value` exactly as it is, without even an empty `content`. | `ctx.respond()` | | A resource's `execute()` | Ends the read, and `value` is sent as a returned value would be: `{ contents }` as it is, anything else as a text block, an object as JSON. (Changed in 1.9.3: before, the read failed with `Resource output not found`.) | Either | | A prompt's `execute()` | Ends the request, and `value` is sent as a returned value would be. (Changed in 1.9.3: before, the request failed with `Prompt output not found`.) | Either | | A job's `execute()` | Ends the run, and `value` becomes its `result`, checked against `outputSchema` as a returned value is. (Changed in 1.9.2: before, `value` replaced the whole `execute_job` response.) | Either | | An agent's `execute()` | Ends the call, and `value` is sent as a returned value would be, with `content` and `structuredContent`. (Changed in 1.8.5: before, `value` was the whole result, with no `content`.) | Either | #### Caveats - **`this.respond()` works by throwing**, like `this.fail()`. A `try`/`catch` around it catches it, as a `FlowControl` with `type: "respond"`, and `execute()` carries on. See [the call carries on](https://frontmcp.dev/reference/sdk/respond#the-call-carries-on-after-thisrespond). - `outputSchema` is only checked for returned values. A result you pass to `this.respond()` reaches the client even when its `structuredContent` doesn't match. - Prefer `return`. It builds `content` and `structuredContent` from one value, and checks it. Reach for `this.respond()` only when the result needs a shape `return` can't give it, like several content blocks. --- ## Usage ### Sending a result you built yourself A returned object becomes one JSON text block and `structuredContent`. To send several text blocks, one per ticket here, build the result yourself: Initial call configuration: ```js { tool: "list_tickets", arguments: { customer: "acme" } } ``` ```ts list-tickets.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [ { id: "T-1", customer: "acme", title: "Cannot log in" }, { id: "T-3", customer: "acme", title: "Login link expired" }, { id: "T-2", customer: "globex", title: "Invoice total is wrong" }, ]; @Tool({ name: "list_tickets", description: "List a customer's open tickets", inputSchema: { customer: z.string() } }) export class ListTickets extends ToolContext { async execute({ customer }: { customer: string }) { const open = tickets.filter((t) => t.customer === customer); this.respond({ content: open.map((t) => ({ type: "text" as const, text: `${t.id}: ${t.title}` })), structuredContent: { customer, tickets: open }, }); } } ``` ```ts list-tickets.test.ts import { test, expect } from "@frontmcp/testing"; test("the result is exactly what execute() built", async ({ mcp }) => { const result = await mcp.tools.call("list_tickets", { customer: "acme" }); expect(result.raw.content).toEqual([ { type: "text", text: "T-1: Cannot log in" }, { type: "text", text: "T-3: Login link expired" }, ]); expect(result.raw.structuredContent).toEqual({ customer: "acme", tickets: [expect.objectContaining({ id: "T-1" }), expect.objectContaining({ id: "T-3" })] }); }); ``` `execute()` doesn't need a `return` after `this.respond()`: nothing after it runs. ### Answering a call from a hook A `Will("execute")` hook can answer a call itself, with the flow's `ctx.respond()`, and the tool never runs. Here a plugin answers repeated calls to read-only tools from memory. [Hooking into Calls](https://frontmcp.dev/learn/hooking-into-calls#changing-the-result) builds the same cache step by step, in "Answering without running the tool". For a real cache, with expiry, Redis and per-caller keys, use the [Cache plugin](https://frontmcp.dev/reference/plugins/cache). Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts cache.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; type CallCtx = FlowCtxOf<"tools:call-tool">; const readOnly = (ctx: CallCtx) => ctx.state.tool?.metadata.annotations?.readOnlyHint === true; @Plugin({ name: "cache", description: "Answers repeated read-only calls from memory" }) export class CachePlugin extends DynamicPlugin { private results = new Map>(); private key(ctx: CallCtx) { return `${ctx.state.tool?.metadata.name}:${JSON.stringify(ctx.state.toolContext?.input)}`; } @ToolHook.Will("execute", { filter: readOnly }) async answerFromCache(ctx: CallCtx) { const cached = this.results.get(this.key(ctx)); if (cached) ctx.respond({ content: [{ type: "text", text: JSON.stringify(cached) }], structuredContent: cached }); } @ToolHook.Did("execute", { filter: readOnly }) async store(ctx: CallCtx) { this.results.set(this.key(ctx), ctx.state.toolContext?.output as Record); } } ``` ```ts get-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; let loads = 0; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() }, annotations: { readOnlyHint: true }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { loads += 1; return { id, title: "Cannot log in", load: loads }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket } from "./get-ticket.tool"; import { CachePlugin } from "./cache.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [CachePlugin] }) export class HelpDeskApp {} ``` ```ts cache.test.ts import { test, expect } from "@frontmcp/testing"; test("the second call is answered by the hook", async ({ mcp }) => { const first = await mcp.tools.call("get_ticket", { id: "T-1" }); const second = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(first.json()).toEqual({ id: "T-1", title: "Cannot log in", load: 1 }); expect(second.json()).toEqual({ id: "T-1", title: "Cannot log in", load: 1 }); }); ``` `ctx.respond()` ends the whole call: the tool, the `Did("execute")` hooks and the result formatting are skipped, so pass a finished result, with `content`. Neither `ctx.respond()` nor `ctx.state.toolContext.respond()` builds one from a plain value: see [a hook's answer is empty](https://frontmcp.dev/reference/sdk/respond#a-hooks-answer-reaches-the-client-empty). The [hook decorators](https://frontmcp.dev/reference/sdk/hooks) page lists the stages you can answer from. ### Ending early everywhere else Outside tools, `respond(value)` ends `execute()` as `return value` would: FrontMCP builds and checks the result from `value` as it does from a returned value. Each tab shows it next to `return`. ### respond() outside tools ### Example: Resource Initial call configuration: ```js { resource: "tickets://T-1" } ``` ```ts ticket.resource.ts active import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json" }) export class Ticket extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { if (id === "T-0") { this.respond({ contents: [{ uri, mimeType: "application/json", text: "{}" }] }); // ✅ since 1.9.3 } return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ id, title: "Cannot log in" }) }] }; // ✅ } } ``` ```ts ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("respond() ends the read with its value", async ({ mcp }) => { expect((await mcp.resources.read("tickets://T-0")).text()).toBe("{}"); }); test("return works", async ({ mcp }) => { expect(JSON.parse((await mcp.resources.read("tickets://T-1")).text()!)).toEqual({ id: "T-1", title: "Cannot log in" }); }); ``` ### Example: Prompt Initial call configuration: ```js { prompt: "summarize_ticket", arguments: { id: "T-1" } } ``` ```ts summarize.prompt.ts active import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk"; @Prompt({ name: "summarize_ticket", description: "Summarize a ticket for a handover", arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }], }) export class SummarizeTicket extends PromptContext { async execute({ id }: Record): Promise { const message = (text: string): GetPromptResult => ({ messages: [{ role: "user", content: { type: "text", text } }] }); if (id === "T-0") this.respond(message("There's no ticket T-0.")); // ✅ since 1.9.3 return message(`Summarize ticket ${id} for the next agent on shift.`); // ✅ } } ``` ```ts summarize.test.ts import { test, expect } from "@frontmcp/testing"; test("respond() ends the request with its value", async ({ mcp }) => { const result = await mcp.prompts.get("summarize_ticket", { id: "T-0" }); expect(result.messages[0].content.text).toBe("There's no ticket T-0."); }); test("return works", async ({ mcp }) => { const result = await mcp.prompts.get("summarize_ticket", { id: "T-1" }); expect(result.messages[0].content.text).toBe("Summarize ticket T-1 for the next agent on shift."); }); ``` ### Example: Job Initial call configuration: ```js { tool: "execute_job", arguments: { name: "count-open-tickets", input: { customer: "initech" } } } ``` ```ts count-open.job.ts active import { Job, JobContext, z } from "@frontmcp/sdk"; const open: Record = { acme: 2, globex: 1 }; @Job({ name: "count-open-tickets", description: "Count a customer's open tickets", inputSchema: { customer: z.string() }, outputSchema: { customer: z.string(), open: z.number() }, }) export class CountOpenTickets extends JobContext { async execute({ customer }: { customer: string }) { if (!(customer in open)) this.respond({ customer, open: 0, note: "not a customer yet" }); // ✅ since 1.9.2 return { customer, open: open[customer] }; // ✅ } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { CountOpenTickets } from "./count-open.job"; @App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets] }) export class HelpDeskApp {} ``` ```ts count.test.ts import { test, expect } from "@frontmcp/testing"; test("respond() ends the run, and its value is the run's result", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "initech" } }); expect(result.json()).toMatchObject({ runId: expect.any(String), state: "completed" }); expect(result.json().result).toEqual({ customer: "initech", open: 0 }); // checked against outputSchema: `note` is dropped }); test("✅ return gives a run record", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "acme" } }); expect(result.json()).toMatchObject({ state: "completed", result: { customer: "acme", open: 2 } }); }); ``` ### Example: Agent Initial call configuration: ```js { tool: "invoke_triage", arguments: { query: "" } } ``` ```ts triage.agent.ts active import { Agent, AgentContext, z } from "@frontmcp/sdk"; import { model } from "./model.example"; @Agent({ name: "triage", description: "Suggest a priority for a new support ticket. Pass what the customer wrote.", systemInstructions: "You triage tickets for a help desk. Answer high, normal or low, and say why in one sentence.", inputSchema: { query: z.string().describe("What the customer wrote") }, llm: { adapter: model }, }) export class Triage extends AgentContext { async execute(input: { query: string }) { if (input.query.trim() === "") this.respond({ response: "Nothing to triage." }); // ✅ since 1.8.5 return super.execute(input); // ✅ } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { Triage } from "./triage.agent"; @App({ id: "help-desk", name: "Help Desk", agents: [Triage] }) export class HelpDeskApp {} ``` ```ts model.example.ts // Stands in for a real model, which the Playground can't reach. import type { AgentLlmAdapter } from "@frontmcp/sdk"; export const model: AgentLlmAdapter = { async completion() { return { content: "Normal: nothing is blocked.", finishReason: "stop" }; }, }; ``` ```ts triage.test.ts import { test, expect } from "@frontmcp/testing"; test("respond() ends the call with a formatted result", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { query: "" }); expect(result.json()).toEqual({ response: "Nothing to triage." }); expect(result.raw.structuredContent).toEqual({ response: "Nothing to triage." }); }); test("✅ a returned value is formatted", async ({ mcp }) => { const result = await mcp.tools.call("invoke_triage", { query: "The invoice total is wrong." }); expect(result.json()).toEqual({ response: "Normal: nothing is blocked." }); }); ``` In an agent, `this.respond()` before `super.execute()` ends the call without asking the model, and the client gets a normal result, as it would from `return { response: "Nothing to triage." }`. --- ## Troubleshooting ### The result has no `content` `this.respond()` was given the tool's output, like `this.respond({ id, status })`, and sent it as the whole result. MCP clients read `content`, so the model sees nothing, and `structuredContent` is missing too: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { this.respond({ id, status: "closed" }); // 🚩 the whole result, not the output } } ``` ```ts close-ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("🚩 the value is the result, with no content", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.raw).toMatchObject({ id: "T-1", status: "closed" }); expect(result.raw.content).toBeUndefined(); expect(result.raw.structuredContent).toBeUndefined(); }); ``` Replace `this.respond(value)` with `return value`, and FrontMCP builds `content` and `structuredContent` from it. If you do need `this.respond()`, pass a complete result: `{ content: [...], structuredContent: value }`. ### A hook's answer reaches the client empty The hook passed the tool's output to `ctx.respond()`, as a cache that stored `toolContext.output` would. The flow's `ctx.respond()` only adds an empty `content` to it, and `ctx.state.toolContext.respond()` sends it as it is: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts pinned.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; type CallCtx = FlowCtxOf<"tools:call-tool">; @Plugin({ name: "pinned", description: "Answers get_ticket for pinned tickets from memory" }) export class PinnedPlugin extends DynamicPlugin { private pinned = new Map([["T-1", { id: "T-1", title: "Cannot log in", status: "open" }]]); @ToolHook.Will("execute") async answer(ctx: CallCtx) { const input = ctx.state.toolContext?.input as { id: string; mode?: string }; const ticket = this.pinned.get(input.id); if (!ticket) return; if (input.mode === "toolContext") ctx.state.toolContext?.respond(ticket); // 🚩 sent as it is else ctx.respond(ticket); // 🚩 gets an empty content } } ``` ```ts get-ticket.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string(), mode: z.string().optional() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "(loaded)", status: "open" }; } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { GetTicket } from "./get-ticket.tool"; import { PinnedPlugin } from "./pinned.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [PinnedPlugin] }) export class HelpDeskApp {} ``` ```ts pinned.test.ts import { test, expect } from "@frontmcp/testing"; test("🚩 ctx.respond(value): content is empty", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result.raw).toMatchObject({ content: [], id: "T-1", title: "Cannot log in" }); expect(result.raw.structuredContent).toBeUndefined(); }); test("🚩 toolContext.respond(value): no content at all", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1", mode: "toolContext" }); expect(result.raw).toMatchObject({ id: "T-1", title: "Cannot log in" }); expect(result.raw.content).toBeUndefined(); }); ``` TypeScript refuses a plain object in `ctx.respond()`, which wants a result with `content`, but not a value typed `any`, like this `Map` or anything from `JSON.parse()`. Build the result from the value: `ctx.respond({ content: [{ type: "text", text: JSON.stringify(value) }], structuredContent: value })`, as the cache in [Answering a call from a hook](https://frontmcp.dev/reference/sdk/respond#answering-a-call-from-a-hook) does. ### A text result arrives as `{"0":"C","1":"l",…}` `this.respond("Closed T-1.")` sends the string as the whole result. A result must be an object, so the string is spread into one, a key per character: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { this.respond(`Closed ${id}.`); // 🚩 a string isn't a result } } ``` ```ts close-ticket.test.ts import { test, expect } from "@frontmcp/testing"; test("🚩 the string is spread into the result", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result.raw).toMatchObject({ 0: "C", 1: "l", 2: "o" }); expect(result.raw.content).toBeUndefined(); }); ``` Respond with a complete result, `{ content: [{ type: "text", text: "Closed T-1." }] }`, or return an object and let FrontMCP build the result. ### `Prompt execution failed: Prompt output not found` A prompt's `execute()` finished without returning anything: a code path has no `return`. Return the messages on every path, or end early with `this.respond()`, as in the [Prompt tab above](https://frontmcp.dev/reference/sdk/respond#ending-early-everywhere-else). (Before 1.9.3, calling `this.respond()` in a prompt failed this way too.) ### `Resource "…" read failed: Resource output not found` The same for a resource: `execute()` finished without a `return`. Return `{ contents: [...] }`, or end early with `this.respond()`. (Before 1.9.3, calling `this.respond()` in a resource failed this way too.) ### The call carries on after `this.respond()` `this.respond()` throws to end `execute()`, and a `try`/`catch` around it catches that. The call then continues after the `catch`, and whatever `execute()` returns is the result: Initial call configuration: ```js { tool: "reopen_ticket", arguments: { id: "T-1" } } ``` ```ts reopen-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "reopen_ticket", description: "Reopen a closed ticket", inputSchema: { id: z.string() } }) export class ReopenTicket extends ToolContext { async execute({ id }: { id: string }) { try { if (id === "T-1") this.respond({ content: [{ type: "text", text: `${id} is already open.` }] }); return { id, reopened: true }; } catch { // 🚩 this also catches respond() return { id, reopened: false }; } } } ``` ```ts reopen.test.ts import { test, expect } from "@frontmcp/testing"; test("🚩 the catch swallowed respond()", async ({ mcp }) => { const result = await mcp.tools.call("reopen_ticket", { id: "T-1" }); expect(result.json()).toEqual({ id: "T-1", reopened: false }); }); ``` Call `this.respond()` outside the `try`, or rethrow what you didn't expect in the `catch`. [`this.fail()`](https://frontmcp.dev/reference/sdk/fail#a-tool-call-succeeds-after-thisfail) has the same catch. ### The client got a result that doesn't match `outputSchema` `outputSchema` is checked for returned values only. A result passed to `this.respond()`, or to `ctx.respond()` in a hook, is sent as it is, and a `Did("execute")` hook sees it as the tool's output: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1", how: "respond" } } ``` ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string(), how: z.enum(["respond", "return"]) }, outputSchema: { id: z.string(), status: z.enum(["open", "closed"]) }, }) export class CloseTicket extends ToolContext { async execute({ id, how }: { id: string; how: "respond" | "return" }) { const result = { id, status: "done" as "closed" }; // 🚩 not a status the schema allows if (how === "respond") { this.respond({ content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result }); } return result; } } ``` ```ts audit.plugin.ts import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; export const audited: unknown[] = []; @Plugin({ name: "audit", description: "Records every tool's output" }) export class AuditPlugin extends DynamicPlugin { @ToolHook.Did("execute") async record(ctx: FlowCtxOf<"tools:call-tool">) { audited.push(ctx.state.toolContext?.output); } } ``` ```ts help-desk.app.ts import { App } from "@frontmcp/sdk"; import { AuditPlugin } from "./audit.plugin"; import { CloseTicket } from "./close-ticket.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket], plugins: [AuditPlugin] }) export class HelpDesk {} ``` ```ts output.test.ts import { test, expect } from "@frontmcp/testing"; import { audited } from "./audit.plugin"; test("🚩 respond(): sent unchecked, and the hook sees the whole result", async ({ mcp }) => { audited.length = 0; const result = await mcp.tools.call("close_ticket", { id: "T-1", how: "respond" }); expect(result.raw.structuredContent).toEqual({ id: "T-1", status: "done" }); expect(audited[0]).toMatchObject({ content: expect.any(Array), structuredContent: { id: "T-1", status: "done" } }); }); test("✅ return: checked against outputSchema", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1", how: "return" }); expect(result).toBeError("INVALID_OUTPUT"); }); ``` Return the value to have it checked. A hook that reads `toolContext.output` gets the whole result after `this.respond()`, and the output value after a `return`: check for `content` if it has to handle both. --- # FrontMcpInstance > The class that builds and runs a server from its @FrontMcp configuration, over HTTP, stdio, a Unix socket, a serverless handler or in-process. Source: https://frontmcp.dev/reference/sdk/frontmcp-instance [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `FrontMcpInstance` builds a server from the configuration you give [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp) and runs it. You rarely call it: importing a class decorated with `@FrontMcp` calls `FrontMcpInstance.bootstrap()`, which builds the server and starts an HTTP server. Its other static methods run the same configuration in other ways: over stdio, on a Unix socket, as a handler for a serverless platform or a Worker, or [in your own process](https://frontmcp.dev/reference/sdk/create). ```ts await FrontMcpInstance.bootstrap(config) ``` --- ## Reference ### `FrontMcpInstance.bootstrap(config)` Call `bootstrap()` to start the HTTP server yourself, for example after connecting to a database. Set `serve: false` on `@FrontMcp`, so importing the class doesn't start a second server, and pass the same configuration: ```ts main.ts import "reflect-metadata"; import { FrontMcp, FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; import { connectToDatabase } from "./db"; @FrontMcp({ ...config, serve: false }) export default class HelpDeskServer {} await connectToDatabase(); await FrontMcpInstance.bootstrap(config); ``` [See more examples below.](https://frontmcp.dev/reference/sdk/frontmcp-instance#usage) Starting a server needs a real port and a Node process, so the Playground can't run `bootstrap()`, `createHandler()`, `runStdio()` or `runUnixSocket()`. What this page says about them was checked by running them in Node. #### Parameters | Parameter | Type | Description | | --- | --- | --- | | `config` | `FrontMcpConfigInput` | The object you'd pass to `@FrontMcp`: [`info`, `apps`](https://frontmcp.dev/reference/sdk/frontmcp#options) and the rest. Not the decorated class: a class fails with a `ZodError`, [`Invalid input: expected object, received undefined`](https://frontmcp.dev/reference/sdk/frontmcp-instance#invalid-input-expected-object-received-undefined). | #### Returns A promise that resolves once the server is listening, and logs `MCP HTTP (Express) on 127.0.0.1:3000`. It rejects when the configuration is invalid or the server can't listen, for example `listen EADDRINUSE: address already in use 127.0.0.1:3000` when the port is taken. `bootstrap()` returns nothing to stop the server with. The process ends it. ### What `@FrontMcp` does on import When the decorated class is defined, `@FrontMcp` checks the configuration, records it on the class, and then, depending on the environment: | Environment | What happens | | --- | --- | | `FRONTMCP_SCHEMA_EXTRACT` set | Nothing more. The configuration is only recorded. | | `FRONTMCP_STDIO` set | [`runStdio(config)`](https://frontmcp.dev/reference/sdk/frontmcp-instance#frontmcpinstancerunstdioconfigorclass): serve one client over stdin and stdout. Nothing, with `serve: false`. | | `FRONTMCP_SERVERLESS` set | [`createHandler(config)`](https://frontmcp.dev/reference/sdk/frontmcp-instance#frontmcpinstancecreatehandlerconfig), or `createFetchHandler(config)` if `FRONTMCP_WORKER` is set too. The handler is kept for [`getServerlessHandler()`](https://frontmcp.dev/reference/sdk/frontmcp-instance#getserverlesshandler-and-getserverlesshandlerasync). | | None of those | `bootstrap(config)`, unless the configuration has `serve: false`. | A flag counts as set when its value is `1` or `true`; `yes` or `on` don't count. `frontmcp build` relies on these: its Vercel and Lambda entry files set `FRONTMCP_SERVERLESS=1` before importing your server and export the handler from `getServerlessHandlerAsync()`, and its Cloudflare entry sets `FRONTMCP_WORKER=1` as well. If starting fails, for example because the port is taken, nothing catches the error: Node reports it and the process exits with code 1. `FRONTMCP_SCHEMA_EXTRACT` is for tools that need your configuration without running your server. `frontmcp build` uses it, and so does this site's Playground, which is why importing an example's `@FrontMcp` class here never opens a port. Read the recorded configuration with `getDecoratorConfig()`: ```ts import { getDecoratorConfig } from "@frontmcp/sdk"; import HelpDeskServer from "./main"; const config = getDecoratorConfig(HelpDeskServer); // the parsed configuration, or undefined ``` ### Static methods | Method | Returns | Use it to | | --- | --- | --- | | `bootstrap(config)` | `Promise` | Start the HTTP server. [See above.](https://frontmcp.dev/reference/sdk/frontmcp-instance#frontmcpinstancebootstrapconfig) | | `createFetchHandler(config)` | `Promise<(request, ctx?, env?) => Promise>` | Serve MCP from a runtime that hands you a web `Request`: Cloudflare Workers, Deno, Bun, a framework's route. See [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler). | | `createHandler(config)` | `Promise`, an Express app | Serve MCP from a platform that calls a Node `(req, res)` handler, like Vercel or AWS Lambda. [See below.](https://frontmcp.dev/reference/sdk/frontmcp-instance#frontmcpinstancecreatehandlerconfig) | | `createDirect(config, { app }?)` | `Promise` | Call tools, resources and prompts from your own code, with no transport. `app` picks an app's own endpoint, and a `workerEnv` in `config` sets the [bindings](https://frontmcp.dev/reference/sdk/create#passing-a-workers-bindings) its calls read (since 1.9.4). See [`create()` and `createDirect()`](https://frontmcp.dev/reference/sdk/create). | | `runStdio(configOrClass)` | `Promise` | Serve one local client over stdin and stdout. [See below.](https://frontmcp.dev/reference/sdk/frontmcp-instance#frontmcpinstancerunstdioconfigorclass) | | `runUnixSocket(options)` | `Promise<{ close }>` | Serve over a Unix socket instead of a TCP port. [See below.](https://frontmcp.dev/reference/sdk/frontmcp-instance#frontmcpinstancerununixsocketoptions) | | `createForGraph(config)` | `Promise` | Build the server without serving it, to inspect it. [See below.](https://frontmcp.dev/reference/sdk/frontmcp-instance#inspecting-a-server-without-serving-it) | | `createForCli(config)` | `Promise` | The same, for the `frontmcp` CLI's own commands: it skips widget compilation and the event and elicitation stores to start faster. | To connect an MCP client in memory instead, use [`connect()`](https://frontmcp.dev/reference/sdk/connect). It isn't a method of `FrontMcpInstance`. Every method except `runStdio()` needs the configuration object. [`connect()`](https://frontmcp.dev/reference/sdk/connect) and `runStdio()` also accept the decorated class. #### `FrontMcpInstance.createHandler(config)` Builds the server without listening, and returns the Express app that would have listened, a Node `(req, res)` handler. Export it from the entry file of a platform that calls one: ```ts api/mcp.ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "../src/config"; export default await FrontMcpInstance.createHandler(config); ``` It serves the same paths as `bootstrap()`, and works with `http.createServer(handler)` too. With `serve: false` in the configuration there's no Express app to return, and the promise resolves to `undefined`. #### `FrontMcpInstance.runStdio(configOrClass)` Serves one client over stdin and stdout, for clients that start your server as a subprocess, like Claude Desktop. It opens no port. ```ts stdio.ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; FrontMcpInstance.runStdio(config); ``` - stdout carries the protocol, so from the moment you call it, `runStdio()` sends `console.log`, `console.info` and `console.debug` to stderr. A `console.log` before the call still writes to stdout and breaks the client. - The promise resolves once the client is connected, not when it disconnects. - On `SIGINT` or `SIGTERM` it shuts the server down and exits with code 0, within 5 seconds. - Callers over stdio are anonymous: `this.auth.isAnonymous` is `true` and `this.auth.user.sub` is `""`. One connection serves [all five MCP revisions](https://frontmcp.dev/reference/server/protocol-versions#revisions-over-stdio): `initialize` opens an older session, while a request naming 2026-07-28 is served statelessly, without HTTP header checks. Until `initialize`, calls that name no revision follow `transport.defaultProtocolVersion` (`"legacy"` by default); afterwards they stay in the session. Callers stay anonymous, but the connection owns the [tasks](https://frontmcp.dev/reference/server/tasks#tasks-over-stdio) it starts. Changed in 1.10.5: before, `server/discover` returned `-32601` over stdio and a 2026-07-28 call received an older-revision result. It now returns discovery and `resultType` results, including `input_required`, and supports request-scoped notifications and `subscriptions/listen`. `notifications/cancelled` aborts a matching inline call's `this.signal` while its request is in flight, or ends its listen stream. It accepts the decorated class as well as the configuration. The class must be imported with `FRONTMCP_STDIO=1` already set, or importing it starts an HTTP server first. #### `FrontMcpInstance.runUnixSocket(options)` Serves over a Unix socket file instead of a TCP port, so only processes on the same machine that can open the file can connect: ```ts const handle = await FrontMcpInstance.runUnixSocket({ ...config, socketPath: "/tmp/help-desk.sock" }); ``` `options` is the configuration plus `socketPath` and an optional `sqlite`. `bootstrap()` does the same when the `FRONTMCP_DAEMON_SOCKET` environment variable names a socket path. On `SIGINT` or `SIGTERM` it [shuts down](https://frontmcp.dev/reference/sdk/frontmcp-instance#lifecycle-and-shutdown) like `bootstrap()`: it deletes the socket file, so new clients can't connect, lets requests in flight finish for up to 5 seconds, and exits with code 0. `handle.close()` runs the same shutdown without exiting: it resolves once requests in flight have finished and the socket file is gone. (Before 1.9.3, `SIGTERM` exited at once and `close()` only deleted the socket file.) #### `getServerlessHandler()` and `getServerlessHandlerAsync()` With `FRONTMCP_SERVERLESS=1`, `@FrontMcp` builds a handler instead of listening. Import the decorated class, then get the handler: ```ts import { getServerlessHandlerAsync } from "@frontmcp/sdk"; import "./main"; // defines the @FrontMcp class const handler = await getServerlessHandlerAsync(); ``` `getServerlessHandlerAsync()` waits until the handler is ready. `getServerlessHandler()` returns it at once, or `null` while it's still being built. Both throw what building threw. Without `FRONTMCP_SERVERLESS`, `getServerlessHandlerAsync()` throws [`Serverless handler is not initialized`](https://frontmcp.dev/reference/sdk/frontmcp-instance#serverless-handler-is-not-initialized). ### Instance members `bootstrap()` builds an instance and starts it. `createForGraph()` returns one that's built and not started. | Member | Description | | --- | --- | | `config` | The parsed configuration, with defaults filled in. | | `getConfig()` | Returns `config`. | | `ready` | A promise that resolves once the server is built: its apps, tools, resources and the rest. On failure, waits for cleanup of the partial server for at most 10 seconds before rejecting with the startup error. Cleanup can still be pending after that deadline. | | `shutdown()` | `Promise`. Waits for initialization to finish or fail, then shuts down and disposes every scope and stops the HTTP host. Repeated calls share the same promise. | | `getScopes()` | The server's [scopes](https://frontmcp.dev/reference/sdk/scope): one for the whole server, or one per app with `splitByApp: true`. | | `getPrimaryScope()` | The scope that `runStdio()` serves, and `createDirect()` and `connect()` without an `app`: the one holding every app that isn't `standalone`, `root`. With `splitByApp: true`, or when every app is `standalone`, the first scope. | | `getAppScope(app)` | The scope an app has of its own, served at `/`: each app's with `splitByApp: true`, a `standalone` app's otherwise. `createDirect()` and `connect()` serve it when given that `app`. Throws `ScopeConfigurationError`, `No endpoint serves app "…" on its own`, for an app without one, naming the apps that have one. New in 1.9.3. | | `start()` | Starts the HTTP server of a built instance, then runs the callbacks registered with `scope.onServerStarted()`. | | `initialize()` | Builds the server. The constructor calls it and keeps the promise in `ready`; don't call it again. | The constructor, `new FrontMcpInstance(config)`, takes a configuration that has already been parsed. Use the static methods, which parse it for you. ### Ports and the entry path | Setting | Where it comes from | | --- | --- | | Port | `http.port`, else the `PORT` [environment variable](https://frontmcp.dev/reference/server/config-files#environment-variables), else `3000`. `PORT` is read when the server's options are parsed, so setting `process.env.PORT` in code before `bootstrap()` works. (Changed in 1.9: before, it was read when `@frontmcp/sdk` was first imported, and a later change had no effect.) | | Address | `127.0.0.1` unless `http.security.bindAddress` or `FRONTMCP_BIND_ADDRESS` says otherwise. See [`http`](https://frontmcp.dev/reference/sdk/frontmcp#http). | | MCP endpoint | `http.entryPath`, else the `FRONTMCP_HTTP_ENTRY_PATH` environment variable, else the root, `/`. | | Health checks | `/healthz`, `/health` and `/readyz`, whatever the entry path. | | With `splitByApp: true` | Each app at `/` under the entry path. The root answers 404. | `frontmcp dev` sets `FRONTMCP_HTTP_ENTRY_PATH` to the path in its own configuration. ### Lifecycle and shutdown `bootstrap()` goes through these steps: 1. Parses the configuration, and fails on anything invalid. 2. Builds the server: its providers, its logger, then its scopes, which build the apps and their tools, resources, prompts, jobs and plugins. This is `ready`. 3. Starts the HTTP server and waits until it listens. 4. Runs every scope's `onServerStarted()` callbacks, then logs `FrontMCP bootstrap complete`. On `SIGINT` or `SIGTERM` it shuts the server down: it stops taking new connections, lets requests in flight finish for up to 5 seconds, closes what the server holds (its Redis connections, and a distributed instance's heartbeat), and exits `0`, or `1` when a step fails or the whole shutdown takes more than 10 seconds. See [stopping a Node server](https://frontmcp.dev/reference/deployment/node#stopping). A server on a Unix socket is the exception: it removes the socket file and exits at once, as described above. (Changed in 1.9.2: before, `bootstrap()` installed no signal handlers, and `SIGTERM` ended the process at once.) Changed in 1.9.7: before, a failed initialization left the scopes it built alive. Now `ready` awaits shutdown before rejecting. This applies to the creation methods above and to `connect*()`. Cleanup failures are logged as `Graceful shutdown: … failed` and never replace the startup error. If cleanup does not settle within 10 seconds, FrontMCP logs `Releasing a server that failed to start did not finish within 10000 ms` and rejects with the startup error; the deadline bounds the wait, not the unfinished cleanup. Changed in 1.9.7: `shutdown()` called during initialization waits for it to settle before collecting scopes. Before, it could finish with an incomplete scope list and leave scopes alive, on either a successful or a failed start. #### Caveats - **Importing a decorated class starts a server.** Keep the configuration in a module of its own, and decorate a class with it only in the entry file, so scripts, tests and workers can import the configuration without starting anything. - **Only `runStdio()` and `connect()` accept the decorated class.** The other methods need the configuration object. Pass `getDecoratorConfig(Server)` if all you have is the class. - **With `splitByApp: true`, or a `standalone` app, only `bootstrap()`, `createHandler()` and `createFetchHandler()` serve every app.** `runStdio()` serves the [primary scope](https://frontmcp.dev/reference/sdk/frontmcp-instance#instance-members) only: the apps that aren't `standalone`, or with `splitByApp: true` the first app. `createDirect()` and `connect()` do too, unless they're given an `app`, which serves that app's own scope. See [Apps, discovery and splitting](https://frontmcp.dev/reference/server/apps). (Changed in 1.9.3: before, `createDirect()` and `connect()` had no `app` option. Changed in 1.9.2: `createFetchHandler()` serves them all; see [its entry paths](https://frontmcp.dev/reference/sdk/create-fetch-handler).) - Declare one `@FrontMcp` per process. Each `bootstrap()` starts another server, and a second one on the same port fails with `EADDRINUSE`. --- ## Usage ### Starting the server after setup A server whose tools need a database connection, or secrets fetched at startup, shouldn't accept calls before they're ready. Keep the configuration in its own module, turn off `serve`, and call `bootstrap()` when you're ready: ```ts config.ts import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], http: { port: 8080, entryPath: "/mcp" }, }; ``` ```ts main.ts import "reflect-metadata"; import { FrontMcp, FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; import { connectToDatabase } from "./db"; @FrontMcp({ ...config, serve: false }) export default class HelpDeskServer {} try { await connectToDatabase(); await FrontMcpInstance.bootstrap(config); } catch (err) { console.error("help-desk didn't start:", err); process.exit(1); } ``` Clients connect to `http://localhost:8080/mcp`. This needs a real port, so run it locally. ### Reading a server's configuration `@FrontMcp` records the configuration on the class, with every default filled in. `getDecoratorConfig()` reads it back, and it's what to pass to the methods that don't take the class. In the Playground, `FRONTMCP_SCHEMA_EXTRACT` is set, so defining `HelpDeskServer` records the configuration and starts nothing; the Playground then serves it with `createFetchHandler()`. Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { HelpDesk } from "./help-desk.app"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] }) export default class HelpDeskServer {} ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` ```ts config.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance, getDecoratorConfig } from "@frontmcp/sdk"; import HelpDeskServer from "./main"; test("the class carries its configuration, with defaults", () => { const config = getDecoratorConfig(HelpDeskServer); expect(config).toMatchObject({ info: { name: "help-desk", version: "1.0.0" }, serve: true, transport: { sessionMode: "stateful", protocol: "legacy" }, }); }); test("the methods that don't take the class take its configuration", async () => { await expect(FrontMcpInstance.createForGraph(HelpDeskServer as never)).rejects.toThrow("Invalid input: expected object, received undefined"); const instance = await FrontMcpInstance.createForGraph(getDecoratorConfig(HelpDeskServer)!); expect(instance.getConfig().info.name).toBe("help-desk"); }); ``` ### Inspecting a server without serving it `createForGraph()` builds the whole server, apps, tools and all, and doesn't serve it. Use it to check what a configuration produces: in a test, or in a script that lists every tool. A server has one scope, `root`, holding every app's tools. With `splitByApp: true` it has one scope per app, each at its own path, and a `standalone` app always gets one of its own. ```ts inspect.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { Billing, BillingApi, HelpDesk } from "./apps"; const info = { name: "help-desk", version: "1.0.0" }; function describe(instance: FrontMcpInstance) { return instance.getScopes().map((scope) => ({ id: scope.id, path: scope.routeBase, tools: scope.tools.getTools().map((tool) => tool.metadata.name), })); } test("one scope holds every app's tools", async () => { const instance = await FrontMcpInstance.createForGraph({ info, apps: [HelpDesk, Billing] }); expect(describe(instance)).toEqual([{ id: "root", path: "", tools: ["get_ticket", "refund_invoice"] }]); }); test("with splitByApp, each app is a scope at its own path", async () => { const instance = await FrontMcpInstance.createForGraph({ info, apps: [HelpDesk, Billing], splitByApp: true }); expect(describe(instance)).toEqual([ { id: "help-desk", path: "/help-desk", tools: ["get_ticket"] }, { id: "billing", path: "/billing", tools: ["refund_invoice"] }, ]); expect(instance.getPrimaryScope()?.id).toBe("help-desk"); }); test("a standalone app gets a scope of its own; createDirect() serves the primary one, or that one with `app`", async () => { const config = { info, apps: [BillingApi, HelpDesk] }; const instance = await FrontMcpInstance.createForGraph(config); expect(describe(instance)).toEqual([ { id: "billing-api", path: "/billing-api", tools: ["refund_invoice"] }, { id: "root", path: "", tools: ["get_ticket"] }, ]); expect(instance.getPrimaryScope()?.id).toBe("root"); expect(instance.getAppScope("billing-api").routeBase).toBe("/billing-api"); expect(() => instance.getAppScope("help-desk")).toThrow('No endpoint serves app "help-desk" on its own'); const server = await FrontMcpInstance.createDirect(config); const { tools } = await server.listTools(); await server.dispose(); expect(tools.map((tool) => tool.name)).toEqual(["get_ticket"]); const billing = await FrontMcpInstance.createDirect(config, { app: "billing-api" }); const billingTools = await billing.listTools(); await billing.dispose(); expect(billingTools.tools.map((tool) => tool.name)).toEqual(["refund_invoice"]); }); ``` ```ts apps.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "open" }; } } @Tool({ name: "refund_invoice", description: "Refund an invoice", inputSchema: { invoice: z.string() } }) class RefundInvoice extends ToolContext { async execute({ invoice }: { invoice: string }) { return { invoice, refunded: true }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} @App({ id: "billing", name: "Billing", tools: [RefundInvoice] }) export class Billing {} @App({ id: "billing-api", name: "Billing API", tools: [RefundInvoice], standalone: true }) export class BillingApi {} ``` A scope's registries are described in [Scope and registries](https://frontmcp.dev/reference/sdk/scope). ### Serving over stdio Local clients like Claude Desktop and Cursor can start your server as a subprocess and talk to it over stdin and stdout. Give them an entry file that calls `runStdio()` with the configuration, and never imports the decorated class: ```ts stdio.ts import "reflect-metadata"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; FrontMcpInstance.runStdio(config); ``` ```json claude_desktop_config.json { "mcpServers": { "help-desk": { "command": "node", "args": ["/abs/path/dist/stdio.js"] } } } ``` Or keep one entry file and set `FRONTMCP_STDIO=1` in the client's configuration (`"env": { "FRONTMCP_STDIO": "1" }`): `@FrontMcp` then calls `runStdio()` instead of starting an HTTP server. Anything the server writes to stdout that isn't a protocol message breaks the client, so log to stderr. --- ## Troubleshooting ### `Invalid input: expected object, received undefined` The error is a `ZodError` with `invalid_union`, whose issues are at `info` and at `apps` (`expected array, received undefined`). You passed the decorated class to a method that takes the configuration object: `bootstrap()`, `createFetchHandler()`, `createDirect()` or `createForGraph()`. The class has no `info` or `apps` of its own: they're recorded on it. (Changed in 1.9: before, the issue was `expected object, received function`.) ```ts // 🚩 The class await FrontMcpInstance.createDirect(HelpDeskServer); // ✅ Its configuration await FrontMcpInstance.createDirect(getDecoratorConfig(HelpDeskServer)!); ``` Better, export the configuration from a module of its own and pass that. [The first Playground](https://frontmcp.dev/reference/sdk/frontmcp-instance#reading-a-servers-configuration) shows both. ### `listen EADDRINUSE: address already in use 127.0.0.1:3000` Something already listens on the port: another copy of your server, or a second `bootstrap()` in the same process, often because a script imported the decorated class and then called `bootstrap()` too. Stop the other process, or choose another port with `http.port` or the `PORT` environment variable. Set `PORT` in the environment that starts the process, or in code before the server starts. ### A script that imports my server starts listening Importing a class decorated with `@FrontMcp` starts a server. Import the configuration module instead, or set `serve: false` on the decorator and start the server where you want it with `bootstrap()`. ### `Serverless handler is not initialized` `getServerlessHandlerAsync()` found no handler, because `FRONTMCP_SERVERLESS` wasn't set to `1` or `true` when the decorated class was defined. Without it, `@FrontMcp` called `bootstrap()` and started an HTTP server instead. Set the variable in the platform's environment, or skip the decorator and call `createHandler(config)` or [`createFetchHandler(config)`](https://frontmcp.dev/reference/sdk/create-fetch-handler) yourself. ### My client gets `404` or `Cannot POST /mcp` The MCP endpoint is at the root unless `http.entryPath` is set. See [the `@FrontMcp` entry](https://frontmcp.dev/reference/sdk/frontmcp#my-client-gets-404-or-cannot-post-mcp). ### Only my first app is reachable The server uses `splitByApp: true` and is served by `runStdio()`, or by `createDirect()` or `connect()` without an `app`, which use the first scope only. Pass `{ app }` to `createDirect()` or `connect()` for another app's scope, serve the server with `bootstrap()`, `createHandler()` or `createFetchHandler()`, which give each app its path, or turn `splitByApp` off. ### `Releasing a server that failed to start did not finish within 10000 ms` Initialization failed, and cleanup is still pending after its 10-second deadline. `ready` rejects with the original startup error, so fix that error first. Check provider disposal, disconnect hooks and storage shutdown for work that never settles. The deadline stops waiting; it does not cancel the unfinished cleanup. New in 1.9.7. --- # createFetchHandler() > Serve an MCP server from any runtime that speaks web Request and Response, like Cloudflare Workers, Deno, Bun or a framework's route. Source: https://frontmcp.dev/reference/sdk/create-fetch-handler [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `FrontMcpInstance.createFetchHandler(config)` turns a server into one function: it takes a web-standard `Request` and returns a `Response`. Anything that hands you a `Request` can serve MCP with it: a Cloudflare Worker, `Deno.serve()`, `Bun.serve()`, or a route in a framework like Hono or Next.js. It needs no Node HTTP server and opens no port. This site's Playground runs every example through it. ```ts const handler = await FrontMcpInstance.createFetchHandler(config) const response = await handler(request, ctx?, env?) ``` --- ## Reference ### `FrontMcpInstance.createFetchHandler(config)` Create the handler once, at the top of your entry module, and call it for every request: ```ts worker.ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; const handler = FrontMcpInstance.createFetchHandler(config); export default { async fetch(request: Request, env: unknown, ctx: { waitUntil(promise: Promise): void }) { return (await handler)(request, ctx, env); }, }; ``` [See more examples below.](https://frontmcp.dev/reference/sdk/create-fetch-handler#usage) #### Parameters | Parameter | Type | Description | | --- | --- | --- | | `config` | `FrontMcpConfigInput` | The object you'd pass to [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp#options). Not the decorated class, which fails with [`expected object, received undefined`](https://frontmcp.dev/reference/sdk/frontmcp-instance#invalid-input-expected-object-received-undefined). | #### Returns A promise of the handler. `createFetchHandler()` builds the server when it's called, and keeps it for every request. That's when the configuration is checked and the startup checks run, as for [`createDirect()`](https://frontmcp.dev/reference/sdk/create), so a server that can't start rejects the promise, and there's no handler: an invalid configuration, an entry whose `approval`, `featureFlag` or `authorities` nothing enforces, or a missing `JWT_SECRET` in production. See [Catching a misconfigured server at startup](https://frontmcp.dev/reference/sdk/create-fetch-handler#catching-a-misconfigured-server-at-startup). On an edge isolate, like a Cloudflare Worker, the server can't be built while the module loads, so it's built when the first request arrives instead. `createFetchHandler()` then only runs [`assertStaticStartupConfig(config)`](https://frontmcp.dev/reference/sdk/create-fetch-handler#assertstaticstartupconfigconfig), and throws what it throws. Changed in 1.8.4: before, the server was built on the first request everywhere, so a misconfigured server's first request failed instead of `createFetchHandler()`. Changed in 1.9.7: before, each failed deferred build could leave another server's timers and memory behind. A failed build now [awaits cleanup of its partly built scopes](https://frontmcp.dev/reference/sdk/frontmcp-instance#lifecycle-and-shutdown) for at most 10 seconds before the error reaches the caller or the deferred handler answers. Cleanup does not replace the build error. A retry can begin after that wait, even if cleanup is still pending when the deadline expires. Each call to `createFetchHandler()` builds a separate server, with its own providers and state. Create one per process. ### `assertStaticStartupConfig(config)` Throws the startup error that the configuration's decorators alone show, without building the server: `AuthConfigurationError` for an entry with `authorities` on a server without the `authorities` option, or with a rule that names a profile the option doesn't define, then `UnenforcedMetadataError` for a plugin's option, like `approval`, that no plugin reaching the entry enforces. It returns nothing when it finds none. `createFetchHandler()` calls it on an edge isolate, and so can a test or a build step. (Changed in 1.9.2: before, it missed an unknown profile name, which only the server's build caught.) It's one-sided: a configuration it accepts can still fail when the server is built, like one with an invalid `info`, because it doesn't validate the configuration itself. On an edge isolate, a failed build answers the request instead: | The build failed with | Response | | --- | --- | | A configuration fault: an invalid configuration (`CONFIG_INVALID`), a failed startup check (`AUTH_CONFIGURATION_ERROR`, `UNENFORCED_METADATA`) or a [missing secret](https://frontmcp.dev/reference/sdk/create-fetch-handler#production-secrets) | `500` `{ "error": "server_misconfigured", "code", "message" }` | | Anything else, like a provider whose factory throws | `503` `{ "error": "server_unavailable", "code": "SERVER_START_FAILED", "message" }`, with `Retry-After` | Later requests try the build again, waiting longer after each failure, from 1 second up to 60. For an edge entry point of your own, `@frontmcp/sdk` exports the same pieces: `createDeferredServerBuild(build)`, whose `get()` builds once and keeps the result or the failure, and `startupFailureResponse(error, retryAfterSeconds?)`, which makes the `500` or `503` above. The Playground isn't an edge isolate, so this was checked in Node with the `EdgeRuntime` global that marks one. Changed in 1.8.5: before, only the recognized faults and secrets got a `500`, any other failure rejected the request's promise, and every request rebuilt at once. ### `createWebFetchHandler(scope, options?)` The lower-level function under `createFetchHandler()`, also exported from `@frontmcp/sdk`. It takes one endpoint of a server that's already built, a scope, instead of a configuration, and returns the same kind of [handler](https://frontmcp.dev/reference/sdk/create-fetch-handler#handlerrequest-ctx-env), at once rather than as a promise: ```ts worker.ts import { FrontMcpInstance, createWebFetchHandler } from "@frontmcp/sdk"; import { config } from "./config"; type Scope = Parameters[0]; const instance = await FrontMcpInstance.createForGraph(config); const handler = createWebFetchHandler(instance.getPrimaryScope() as Scope, { entryPath: ["/mcp", "/v1/mcp"] }); ``` `instance.getPrimaryScope()` is the endpoint that serves the server's own apps, and `instance.getAppScope("billing")` a [standalone](https://frontmcp.dev/reference/sdk/app#standalone-apps) app's. `@frontmcp/sdk` doesn't export the `Scope` type, hence the cast. Each request runs through the same flow as with `createFetchHandler()`. | Option | Default | Description | | --- | --- | --- | | `entryPath` | `http.entryPath`, else `/` | Where MCP is served. A list serves it at every path in it. | | `healthPaths` | none | Paths that answer `200` `{ "status": "ok", "server", "transport": "web-fetch" }`, with no probes run. When it's set, `health`'s own paths, `/healthz` and `/readyz`, aren't answered unless it lists them. | | `cors` | `http.cors` | CORS for this handler: `origin`, `credentials` and `maxAge`, and also `methods`, `headers` and `exposeHeaders`, the lists of the `Access-Control-Allow-*` and `-Expose-Headers` headers. | | `sessionRouter` | none | `(request, env, ctx) => Promise`, offered every MCP request before the handler serves it, for sending sessions to a stateful host such as a Durable Object. A `Response` it returns is the answer; `undefined` lets the handler serve the request. Health checks and CORS preflights don't reach it. | | `metrics` | none | `{ service, config }`, for `/metrics`. `@frontmcp/sdk` doesn't export the service, so in practice this handler serves no `/metrics`. | Use `createFetchHandler()` unless you need one of these options. Compared with it, `createWebFetchHandler()`: - **Serves one endpoint.** A server with a standalone app, or with `splitByApp`, has several, and `createFetchHandler()` serves each at its path. This handler serves only the scope you pass, at its `entryPath`: a standalone app's scope too, unless you give it the app's path. - **Builds nothing.** The scope must already be built, so there's none of `createFetchHandler()`'s deferred build on an edge isolate, with its retries and its `500` and `503` answers. - **Serves no `/metrics`.** The handler's type is `WebFetchHandler`, `(request, ctx?, env?) => Promise`, and its options' are `CreateWebFetchHandlerOptions`, `WebFetchCorsOptions` and `WebFetchSessionRouter`, all exported. See [Building the handler from a scope](https://frontmcp.dev/reference/sdk/create-fetch-handler#building-the-handler-from-a-scope). ### `handler(request, ctx?, env?)` | Parameter | Type | Description | | --- | --- | --- | | `request` | `Request` | The incoming request. | | `ctx` | `FetchHandlerCtx` | Optional. What your runtime knows about the request. FrontMCP uses three things from it: `waitUntil(promise)` (Cloudflare Workers), which it calls to finish a streamed response to an older client after the handler returns; `remoteAddr.hostname` (Deno's `info`) and `requestIP(request)` (Bun's `server`), for [the client's address](https://frontmcp.dev/reference/sdk/create-fetch-handler#the-clients-address). | | `env` | `unknown` | Optional. Cloudflare's bindings. FrontMCP passes them to its authentication flows, which reach their stores through it, and to your tools as [`this.workerEnv`](https://frontmcp.dev/reference/sdk/create-fetch-handler#reading-a-workers-bindings). | Note the order: a Worker's `fetch(request, env, ctx)` takes `env` before `ctx`, and the handler takes `ctx` first. It returns a promise of a `Response`. MCP errors, bad requests and failed authentication are all responses, with a status, and so are a missing [production secret](https://frontmcp.dev/reference/sdk/create-fetch-handler#production-secrets) and, on an edge isolate, a server that [can't be built](https://frontmcp.dev/reference/sdk/create-fetch-handler#assertstaticstartupconfigconfig). #### What it answers | Request | Response | | --- | --- | | `POST` to the MCP endpoint | The MCP response: JSON, or an event stream when the call streams progress or logs. | | `GET` to the MCP endpoint | An event stream for older clients. Without `Accept: text/event-stream`, `406` `Not Acceptable: Client must accept text/event-stream`. | | `DELETE` to the MCP endpoint | `404` `Session not found`: there are no sessions to end. [See below.](https://frontmcp.dev/reference/sdk/create-fetch-handler#sessions) | | `OPTIONS`, with `http.cors` set | `204` and [the CORS headers](https://frontmcp.dev/reference/sdk/create-fetch-handler#cors-and-host-checks). A configured server card answers its own preflight with `204` and CORS `*`, including without `http.cors`. Without `http.cors`, `405` `Method not allowed.` at the MCP endpoint. | | `GET /healthz` or `/health` | `200` `{ "status": "ok", "server": { name, version }, "transport": "web-fetch" }`, with `Cache-Control: no-store`. | | `GET /readyz` | `200` with the readiness report, `{ "status": "ready", "catalog": { … }, "probes": { … }, "transport": "web-fetch" }`, or `503` with `"not_ready"` when a probe fails. See [Health checks](https://frontmcp.dev/reference/sdk/create-fetch-handler#health-checks). | | `GET /metrics`, with `metrics` on | The scrape, as on FrontMCP's Node server. See [Metrics](https://frontmcp.dev/reference/server/observability#metrics). (Since 1.9; before, `404`.) | | A `POST` with a body over `http.bodyLimit` | `413` `{ "jsonrpc": "2.0", "error": { "code": -32600, "message": "Payload Too Large", "data": { limit, length } }, "id": null }`. The default limit is `4mb`. | | Registry documents | With `registry` configured, the domain proof at `/.well-known/mcp-registry-auth`, the card at `/server-card` and the AI Catalog at `/.well-known/ai-catalog.json`. See [MCP Registry](https://frontmcp.dev/reference/deployment/mcp-registry#documents-served). | | Anything else | `404` `{ "error": "Not Found", "entryPaths": ["/"] }`, with every MCP endpoint's path in `entryPaths`, unless an auth route, `http.routes` handler or channel webhook claims it. | The MCP endpoint is `http.entryPath`, or `/` if you don't set one; a trailing slash doesn't matter. A server with one endpoint serves MCP at that path only. A server with more serves each where FrontMCP's Node server does: a [`standalone`](https://frontmcp.dev/reference/sdk/app#standalone-apps) app at `/`, like `/billing`, and the other apps at the entry path (with no tools, when every app is standalone); with [`splitByApp: true`](https://frontmcp.dev/reference/sdk/frontmcp), each app at `/`, and nothing at the entry path itself. See [Answering requests that aren't MCP calls](https://frontmcp.dev/reference/sdk/create-fetch-handler#answering-requests-that-arent-mcp-calls). Each of those endpoints answers its own OAuth discovery documents, like `/.well-known/oauth-protected-resource/billing`. (Changed in 1.9.3: before, with `splitByApp` or only standalone apps, the first endpoint was also served at the entry path, and a 404's `entryPaths` listed one endpoint's paths. Changed in 1.9.2: before, the handler served only the first app, and a standalone app wasn't served at all.) Every response carries `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY`, and none carries `X-Powered-By`. [Security](https://frontmcp.dev/reference/deployment/security) covers how to change them. (New in 1.8.7.) `http.bodyLimit` accepts a byte count or a string such as `"512kb"` or `"8mb"`, with `"4mb"` (4 MiB) as its default. A limit above 4 MiB also reaches the MCP transport for older clients; it doesn't impose a second, smaller limit. See [limiting request size](https://frontmcp.dev/reference/deployment/security#limiting-request-size) for larger inputs and `413` above the configured limit. #### Request headers MCP 2026-07-28 clients repeat parts of the request body in headers, so a proxy can route without reading the body. The handler checks them before anything else runs: | Header | Required | Checked | | --- | --- | --- | | `MCP-Protocol-Version` | On every 2026-07-28 request. | Must be there. | | `Mcp-Method` | On every 2026-07-28 request. | Must be there. | | `Mcp-Name` | On `tools/call`, `prompts/get` and `resources/read`. | Must equal the body's tool or prompt name, or resource URI. | A missing or different header gets `400`, with JSON-RPC error `-32020` and a message that says which: `Header mismatch: the mcp-method header is required`, or `Header mismatch: mcp-name header value 'close_ticket' does not match body value 'get_ticket'`. A body that isn't JSON gets `400` with `-32600` `Invalid Request: missing method`. Other headers reach your tools: | Header | Becomes | | --- | --- | | `Authorization` | The caller, checked by your [`auth`](https://frontmcp.dev/reference/sdk/frontmcp#auth) mode. | | `traceparent` | [`this.context.traceContext`](https://frontmcp.dev/learn/reading-the-request): the client's trace continues. Without it, each request starts a new trace. | | `User-Agent`, `Content-Type`, `Accept` | `this.context.metadata.userAgent`, `contentType` and `accept`. | | `x-frontmcp-*` | `this.context.metadata.customHeaders`, by lowercase name. | The client's name and version come from each request's `_meta`, as `io.modelcontextprotocol/clientInfo`, and become `this.clientInfo` and `this.platform`. [Reading the Request](https://frontmcp.dev/learn/reading-the-request) tests all of these. #### Sessions The handler keeps no sessions. MCP 2026-07-28 has none, so its clients lose nothing: each request carries everything it needs. An anonymous caller is a new `anon:` user on every request. Clients on older protocol versions work too, one request at a time. Their `initialize` succeeds, but the response has no `Mcp-Session-Id`, and each later request is handled on its own, as an event stream. None of them is in a session (`this.context.verifiedSessionId` is `undefined`), so anything FrontMCP keeps per session for those clients, like a [`CONTEXT` provider](https://frontmcp.dev/reference/sdk/provider#scopes), starts over with every request. They're also the only clients for which a server in production needs `MCP_SESSION_SECRET`. A request that names no protocol version, in its headers or its body, is served as 2026-07-28. #### In a browser FrontMCP's browser build has no `AsyncLocalStorage`. Without the TC39 `AsyncContext`, it serves **one request at a time**: a request keeps its turn until it, and everything it started, has finished. Two calls sent at once take turns, a background job runs before the next request is served, and the same goes for [`createDirect()`](https://frontmcp.dev/reference/sdk/create) and [`connect()`](https://frontmcp.dev/reference/sdk/connect) calls. Inside one request, two calls at once, like two `this.callTool()` in a `Promise.all`, fail with `AsyncContextOverlapError`: run them one after the other. Node and Cloudflare Workers keep their own async context, and aren't affected. This site's Playground provides a minimal `AsyncContext`, so its calls overlap as they do on Node, as [Serving calls at the same time](https://frontmcp.dev/reference/sdk/create-fetch-handler#serving-calls-at-the-same-time) shows; the one-at-a-time mode was checked in a browser worker without it. Changed in 1.8.4: before, a request that arrived while another was waiting could run with that request's context: its caller, its session and its running tool. #### The client's address `this.context.metadata.clientIp`, which [IP filters](https://frontmcp.dev/reference/sdk/frontmcp#setting-defaults-for-every-tool) also use, comes from the runtime when it says who connected, and from proxy headers only when you say the proxy can be trusted: | Source | Used | | --- | --- | | `ctx.requestIP(request).address` (Bun) | Always. | | `ctx.remoteAddr.hostname` (Deno) | Always. | | The `CF-Connecting-IP` header | On Cloudflare Workers, where the request has a `cf` property. Elsewhere anyone could send the header, so it's ignored. | | `X-Forwarded-For`, or else `X-Real-IP` | Only with the environment variable `FRONTMCP_TRUST_PROXY` set to `1`, `true`, `yes` or `on`. The address is the last entry of `X-Forwarded-For`, the one your proxy added; with `FRONTMCP_TRUSTED_PROXY_DEPTH=2` it's the second from the end, for two proxies. | A value that isn't an IP address is ignored. With none of these, `clientIp` is `undefined`. Set `FRONTMCP_TRUST_PROXY` only when every request comes through a proxy that sets the header, or clients can put any address in it. #### CORS and host checks Changed in 1.10.0: before, the fetch handler replaced a response's CORS with its configured policy. A response that sets `Access-Control-Allow-Origin` now keeps its own CORS headers. The [server card](https://frontmcp.dev/reference/deployment/mcp-registry#serving-the-proof-and-discovery-documents) uses `*` and handles its own preflight, even when `http.cors` restricts other responses to specific origins. The handler reads the same `http` options as FrontMCP's Node server: - **`http.cors`**: `{ origin, credentials?, maxAge? }`. `origin` is `true` (reflect the request's origin), `"*"`, one origin, or a list. A preflight from a listed origin gets `204` with `Access-Control-Allow-Origin`, `-Methods` (`GET, POST, OPTIONS, DELETE`), `-Headers` (the ones the browser asked for) and `-Expose-Headers` (`Mcp-Session-Id, WWW-Authenticate`); other origins get `204` with none of them. Without `http.cors`, the handler adds no CORS headers; a response can still set its own, as the server card does. An `origin` function isn't supported here: the configured CORS policy stays off, and a warning is logged. - **`http.security.dnsRebindingProtection`**: with `allowedHosts` or `allowedOrigins`, a request for any other host gets `403` `{ "error": "Forbidden", "message": "Invalid Host header" }`. #### Production secrets With `NODE_ENV=production`, FrontMCP refuses to make up secrets. For the session secret, which only clients on protocol versions before 2026-07-28 need, the handler answers `500` with what to fix: ```json { "error": "server_misconfigured", "code": "SESSION_SECRET_REQUIRED", "message": "Set MCP_SESSION_SECRET in the deployment environment (e.g. `wrangler secret put MCP_SESSION_SECRET`). Session IDs are encrypted with it, and production refuses the development machine-id fallback." } ``` | Code | When | Health checks | | --- | --- | --- | | `SESSION_SECRET_REQUIRED` | `MCP_SESSION_SECRET` isn't set, and a request declares a protocol version before 2026-07-28. 2026-07-28 requests work without it, anonymous and static-key callers included. (Changed in 1.8.5: before, every server needed it, and every request got the `500`.) | Still answer `200`. | | `JWT_SECRET_REQUIRED` | `JWT_SECRET` isn't set, and the auth mode signs tokens, like `local`. | The server can't be built: `createFetchHandler()` rejects with a `JwtSecretRequiredError`, whose `code` is this one. On an edge isolate, every request gets the `500`, health checks included. | | `JWT_SECRET_INVALID` | `JWT_SECRET` is shorter than 32 bytes. | The same, with a `JwtSecretWeakError`. | On Cloudflare, set them with `wrangler secret put`. Outside production, FrontMCP falls back to a secret derived from the machine for sessions, and a random one for tokens, which doesn't survive a restart. The Playground can't switch to production, so this was checked in Node. #### Caveats - **Health checks are `/healthz`, `/health` and `/readyz`** by default, as on FrontMCP's Node server. `health` renames or turns them off: see [Health checks](https://frontmcp.dev/reference/sdk/create-fetch-handler#health-checks). - **The runtime controls the listener.** `http.port`, `http.socketPath` and the bind address don't apply. `http.routes` and channel webhooks are served, behind IP filtering and session verification. For a streaming route, the `Response` is returned once its headers are written; an error after that errors the response body. Changed in 1.9.5: before, custom routes answered `404`. - **With `splitByApp: true`, nothing is served at the entry path itself**, as on the Node server: point each client at its app's path. - **OAuth routes pass on the cookies FrontMCP sets.** In `local` mode, `/oauth/authorize` answers with a `Set-Cookie` that ties the sign-in to the browser that started it (`frontmcp_signin_…`, `HttpOnly`, `SameSite=Lax`, on `/oauth`), and the sign-in only finishes when the browser sends it back. A script can't read `Set-Cookie` from a response in a browser, so this was checked in Node. - **Keep one handler per process.** Each `createFetchHandler()` builds a new server, so state in providers isn't shared between two handlers. - On Cloudflare, pass `ctx`: FrontMCP calls its `waitUntil()` so a streamed response to an older client can finish after the handler returns. --- ## Usage ### Serving MCP from a Cloudflare Worker The Worker passes its `ctx` and `env` along, in the handler's order. The tests send it the requests an MCP client would; `requests.ts` builds them. ```ts worker.ts active import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; type Ctx = { waitUntil(promise: Promise): void }; const handler = FrontMcpInstance.createFetchHandler(config); export default { async fetch(request: Request, env: unknown, ctx: Ctx): Promise { return (await handler)(request, ctx, env); }, }; ``` ```ts worker.test.ts import { test, expect } from "@frontmcp/testing"; import worker from "./worker"; import { mcpRequest } from "./requests"; const ctx = { waitUntil() {} }; test("calls a tool at /mcp", async () => { const res = await worker.fetch(mcpRequest("https://desk.example.com/mcp", "tools/call", { name: "get_ticket", arguments: { id: "T-1" } }), {}, ctx); const body = await res.json(); expect(body.result.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" }); }); test("answers health checks", async () => { const res = await worker.fetch(new Request("https://desk.example.com/healthz"), {}, ctx); expect(await res.json()).toEqual({ status: "ok", server: { name: "help-desk", version: "1.0.0" }, transport: "web-fetch" }); }); test("anything else is a 404 that names the endpoint", async () => { const res = await worker.fetch(mcpRequest("https://desk.example.com/", "tools/list"), {}, ctx); expect(res.status).toBe(404); expect(await res.json()).toEqual({ error: "Not Found", entryPaths: ["/mcp"] }); }); test("a tool name that doesn't match its header is refused", async () => { const request = mcpRequest("https://desk.example.com/mcp", "tools/call", { name: "get_ticket", arguments: { id: "T-1" } }, { "mcp-name": "close_ticket" }); const res = await worker.fetch(request, {}, ctx); expect(res.status).toBe(400); expect((await res.json()).error).toEqual({ code: -32020, message: "Header mismatch: mcp-name header value 'close_ticket' does not match body value 'get_ticket'", }); }); ``` ```ts requests.ts // What an MCP 2026-07-28 client sends: the method, and for calls the tool's name, // repeated in headers, and the protocol version in two places. export function mcpRequest(url: string, method: string, params: Record = {}, headers: Record = {}) { return new Request(url, { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(method === "tools/call" ? { "mcp-name": String(params.name) } : {}), ...headers, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); } ``` ```ts config.ts import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], http: { entryPath: "/mcp" }, }; ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` `frontmcp build` has a Cloudflare target that writes a Worker like this one for you. [Running FrontMCP Anywhere](https://frontmcp.dev/learn/running-frontmcp-anywhere) walks through the same Worker step by step. ### Answering requests that aren't MCP calls Health checks, stray paths and methods, and requests without the 2026-07-28 headers all get a response, never an exception. Your runtime or router sees each status as it is: Other methods at the endpoint belong to the session-based protocol versions before 2026-07-28, so they're checked in Node rather than in this browser Playground: a `GET` without `Accept: text/event-stream` gets `406`, a `DELETE` for an unknown session gets `404` with the text `Session not found`, and `OPTIONS` gets `405` unless you configure `http.cors`. The last three tests show where a server with several endpoints serves each app. ```ts routes.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { Billing, BillingApi, HelpDesk } from "./apps"; const info = { name: "help-desk", version: "1.0.0" }; const handler = FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk] }); const at = (path: string, init?: RequestInit) => handler.then((h) => h(new Request(`https://desk.example.com${path}`, init))); test("health checks at /healthz, /readyz and /health", async () => { const healthz = await at("/healthz"); expect(healthz.headers.get("cache-control")).toBe("no-store"); expect(await healthz.json()).toEqual({ status: "ok", server: info, transport: "web-fetch" }); expect(await (await at("/health")).json()).toEqual({ status: "ok", server: info, transport: "web-fetch" }); const readyz = await at("/readyz"); expect(readyz.status).toBe(200); expect(await readyz.json()).toMatchObject({ status: "ready", catalog: { toolCount: 1 }, transport: "web-fetch" }); }); test("every response carries the security headers", async () => { for (const res of [await at("/healthz"), await at("/nothing-here")]) { expect(res.headers.get("x-content-type-options")).toBe("nosniff"); expect(res.headers.get("x-frame-options")).toBe("DENY"); expect(res.headers.get("x-powered-by")).toBeNull(); } }); test("requests without the 2026-07-28 headers, or without JSON", async () => { const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }); const noHeaders = await at("/", { method: "POST", headers: { "content-type": "application/json" }, body }); expect(await noHeaders.json()).toMatchObject({ error: { code: -32020, message: "Header mismatch: the mcp-protocol-version header is required" } }); const headers = { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list" }; const notJson = await at("/", { method: "POST", headers, body: "{oops" }); expect(await notJson.json()).toMatchObject({ error: { code: -32600, message: "Invalid Request: missing method" } }); }); test("hosts other than the allowed ones are refused", async () => { const guarded = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk], http: { security: { dnsRebindingProtection: { enabled: true, allowedHosts: ["desk.example.com"] } } }, }); const res = await guarded(new Request("https://attacker.example/healthz")); expect(res.status).toBe(403); expect(await res.json()).toEqual({ error: "Forbidden", message: "Invalid Host header" }); }); test("`health` renames the checks, or turns them off", async () => { const renamed = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk], health: { healthzPath: "/live", readyzPath: "/ready" } }); const get = (path: string) => renamed(new Request(`https://desk.example.com${path}`)); expect((await get("/live")).status).toBe(200); expect((await get("/ready")).status).toBe(200); expect((await get("/healthz")).status).toBe(404); const off = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk], health: { enabled: false } }); expect((await off(new Request("https://desk.example.com/healthz"))).status).toBe(404); }); test("a failing probe makes /readyz a 503", async () => { const database = { name: "database", check: async () => ({ status: "unhealthy", error: "connection refused" }) }; const failing = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk], health: { includeDetails: true, probes: [database] } }); const res = await failing(new Request("https://desk.example.com/readyz")); expect(res.status).toBe(503); expect(await res.json()).toMatchObject({ status: "not_ready", probes: { database: { status: "unhealthy", error: "connection refused" } } }); }); test("a body over `http.bodyLimit` is refused with 413", async () => { const limited = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk], http: { bodyLimit: "1kb" } }); const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { padding: "x".repeat(2000) } }); const res = await limited(new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json" }, body })); expect(res.status).toBe(413); expect(await res.json()).toMatchObject({ error: { code: -32600, message: "Payload Too Large", data: { limit: 1024 } } }); }); async function toolsAt(handler: (request: Request) => Promise, path: string) { const headers = { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list" }; const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }); const response = await handler(new Request(`https://desk.example.com${path}`, { method: "POST", headers, body })); if (response.status !== 200) return response.status; return (await response.json()).result.tools.map((tool: { name: string }) => tool.name); } test("with splitByApp, each app at its own path, and nothing at the endpoint", async () => { const split = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk, Billing], splitByApp: true }); expect(await toolsAt(split, "/help-desk")).toEqual(["get_ticket"]); expect(await toolsAt(split, "/billing")).toEqual(["refund_invoice"]); expect(await toolsAt(split, "/")).toBe(404); }); test("a standalone app at its own path; the other apps at the endpoint", async () => { const withStandalone = await FrontMcpInstance.createFetchHandler({ info, apps: [BillingApi, HelpDesk] }); expect(await toolsAt(withStandalone, "/billing-api")).toEqual(["refund_invoice"]); expect(await toolsAt(withStandalone, "/")).toEqual(["get_ticket"]); expect(await toolsAt(withStandalone, "/help-desk")).toBe(404); const onlyStandalone = await FrontMcpInstance.createFetchHandler({ info, apps: [BillingApi] }); expect(await toolsAt(onlyStandalone, "/")).toEqual([]); }); test("a 404 lists every endpoint", async () => { const split = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk, Billing], splitByApp: true, http: { entryPath: "/mcp" } }); const res = await split(new Request("https://desk.example.com/mcp", { method: "POST" })); expect(res.status).toBe(404); expect(await res.json()).toEqual({ error: "Not Found", entryPaths: ["/mcp/help-desk", "/mcp/billing"] }); }); ``` ```ts apps.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "open" }; } } @Tool({ name: "refund_invoice", description: "Refund an invoice", inputSchema: { invoice: z.string() } }) class RefundInvoice extends ToolContext { async execute({ invoice }: { invoice: string }) { return { invoice, refunded: true }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} @App({ id: "billing", name: "Billing", tools: [RefundInvoice] }) export class Billing {} @App({ id: "billing-api", name: "Billing API", tools: [RefundInvoice], standalone: true }) export class BillingApi {} ``` ### Health checks `/healthz` says the process is up; `/health` answers the same. `/readyz` runs the server's readiness probes and answers `200` with the report, or `503` and `"status": "not_ready"` when one is unhealthy. The handler reads the same [`health` options](https://frontmcp.dev/reference/server/observability#health) as FrontMCP's Node server: `healthzPath` and `readyzPath` rename the checks, `readyz.enabled: false` drops `/readyz`, `enabled: false` drops them all, and `probes` adds checks of your own, listed in the report when `includeDetails` is `true` (it's on outside production by default). The tests above show each. Health checks answer before the MCP endpoint's own checks, so a platform's prober needs no token or MCP headers. Changed in 1.8.7: before, the handler ignored `health` and `http.bodyLimit`, answered `/readyz` with the same short body as `/healthz`, and answered `/health` with `404`. ### Catching a misconfigured server at startup `createFetchHandler()` runs the same startup checks as `createDirect()`, so a server that can't start never gets a handler. The error names what to fix: ```ts startup.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance, assertStaticStartupConfig } from "@frontmcp/sdk"; import { Admin, Billing, HelpDesk } from "./apps"; const info = { name: "help-desk", version: "1.0.0" }; test("an invalid configuration rejects createFetchHandler()", async () => { await expect(FrontMcpInstance.createFetchHandler({ info: { name: "help-desk" } } as never)).rejects.toThrow("expected string, received undefined"); }); test("so does an `approval` that no plugin enforces", async () => { await expect(FrontMcpInstance.createFetchHandler({ info, apps: [Billing()] })).rejects.toThrow( `Unenforced metadata: Tool "refund_invoice" declares 'approval' (enforced by ApprovalPlugin from @frontmcp/plugin-approval)`, ); }); test("assertStaticStartupConfig() finds it without building the server", () => { expect(() => assertStaticStartupConfig({ info, apps: [Billing()] })).toThrow(`Tool "refund_invoice" declares 'approval'`); expect(() => assertStaticStartupConfig({ info, apps: [HelpDesk] })).not.toThrow(); }); test("it finds a rule that names an unknown profile", async () => { const authorities = { profiles: { admn: { roles: { any: ["admin"] } } } }; // a typo: the tool asks for "admin" const message = `Invalid authorities rule: Tool "purge_closed": authorities names an unknown profile "admin"`; expect(() => assertStaticStartupConfig({ info, apps: [Admin()], authorities })).toThrow(message); await expect(FrontMcpInstance.createFetchHandler({ info, apps: [Admin()], authorities })).rejects.toThrow(message); }); test("🚩 it doesn't validate the configuration itself", async () => { const invalid = { info: { name: "help-desk" }, apps: [HelpDesk] } as never; expect(() => assertStaticStartupConfig(invalid)).not.toThrow(); await expect(FrontMcpInstance.createFetchHandler(invalid)).rejects.toThrow("expected string, received undefined"); }); ``` ```ts apps.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} @Tool({ name: "purge_closed", description: "Delete closed tickets, for admins", inputSchema: {}, authorities: "admin" }) class PurgeClosed extends ToolContext { async execute() { return { purged: 31 }; } } @Tool({ name: "refund_invoice", description: "Refund an invoice", inputSchema: { invoice: z.string() }, approval: true }) class RefundInvoice extends ToolContext { async execute({ invoice }: { invoice: string }) { return { invoice, refunded: true }; } } // These two stop a server from starting, so they're made in the tests, not served by the Playground. export const Admin = () => { @App({ id: "admin", name: "Admin", tools: [PurgeClosed] }) class AdminApp {} return AdminApp; }; // 🚩 `approval` needs the Approval plugin. export const Billing = () => { @App({ id: "billing", name: "Billing", tools: [RefundInvoice] }) class BillingApp {} return BillingApp; }; ``` On an edge isolate, `createFetchHandler()` runs only `assertStaticStartupConfig()`, so the `approval` failure and the profile typo stop it there. The invalid configuration doesn't: the handler is created, and each request gets `500` `{"error":"server_misconfigured",…}`, with the code `CONFIG_INVALID`. (The Playground isn't an edge isolate; this was checked in Node with the `EdgeRuntime` global.) Run `createDirect()` in a test before you deploy: it runs every check. ### Serving from Deno, Bun or a framework The handler has the shape each of them expects. Pass the runtime's connection info as `ctx`, so FrontMCP knows the client's address: ```ts deno.ts const handler = await FrontMcpInstance.createFetchHandler(config); Deno.serve((request, info) => handler(request, info)); ``` ```ts bun.ts const handler = await FrontMcpInstance.createFetchHandler(config); Bun.serve({ port: 8080, fetch: (request, server) => handler(request, server) }); ``` In a framework, route every method at the MCP path to the handler, and set `http.entryPath` to the same path, because the handler checks the path too: ```ts hono.ts const handler = await FrontMcpInstance.createFetchHandler({ ...config, http: { entryPath: "/mcp" } }); app.all("/mcp", (c) => handler(c.req.raw)); ``` ```ts app/mcp/route.ts // Next.js: one handler for each method a client uses. const handler = FrontMcpInstance.createFetchHandler({ ...config, http: { entryPath: "/mcp" } }); export const GET = async (request: Request) => (await handler)(request); export const POST = GET; export const DELETE = GET; ``` These need their runtimes, so they don't run here. The Playground below passes the same `ctx` objects Deno and Bun do. ### Building the handler from a scope [`createWebFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler#createwebfetchhandlerscope-options) serves one endpoint of a server built with `FrontMcpInstance.createForGraph(config)`, with options `createFetchHandler()` doesn't take. Here the help desk is served at two paths, and the billing app, which is `standalone`, only by a handler of its own: ```ts scope.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance, createWebFetchHandler } from "@frontmcp/sdk"; import { config } from "./config"; import { mcpRequest } from "./requests"; type Scope = Parameters[0]; type Options = Parameters[1]; const server = FrontMcpInstance.createForGraph(config); async function handlerFor(options: Options = {}, app?: string) { const instance = await server; return createWebFetchHandler((app ? instance.getAppScope(app) : instance.getPrimaryScope()) as Scope, options); } const getTicket = (path: string) => mcpRequest(`https://desk.example.com${path}`, "tools/call", { name: "get_ticket", arguments: { id: "T-1" } }); test("`entryPath` serves MCP at every path it lists", async () => { const handler = await handlerFor({ entryPath: ["/mcp", "/v1/mcp"] }); for (const path of ["/mcp", "/v1/mcp"]) { const res = await handler(getTicket(path)); expect((await res.json()).result.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" }); } }); test("the handler serves only the scope it was given", async () => { const desk = await handlerFor(); const res = await desk(mcpRequest("https://desk.example.com/mcp/billing", "tools/list")); expect(res.status).toBe(404); expect(await res.json()).toEqual({ error: "Not Found", entryPaths: ["/mcp"] }); const billing = await handlerFor({ entryPath: "/mcp/billing" }, "billing"); const list = await (await billing(mcpRequest("https://desk.example.com/mcp/billing", "tools/list"))).json(); expect(list.result.tools.map((t: { name: string }) => t.name)).toEqual(["get_invoice"]); }); test("createFetchHandler() serves both endpoints", async () => { const handler = await FrontMcpInstance.createFetchHandler(config); const list = await (await handler(mcpRequest("https://desk.example.com/mcp/billing", "tools/list"))).json(); expect(list.result.tools.map((t: { name: string }) => t.name)).toEqual(["get_invoice"]); }); test("`healthPaths` replaces /healthz and /readyz", async () => { const handler = await handlerFor({ healthPaths: ["/livez"] }); const live = await handler(new Request("https://desk.example.com/livez")); expect(await live.json()).toEqual({ status: "ok", server: { name: "help-desk", version: "1.0.0" }, transport: "web-fetch" }); expect((await handler(new Request("https://desk.example.com/readyz"))).status).toBe(404); }); test("a `sessionRouter` sees each MCP request first, and can pass it on", async () => { const sessionRouter = async (_request: Request, env: unknown) => ((env as { SESSIONS?: unknown })?.SESSIONS ? new Response("routed to the session host") : undefined); const handler = await handlerFor({ sessionRouter }); expect(await (await handler(getTicket("/mcp"), undefined, { SESSIONS: {} })).text()).toBe("routed to the session host"); expect((await (await handler(getTicket("/mcp"))).json()).result.structuredContent.id).toBe("T-1"); }); ``` ```ts config.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @Tool({ name: "get_invoice", description: "Get an invoice by its id", inputSchema: { id: z.string() } }) class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, amount: "49 EUR" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} @App({ id: "billing", name: "Billing", tools: [GetInvoice], standalone: true }) export class Billing {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk, Billing], http: { entryPath: "/mcp" }, }; ``` **Hidden fixture/test file (runs with the example):** ```ts requests.ts hidden export function mcpRequest(url: string, method: string, params: Record = {}, headers: Record = {}) { return new Request(url, { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(method === "tools/call" ? { "mcp-name": String(params.name) } : {}), ...headers, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); } ``` `FrontMcpInstance.createForGraph(config)` builds the server and its scopes without serving them, and the handlers you make serve them. This runs in the Playground, and was also checked in Node, with a CORS `origin` that overrides `http.cors`. ### Reading the client's address This tool returns `this.context.metadata.clientIp`. The tests call it the way Deno and Bun would, then from behind a proxy, before and after trusting it: ```ts where.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "where_from", description: "The caller's IP address, as FrontMCP sees it. For debugging.", inputSchema: {} }) export class WhereFrom extends ToolContext { async execute() { return { clientIp: this.context.metadata.clientIp ?? null }; } } ``` ```ts where.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { WhereFrom } from "./where.tool"; import { mcpRequest } from "./requests"; @App({ id: "desk", name: "Desk", tools: [WhereFrom] }) class Desk {} const handler = FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }); async function clientIp(headers: Record, ctx?: object) { const res = await (await handler)(mcpRequest("https://desk.example.com/", "tools/call", { name: "where_from", arguments: {} }, headers), ctx); return (await res.json()).result.structuredContent.clientIp; } test("Deno's and Bun's connection info give the address", async () => { expect(await clientIp({}, { remoteAddr: { hostname: "203.0.113.7" } })).toBe("203.0.113.7"); expect(await clientIp({}, { requestIP: () => ({ address: "203.0.113.8" }) })).toBe("203.0.113.8"); }); test("X-Forwarded-For is ignored by default", async () => { expect(await clientIp({ "x-forwarded-for": "198.51.100.4" })).toBeNull(); }); test("with FRONTMCP_TRUST_PROXY, the proxy's entry counts", async () => { process.env.FRONTMCP_TRUST_PROXY = "1"; try { expect(await clientIp({ "x-forwarded-for": "10.0.0.1, 198.51.100.4" })).toBe("198.51.100.4"); expect(await clientIp({ "x-real-ip": "198.51.100.5" })).toBe("198.51.100.5"); process.env.FRONTMCP_TRUSTED_PROXY_DEPTH = "2"; expect(await clientIp({ "x-forwarded-for": "10.0.0.1, 198.51.100.4" })).toBe("10.0.0.1"); } finally { delete process.env.FRONTMCP_TRUST_PROXY; delete process.env.FRONTMCP_TRUSTED_PROXY_DEPTH; } }); test("CF-Connecting-IP counts only on Cloudflare", async () => { const request = () => mcpRequest("https://desk.example.com/", "tools/call", { name: "where_from", arguments: {} }, { "cf-connecting-ip": "203.0.113.9" }); const elsewhere = await (await handler)(request()); expect((await elsewhere.json()).result.structuredContent.clientIp).toBeNull(); const onCloudflare = request(); Object.defineProperty(onCloudflare, "cf", { value: { colo: "FRA" } }); // what Cloudflare adds to each request const res = await (await handler)(onCloudflare); expect((await res.json()).result.structuredContent.clientIp).toBe("203.0.113.9"); }); ``` **Hidden fixture/test file (runs with the example):** ```ts requests.ts hidden export function mcpRequest(url: string, method: string, params: Record = {}, headers: Record = {}) { return new Request(url, { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(method === "tools/call" ? { "mcp-name": String(params.name) } : {}), ...headers, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); } ``` With `X-Forwarded-For: 10.0.0.1, 198.51.100.4`, the proxy added the last entry, the address that connected to it. The first entry is whatever the client wrote, which is why FrontMCP takes the last. ### Reading a Worker's bindings A Worker's `env` holds its bindings: KV namespaces, D1 databases, R2 buckets, secrets and `[vars]`. The handler's third argument carries it, and a tool, a resource, a prompt or an agent reads it as `this.workerEnv`, as does a job that `execute_job` runs while the client waits. It's typed as `Readonly> | undefined`, so cast it to the shape you bind. Where nothing passes an `env`, like a request without one, `this.workerEnv` is `undefined`. A server built in a Durable Object or a queue consumer with `create()` or `createDirect()` gets no request from the handler: give it the bindings with its `workerEnv` option, as in [Passing a Worker's bindings](https://frontmcp.dev/reference/sdk/create#passing-a-workers-bindings) (since 1.9.4). (Changed in 1.9: before, prompts and jobs didn't get it.) ```ts tickets.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; type Env = { TICKETS?: { get(key: string): Promise } }; @Tool({ name: "get_ticket", description: "Get one support ticket from the Worker's KV namespace", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const tickets = (this.workerEnv as Env | undefined)?.TICKETS; if (!tickets) return { id, title: null, source: "no bindings" }; return { id, title: await tickets.get(id), source: "kv" }; } } ``` ```ts tickets.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { GetTicket } from "./tickets.tool"; import { mcpRequest } from "./requests"; @App({ id: "desk", name: "Desk", tools: [GetTicket] }) class Desk {} const handler = FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }); const call = async (env?: unknown) => { const request = mcpRequest("https://desk.example.com/", "tools/call", { name: "get_ticket", arguments: { id: "T-1" } }); const res = await (await handler)(request, { waitUntil() {} }, env); return (await res.json()).result.structuredContent; }; test("a tool reads the bindings the Worker passed", async () => { const TICKETS = { get: async (key: string) => `Cannot log in (${key})` }; expect(await call({ TICKETS, ENVIRONMENT: "staging" })).toEqual({ id: "T-1", title: "Cannot log in (T-1)", source: "kv" }); }); test("without an env, `this.workerEnv` is undefined", async () => { expect(await call()).toEqual({ id: "T-1", title: null, source: "no bindings" }); }); ``` **Hidden fixture/test file (runs with the example):** ```ts requests.ts hidden export function mcpRequest(url: string, method: string, params: Record = {}, headers: Record = {}) { return new Request(url, { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(method === "tools/call" ? { "mcp-name": String(params.name) } : {}), ...headers, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); } ``` New in 1.8.7: before, a tool had no way to reach a Worker's bindings. Also, FrontMCP now reads `NODE_ENV` each time it needs it, not once, so a `NODE_ENV = "production"` in wrangler's `[vars]` switches production mode on. (The Playground can't be a Worker, so that was checked in Node, by changing `process.env.NODE_ENV` between calls.) ### Letting web pages connect A browser only lets a page read a response from another origin if the server allows it with CORS headers. Set `http.cors` to the origins that host your MCP client: ```ts config.ts export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], http: { cors: { origin: ["https://desk.example.com"], maxAge: 600 } }, }; ``` A preflight from `https://desk.example.com` that asks for `content-type, mcp-protocol-version, mcp-method` then gets `204` with: ```http Access-Control-Allow-Origin: https://desk.example.com Access-Control-Allow-Methods: GET, POST, OPTIONS, DELETE Access-Control-Allow-Headers: content-type, mcp-protocol-version, mcp-method Access-Control-Expose-Headers: Mcp-Session-Id, WWW-Authenticate Access-Control-Max-Age: 600 Vary: Origin ``` The response to the `POST` that follows carries the same `Access-Control-Allow-Origin`. A preflight from any other origin gets `204` with none of these headers, so the browser blocks the page. Browsers don't let code set the `Origin` header, so this can't run in the Playground; it was checked in Node. CORS only affects browsers, and isn't access control: anything else can still send requests. Use `auth` for that. ### Requiring a token `auth` works as it does on FrontMCP's Node server, and the handler serves its OAuth routes too, including `remote` mode's provider callback, `/oauth/provider//callback` (a `404` before 1.8.4). With `static`, a request needs one of the listed tokens: ```ts config.ts active import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], auth: { mode: "static" as const, tokens: ["sk_desk_1"] }, }; ``` ```ts auth.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; import { mcpRequest } from "./requests"; const handler = FrontMcpInstance.createFetchHandler(config); test("without a token, 401 and a challenge", async () => { const res = await (await handler)(mcpRequest("https://desk.example.com/", "tools/list")); expect(res.status).toBe(401); expect(res.headers.get("www-authenticate")).toBe('Bearer realm="mcp"'); expect(await res.json()).toEqual({ error: "Unauthorized" }); }); test("with the token, the call goes through", async () => { const request = mcpRequest("https://desk.example.com/", "tools/call", { name: "get_ticket", arguments: { id: "T-1" } }, { authorization: "Bearer sk_desk_1" }); const res = await (await handler)(request); expect((await res.json()).result.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" }); }); ``` **Hidden fixture/test file (runs with the example):** ```ts requests.ts hidden export function mcpRequest(url: string, method: string, params: Record = {}, headers: Record = {}) { return new Request(url, { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(method === "tools/call" ? { "mcp-name": String(params.name) } : {}), ...headers, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` The Playground's own client sends no token, so the tests build the handler from `config.ts` themselves. In a real server, read the tokens from your platform's secrets rather than writing them in code. ### Serving clients on older protocol versions Clients that predate MCP 2026-07-28 start with `initialize` and expect a session. The handler answers them, without one: `initialize` gets a `200` event stream with the negotiated protocol version and no `mcp-session-id` header (checked in Node; this browser Playground can't open a legacy session), and later requests work without it: ```ts legacy.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDesk } from "./help-desk.app"; const handler = FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] }); function legacy(id: number, method: string, params: Record) { return new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), }); } test("later requests work without a session", async () => { const res = await (await handler)(legacy(2, "tools/call", { name: "get_ticket", arguments: { id: "T-1" } })); expect(await res.text()).toContain('"structuredContent":{"id":"T-1","title":"Cannot log in","status":"open"}'); }); ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` Each request is handled on its own. Tools that don't depend on a session work the same for every client; anything a legacy client expects the server to remember between requests is gone. ### Serving calls at the same time On Node, and in the Playground, which provides a minimal `AsyncContext`, calls sent at once run at the same time, and so do two `this.callTool()` inside one request: ```ts lookup.tools.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "lookup", description: "Look up a ticket (takes 100 ms)", inputSchema: { id: z.string() } }) export class Lookup extends ToolContext { async execute({ id }: { id: string }) { await new Promise((resolve) => setTimeout(resolve, 100)); return { id, status: "open" }; } } @Tool({ name: "lookup_both", description: "Look up two tickets at once", inputSchema: {} }) export class LookupBoth extends ToolContext { async execute() { // 🚩 Fails with AsyncContextOverlapError in a browser without AsyncContext const [a, b] = await Promise.all([this.callTool("lookup", { id: "T-1" }), this.callTool("lookup", { id: "T-2" })]); return { tickets: [a.structuredContent, b.structuredContent] }; } } ``` ```ts overlap.test.ts import { test, expect } from "@frontmcp/testing"; test("two calls sent at once run at the same time", async ({ mcp }) => { const started = Date.now(); await Promise.all([mcp.tools.call("lookup", { id: "T-1" }), mcp.tools.call("lookup", { id: "T-2" })]); expect(Date.now() - started).toBeLessThan(200); }); test("two calls inside one request can overlap", async ({ mcp }) => { const result = await mcp.tools.call("lookup_both", {}); expect(result.json()).toEqual({ tickets: [{ id: "T-1", status: "open" }, { id: "T-2", status: "open" }] }); }); ``` FrontMCP's browser build without `AsyncContext` serves [one request at a time](https://frontmcp.dev/reference/sdk/create-fetch-handler#in-a-browser) instead: there the two calls sent at once take turns, about 200 ms in all, and `lookup_both` fails with `AsyncContextOverlapError` (`Concurrent runs of an async context overlapped in one request, …`). A tool that serves such browsers calls other tools one after the other: `await` the first `this.callTool()` before starting the second. --- ## Troubleshooting ### `{"error":"Not Found","entryPaths":["/"]}` The request's path isn't the MCP endpoint. `entryPaths` says where the endpoint is: `/` unless `http.entryPath` sets it. A server with several endpoints lists each, like `["/mcp/help-desk", "/mcp/billing"]` with `splitByApp`, which serves nothing at the entry path itself. Either point the client there, or set `http: { entryPath: "/mcp" }` to match the URL your clients use. [The Worker example](https://frontmcp.dev/reference/sdk/create-fetch-handler#serving-mcp-from-a-cloudflare-worker) serves `/mcp`. ### `Header mismatch: the mcp-protocol-version header is required` The request has a 2026-07-28 body but not the headers 2026-07-28 requires: `MCP-Protocol-Version` and `Mcp-Method`, plus `Mcp-Name` on calls. Clients built for 2026-07-28 send them; code that builds requests by hand has to, as `requests.ts` does above. A proxy between the client and the handler must pass them through. ### `Header mismatch: mcp-name header value '…' does not match body value '…'` `Mcp-Name` names a different tool, prompt or resource from the body. Something rewrote one without the other, often a proxy or a retry that reuses headers. ### `{"error":"server_misconfigured","code":"SESSION_SECRET_REQUIRED"}` The server runs with `NODE_ENV=production` and no `MCP_SESSION_SECRET`, and the request came from a client on a protocol version before 2026-07-28. Set one, a long random string such as the output of `openssl rand -hex 32`, and keep it the same on every instance. `JWT_SECRET_REQUIRED` and `JWT_SECRET_INVALID` mean the same for `JWT_SECRET`. See [production secrets](https://frontmcp.dev/reference/sdk/create-fetch-handler#production-secrets). ### `Not Acceptable: Client must accept text/event-stream` A `GET` to the MCP endpoint without `Accept: text/event-stream`, often a browser or a health check pointed at the endpoint. Point health checks at `/healthz`. ### `this.context.metadata.clientIp` is `undefined` The runtime didn't pass who connected, and proxy headers aren't trusted. Pass Deno's `info` or Bun's `server` as `ctx`; on Cloudflare the address comes from `CF-Connecting-IP`. Behind your own proxy, set `FRONTMCP_TRUST_PROXY=1`. See [the client's address](https://frontmcp.dev/reference/sdk/create-fetch-handler#the-clients-address). ### A web page can't read the responses The page's origin isn't in `http.cors.origin`, or `http.cors` isn't set, in which case the browser's preflight gets `405` `Method not allowed.`. Add the origin. See [letting web pages connect](https://frontmcp.dev/reference/sdk/create-fetch-handler#letting-web-pages-connect). ### An older client keeps sending `initialize` It expects an `Mcp-Session-Id` header, and the handler doesn't create sessions. Most clients carry on without one; a client that insists needs FrontMCP's Node server (`frontmcp dev`, or [`bootstrap()`](https://frontmcp.dev/reference/sdk/frontmcp-instance)), which keeps sessions. --- # create() and createDirect() > Build a server inside your own process and call its tools, resources, prompts and jobs as plain async functions, as a user you name. Source: https://frontmcp.dev/reference/sdk/create [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `create()` and `FrontMcpInstance.createDirect()` build a server inside your process and hand you its tools, resources and prompts as plain async methods. There's no transport, no client and no protocol in between: `callTool()` runs the same checks, hooks and `execute()` as a call from a client, and returns the result, or throws the error. Use them in tests, scripts, scheduled jobs, and in code that embeds a server. `create()` takes a flat configuration with `tools` at the top level; `createDirect()` takes the one you give `@FrontMcp`. ```ts const server = await create(config) const server = await FrontMcpInstance.createDirect(config) const result = await server.callTool(name, args?, { authContext? }) await server.dispose() ``` --- ## Reference ### `create(config)` Pass your server's name and its entries. `create()` puts them in one app and builds a server around it: ```ts nightly.ts import { create } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [CloseTicket] }); try { const result = await server.callTool("close_ticket", { id: "T-1" }, { authContext: { user: { sub: "nightly-cleanup" } } }); console.log(result.structuredContent); } finally { await server.dispose(); } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/create#usage) #### Options | Option | Type | Description | | --- | --- | --- | | `info` | `{ name, version, … }` | **Required.** As on [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp#info). | | `tools`, `resources`, `prompts` | arrays | The entries to serve: classes, or `tool()`, `resource()` and `prompt()` results. | | `providers`, `plugins`, `adapters` | arrays | As on [`@App`](https://frontmcp.dev/reference/sdk/app). | | `agents`, `skills`, `authProviders` | arrays | As on `@App`. | | `jobDefinitions`, `workflowDefinitions` | arrays | The app's jobs and workflows. Note the names: `@App` calls them `jobs` and `workflows`. | | `jobs` | `{ enabled, store? }` | The jobs system itself, as on `@FrontMcp`. | | `auth` | `AuthOptions` | Set on the root server scope. In-process calls skip authentication, so even with `mode: "static"` a call without a token goes through, as `direct`. | | Every other `@FrontMcp` option | | As on `@FrontMcp`: `fetch`, `authorities`, `instructions`, `ui`, `output`, `throttle`, `elicitation`, `pagination`, `logging`, `redis` and the rest, except `http` and `splitByApp`. [See below.](https://frontmcp.dev/reference/sdk/create#keeping-the-servers-options) | | `appName` | `string` | The name of the app `create()` builds around your entries. Defaults to `info.name`. | | `logging` | logging options | Defaults to `warn` when omitted; `FRONTMCP_LOG_LEVEL` still wins. Changed in 1.9.5: before, the default was `info`. | | `cacheKey` | `string` | Return the same server for every `create()` with this key, until it's disposed. [See below.](https://frontmcp.dev/reference/sdk/create#reusing-one-server) | | `machineId` | `string` | Replaces FrontMCP's machine id for the whole process until this server is disposed. In-process servers don't initialize HTTP session storage. | | `workerEnv` | `Record` | Platform bindings, like a Cloudflare Worker's `env`, that every call and every client from `server.connect()` hands to the code it runs, as [`this.workerEnv`](https://frontmcp.dev/reference/sdk/contexts#thisworkerenv). A call's or a client's own `workerEnv` replaces it. [See below.](https://frontmcp.dev/reference/sdk/create#passing-a-workers-bindings) New in 1.9.4. | `http` and `splitByApp` are dropped: a server in your process has no HTTP endpoint, and `create()` builds one app. TypeScript rejects them in an object literal. (Changed in 1.9.3: before, `create()` also dropped `fetch`, `authorities`, `instructions`, `ui` and most other server options, without an error. Changed in 1.9.2: before, it dropped `output` and `throttle` too.) ### `FrontMcpInstance.createDirect(config)` Takes the configuration you give `@FrontMcp`, apps and all, and drops `http`. Options for serving HTTP have no effect: see [what the server initializes](https://frontmcp.dev/reference/sdk/create#what-the-server-initializes). ```ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./config"; const server = await FrontMcpInstance.createDirect(config); const billing = await FrontMcpInstance.createDirect(config, { app: "billing" }); ``` `workerEnv` goes in the same object, as for `create()`: `createDirect({ ...config, workerEnv: env })`. `config` is `FrontMcpConfigInput`. Its entries accept these forms: | Entry | Accepted forms | | --- | --- | | `apps` | An `@App` class, `App.remote()` or `App.esm()`. | | `providers` | An `@Provider` class, or a `useValue`, `useClass` or `useFactory` provider record. | | `plugins` | An `@Plugin` class, or a value or factory record such as `Plugin.init()`. | | `adapters` | An `@Adapter` class, or a value or factory record such as `Adapter.init()`. | Changed in 1.9.7: these input types match the runtime for `createDirect`, `createHandler`, `createFetchHandler` and `connect*`; before, non-class entries ran but could fail to compile. App value records and plugin/adapter `useClass` records are still rejected by the validators. Changed in 1.9.7: before, a failed initialization left the partial server alive. FrontMCP now [awaits cleanup before the startup error reaches the caller](https://frontmcp.dev/reference/sdk/frontmcp-instance#lifecycle-and-shutdown), for at most 10 seconds. Cleanup failures are logged and do not replace that error; cleanup can still be pending when the deadline expires. It needs `apps`: a `create()`-style configuration fails with `Invalid input: expected array, received undefined` at `apps`. It needs the configuration object, not the decorated class; pass `getDecoratorConfig(Server)` if all you have is the class. It serves the server's main endpoint: every app that isn't `standalone: true`, or with `splitByApp: true`, the first app. The second argument, `{ app }`, serves the endpoint an app has of its own instead: each app's with `splitByApp`, a `standalone` app's otherwise. An app without one rejects with `ScopeConfigurationError`, `No endpoint serves app "…" on its own`, naming the apps that have one. [`connect()`](https://frontmcp.dev/reference/sdk/connect#connecting-to-one-app) takes the same option. New in 1.9.3: before, with `splitByApp`, only the first app could be served in-process. #### What the server initializes Changed in 1.10.0: before, `create()` and `createDirect()` initialized HTTP machinery even though they served only in process. They now build no transport, `http:*` flows, health checks, HA or relay, custom routes or channel webhook routes. `scope.transportService` and `scope.healthService` are `undefined`. Configured webhooks log `Channel "…" has a webhook source but no HTTP server to serve …`. Without `auth`, the scope uses public auth without OAuth, OAuth discovery or session-verification flows. `scope.auth.options.mode` is still `"public"`; trying to run an `oauth:*` flow or `session:verify` throws `FlowNotRegisteredError`. An explicit `auth` keeps the auth implementation and flows for that mode, while in-process calls still trust the caller your code supplies. Configured [Registry flows](https://frontmcp.dev/reference/server/flows#other-flows) are still registered, but the server has no HTTP endpoint to serve their documents. [`connect()`](https://frontmcp.dev/reference/sdk/connect) uses the same initialization in its default mode, including its client adapters. `connect(config, { mode: "cli" })` keeps CLI initialization. `createFetchHandler()`, `createHandler()`, `bootstrap()`, `runStdio()`, `runUnixSocket()` and `createForGraph()` still build the services for HTTP. Use a fetch handler to serve [registry documents](https://frontmcp.dev/reference/deployment/mcp-registry), routes or webhooks. ### The `DirectMcpServer` Both return a `DirectMcpServer`, built and ready: | Method | Returns | | --- | --- | | `listTools(options?)` | `{ tools }`: every tool, however many pages `tools/list` splits them into. `{ paginate: true }` returns one page and its `nextCursor`, and `{ cursor }` the page after it. [See below.](https://frontmcp.dev/reference/sdk/create#listing-more-than-one-page-of-tools) | | `callTool(name, args?, options?)` | The tool's MCP result: `content`, and `structuredContent` for object results. **Throws** when the call fails. | | `listResources(options?)` | `{ resources }`, every page; `paginate` and `cursor` as for `listTools()`. | | `listResourceTemplates(options?)` | `{ resourceTemplates }`, the same. | | `readResource(uri, options?)` | `{ contents }`. | | `listPrompts(options?)` | `{ prompts }`, the same. | | `getPrompt(name, args?, options?)` | `{ description, messages }`. | | `listJobs(options?)`, `listWorkflows(options?)` | The result of the `list_jobs` or `list_workflows` tool: the list is JSON in `content[0].text`. | | `executeJob(name, input?, options?)`, `executeWorkflow(name, input?, options?)` | The `execute_job` or `execute_workflow` result. `structuredContent` has `runId`, `state` and, once done, `result`. With `options.background: true`, the run goes on [in the background](https://frontmcp.dev/learn/running-jobs-in-the-background). | | `getJobStatus(runId, options?)`, `getWorkflowStatus(runId, options?)` | The run's status, in `structuredContent`. | | `connect(sessionIdOrOptions?)` | A [`DirectClient`](https://frontmcp.dev/reference/sdk/connect#the-directclient) connected to this server, for code that wants an MCP client. Closing it leaves the server running, for its other clients too; only `dispose()` ends it. (Changed in 1.9.3: before, closing one disposed part of the server, so `registerTool()` failed and the other clients stopped getting notifications.) | | `registerTool(definition)` | Adds a tool while the server runs, and returns a function that removes it. New in 1.9: see [`registerTool()`](https://frontmcp.dev/reference/sdk/register-tool). | | `dispose()` | Releases the server. [See below.](https://frontmcp.dev/reference/sdk/create#disposing) | | `ready` | A promise that has already resolved. | The job and workflow methods call the built-in tools that run [jobs](https://frontmcp.dev/reference/sdk/job), so they return tool results, not parsed objects. #### Call options Every method takes `options` last: | Option | Description | | --- | --- | | `authContext` | Who is calling. [See below.](https://frontmcp.dev/reference/sdk/create#who-is-calling) | | `metadata` | `{ userAgent?, clientIp?, customHeaders? }`, which becomes `this.context.metadata`, as an HTTP request's headers would. Only `x-frontmcp-*` headers are kept in `customHeaders`, and `clientIp` only if it's an IP address. An `x-frontmcp-trace-id` header with a 32-character hex trace id continues that trace. New in 1.9.2: before, tools always saw `{ customHeaders: {} }`. | | `workerEnv` | Bindings for this call only, read as `this.workerEnv` by the tool, resource, prompt, job or agent it runs. They replace the server's `workerEnv` rather than adding to it. New in 1.9.4. | #### Who is calling `authContext` stands in for what authentication would have established. Nothing checks it: in-process, your code is the authentication, so pass only a user your code has verified. | Field | Becomes | | --- | --- | | `user` | The caller's claims. `user.sub` is `this.auth.user.sub`; `roles`, `name` and `email` are read as from a token, so `this.auth.hasRole()` works. The claims also get `iss: "direct"`. | | `scopes` | `this.auth.scopes` and `this.context.authInfo.scopes`, so `this.auth.hasScope()` works. A `scope` claim in `user` isn't read. New in 1.9.2: before, in-process calls had no scopes. | | `token` | `this.context.authInfo.token`, which [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch) sends to the origins in `fetch.forwardCallerTokenTo`. | | `sessionId` | `this.context.sessionId`. By default each user gets their own, derived from the server's. | | `extra` | `this.context.authInfo.extra`. | Two things differ from a real caller: - **Without `authContext`, the caller is signed in, as `direct`.** `this.auth.user.sub` is `"direct"` and `this.auth.isAnonymous` is `false`, so a check that refuses anonymous callers lets the call through. Always pass a user. - **Scopes come only from `authContext.scopes`.** A token's `scope` claim becomes the caller's scopes over HTTP, but `user.scope` isn't read here: without `scopes`, `this.auth.scopes` is `[]`. Each call gets a `this.context` of its own, with its own `requestId` and caller, so one server can serve many users at once. #### What a tool sees | On `this` | In-process | | --- | --- | | `this.auth` | The `authContext` user, or `direct`. | | `this.context.traceContext` | A new trace for every call, unless `metadata` names one with `x-frontmcp-trace-id`. | | `this.context.metadata` | The call's `metadata`, or `{ customHeaders: {} }` without it. | | `this.workerEnv` | The call's `workerEnv`, else the server's, else `undefined`. | | `this.clientInfo`, `this.platform` | `undefined` and `"unknown"`: there's no client. | | `this.elicit()` | Can't ask anyone. With elicitation turned on, the call returns FrontMCP's fallback result, "This tool requires user input to continue", instead of the answer. | | `this.progress()`, `this.notify()` | Send nothing, and return `false`. | #### Errors A failed call **rejects** instead of returning a result with `isError`: | Failure | Rejects with | | --- | --- | | The tool failed with [`this.fail(error)`](https://frontmcp.dev/reference/sdk/fail) or threw a `PublicMcpError` | That error, `code` included. | | Invalid arguments | `InvalidInputError`, `code` `INVALID_INPUT`, message `Invalid tool input`. `getPublicMessage()` lists the problems. | | No such tool, resource or prompt | `ToolNotFoundError` (`TOOL_NOT_FOUND`), `ResourceNotFoundError` (`RESOURCE_NOT_FOUND`) or `PromptNotFoundError` (`PROMPT_NOT_FOUND`). | | The server was disposed | `InternalMcpError`: `DirectMcpServer has been disposed`. | Wrap calls in `try` when one failure shouldn't stop your code. #### Disposing `dispose()` stops the server's timers, destroys its task store and closes its auth storage. Changed in 1.9.5: before, timers and plugin records could keep the disposed server reachable. `create()` builds one root scope, installs server-scoped plugins and applies `auth` at the server level; before, it also built an empty root scope and server-scoped plugins failed. `dispose()` shuts the whole server down: every endpoint is disposed, those it doesn't serve included, and their [`onDispose`](https://frontmcp.dev/reference/sdk/scope) callbacks run. Every call after it rejects with `DirectMcpServer has been disposed`, and a second `dispose()` does nothing. (Changed in 1.9.3: before, it disposed only the endpoint it served.) It also removes the server from the `cacheKey` cache and undoes `machineId`. Call it when you're done, in a `finally`, so a failed call doesn't leave a server behind. `clearCreateCache()` empties the `cacheKey` cache without disposing anything. #### Caveats - **`create()` drops `http` and `splitByApp`**, and takes every other server option. - **Failures throw.** Code that expects MCP results, with `isError`, gets exceptions instead. For MCP results, use a [client](https://frontmcp.dev/reference/sdk/connect). - **No `authContext` means a signed-in user called `direct`**, not an anonymous caller. - **`machineId` works in CommonJS and ES module projects.** Changed in 1.9.5: before, CommonJS needed `setMachineIdOverride()` before startup. - **Scopes come from `authContext.scopes`** only, never from the user's claims. - `list_jobs` and `list_workflows` aren't in `listTools()`, but `listJobs()` and `listWorkflows()` call them anyway. A job's run belongs to the user who started it: check its status with the same `authContext`, or it's `Run "…" not found`. --- ## Usage ### Checking what an embedded server builds This tool reports the services in its scope. The Call tab uses the Playground's fetch handler, which has a transport and health service. The tests also call it through `create()`, `createDirect()` and `connect()`, whose scopes have neither: Initial call configuration: ```js { tool: "server_services", arguments: {} } ``` ```ts services.ts active import { App, Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "server_services", description: "Report which services this server initializes", inputSchema: {} }) export class ServerServices extends ToolContext { async execute() { return { transport: this.scope.transportService !== undefined, health: this.scope.healthService !== undefined, authMode: this.scope.auth.options.mode }; } } @App({ id: "help-desk", name: "Help Desk", tools: [ServerServices] }) export class HelpDeskApp {} ``` ```ts services.test.ts import { test, expect } from "@frontmcp/testing"; import { connect, create, FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDeskApp, ServerServices } from "./services"; const info = { name: "help-desk", version: "1.0.0" }; test("the fetch handler builds HTTP services", async ({ mcp }) => { expect((await mcp.tools.call("server_services", {})).json()).toEqual({ transport: true, health: true, authMode: "public" }); }); test("create and createDirect skip transport and health", async () => { for (const server of [await create({ info, tools: [ServerServices] }), await FrontMcpInstance.createDirect({ info, apps: [HelpDeskApp] })]) { try { expect((await server.callTool("server_services", {})).structuredContent).toEqual({ transport: false, health: false, authMode: "public" }); } finally { await server.dispose(); } } }); test("connect ignores HTTP routes, including reserved-path collisions", async () => { const client = await connect({ info, apps: [HelpDeskApp], http: { routes: [{ method: "GET", path: "/server-card", handler: (_req, res) => res.json({}) }] } }); try { expect((await client.callTool("server_services", {})).structuredContent).toEqual({ transport: false, health: false, authMode: "public" }); } finally { await client.close(); } }); ``` ### Calling a tool as a signed-in user A tool that decides from [`this.auth`](https://frontmcp.dev/learn/authorizing-calls) needs a caller. Name one in `authContext`, with the claims your tool reads: ```ts reassign-ticket.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "reassign_ticket", description: "Move a ticket to another agent. Team leads only.", inputSchema: { id: z.string(), to: z.string() }, }) export class ReassignTicket extends ToolContext { async execute({ id, to }: { id: string; to: string }) { if (!this.auth.hasRole("lead")) this.fail(new PublicMcpError("Only team leads can reassign tickets.", "LEADS_ONLY")); return { id, assignee: to, by: this.auth.user.sub }; } } ``` ```ts reassign.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { ReassignTicket } from "./reassign-ticket.tool"; const info = { name: "help-desk", version: "1.0.0" }; test("a lead can reassign", async () => { const server = await create({ info, tools: [ReassignTicket] }); try { const nour = { user: { sub: "nour", roles: ["lead"] } }; const result = await server.callTool("reassign_ticket", { id: "T-1", to: "sam" }, { authContext: nour }); expect(result.structuredContent).toEqual({ id: "T-1", assignee: "sam", by: "nour" }); } finally { await server.dispose(); } }); test("an agent without the role can't", async () => { const server = await create({ info, tools: [ReassignTicket] }); try { const sam = { user: { sub: "sam", roles: ["agent"] } }; await expect(server.callTool("reassign_ticket", { id: "T-1", to: "bo" }, { authContext: sam })).rejects.toMatchObject({ code: "LEADS_ONLY" }); } finally { await server.dispose(); } }); ``` ### What a tool sees in-process This tool reports what it knows about its call. The tests show the `direct` user FrontMCP fills in without `authContext`, what `authContext` and `metadata` pass through, and what's missing because there's no client: Initial call configuration: ```js { tool: "describe_call", arguments: {} } ``` ```ts describe-call.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "describe_call", description: "Describe the caller and the request. For debugging.", inputSchema: {} }) export class DescribeCall extends ToolContext { async execute() { return { user: this.auth.user.sub, anonymous: this.auth.isAnonymous, roles: this.auth.roles, scopes: this.auth.scopes, canWrite: this.auth.hasScope("tickets:write"), token: this.context.authInfo.token ?? null, extra: this.context.authInfo.extra ?? null, metadata: this.context.metadata, traceId: this.context.traceContext.traceId, client: this.clientInfo ?? null, platform: this.platform, progressSent: await this.progress(1, 2), }; } } ``` ```ts describe-call.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { DescribeCall } from "./describe-call.tool"; const info = { name: "help-desk", version: "1.0.0" }; test("🚩 without authContext, a signed-in user called `direct`", async () => { const server = await create({ info, tools: [DescribeCall] }); try { expect((await server.callTool("describe_call")).structuredContent).toMatchObject({ user: "direct", anonymous: false, scopes: [] }); } finally { await server.dispose(); } }); test("authContext and metadata reach the tool", async () => { const server = await create({ info, tools: [DescribeCall] }); try { const result = await server.callTool("describe_call", {}, { authContext: { user: { sub: "nour", roles: ["lead"] }, scopes: ["tickets:write"], token: "tok_123", extra: { tenant: "acme" } }, metadata: { userAgent: "nightly/1.0", clientIp: "10.0.0.7", customHeaders: { "x-frontmcp-desk": "berlin", "x-api-key": "not kept" } }, }); expect(result.structuredContent).toEqual({ user: "nour", anonymous: false, roles: ["lead"], scopes: ["tickets:write"], canWrite: true, token: "tok_123", extra: { tenant: "acme" }, metadata: { userAgent: "nightly/1.0", clientIp: "10.0.0.7", customHeaders: { "x-frontmcp-desk": "berlin" } }, traceId: expect.stringMatching(/^[0-9a-f]{32}$/), client: null, platform: "unknown", progressSent: false, }); } finally { await server.dispose(); } }); test("🚩 a scope claim in user isn't read; x-frontmcp-trace-id continues a trace", async () => { const server = await create({ info, tools: [DescribeCall] }); try { const result = await server.callTool("describe_call", {}, { authContext: { user: { sub: "nour", scope: "tickets:write" } }, metadata: { customHeaders: { "x-frontmcp-trace-id": "4bf92f3577b34da6a3ce929d0e0e4736" } }, }); expect(result.structuredContent).toMatchObject({ scopes: [], canWrite: false, traceId: "4bf92f3577b34da6a3ce929d0e0e4736" }); } finally { await server.dispose(); } }); ``` The Playground's own call goes through its client, so there the caller is an anonymous `anon:` user and the client is the Playground. ### Handling failures A failed call rejects with the error the tool failed with, so its `code` tells you why. Give each call its own `try`, so one failure doesn't stop the rest: ```ts close-all.ts active import { create } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; export async function closeAll(ids: string[]) { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [CloseTicket] }); const closed: string[] = []; const failed: { id: string; code: string }[] = []; try { for (const id of ids) { try { await server.callTool("close_ticket", { id }, { authContext: { user: { sub: "nightly-cleanup" } } }); closed.push(id); } catch (err) { failed.push({ id, code: (err as { code?: string }).code ?? "UNKNOWN" }); } } } finally { await server.dispose(); } return { closed, failed }; } ``` ```ts close-ticket.tool.ts import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const open = new Set(["T-1", "T-2"]); @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string().regex(/^T-\d+$/) } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { if (!open.has(id)) this.fail(new PublicMcpError(`There's no open ticket ${id}.`, "TICKET_NOT_FOUND")); open.delete(id); return { id, status: "closed" }; } } ``` ```ts close-all.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { closeAll } from "./close-all"; import { CloseTicket } from "./close-ticket.tool"; test("tickets after a failed one still get closed", async () => { expect(await closeAll(["T-1", "T-9", "bad id", "T-2"])).toEqual({ closed: ["T-1", "T-2"], failed: [ { id: "T-9", code: "TICKET_NOT_FOUND" }, { id: "bad id", code: "INVALID_INPUT" }, ], }); }); test("each failure is an error with a code", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [CloseTicket] }); try { await expect(server.callTool("close_ticket", { id: 9 })).rejects.toMatchObject({ name: "InvalidInputError", message: "Invalid tool input" }); await expect(server.callTool("reopen_ticket", {})).rejects.toMatchObject({ code: "TOOL_NOT_FOUND", message: 'Tool "reopen_ticket" not found' }); } finally { await server.dispose(); } }); ``` ### Reading resources and prompts Resources and prompts work the same way. Results are what a client would get: ```ts desk.ts active import { Prompt, PromptContext, Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ uri: "tickets://open", name: "open-tickets", description: "Open tickets", mimeType: "application/json" }) export class OpenTickets extends ResourceContext { async execute() { return { tickets: [{ id: "T-1", title: "Cannot log in" }] }; } } @Prompt({ name: "triage", description: "Triage a ticket", arguments: [{ name: "id", required: true }] }) export class Triage extends PromptContext { async execute({ id }: { id: string }) { return { messages: [{ role: "user" as const, content: { type: "text" as const, text: `Triage ticket ${id}. Say how urgent it is.` } }] }; } } ``` ```ts desk.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { OpenTickets, Triage } from "./desk"; test("reads a resource and gets a prompt", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, resources: [OpenTickets], prompts: [Triage] }); try { expect(await server.readResource("tickets://open")).toMatchObject({ contents: [{ uri: "tickets://open", mimeType: "application/json", text: '{"tickets":[{"id":"T-1","title":"Cannot log in"}]}' }], }); expect((await server.getPrompt("triage", { id: "T-1" })).messages).toEqual([ { role: "user", content: { type: "text", text: "Triage ticket T-1. Say how urgent it is." } }, ]); await expect(server.readResource("tickets://closed")).rejects.toMatchObject({ code: "RESOURCE_NOT_FOUND" }); } finally { await server.dispose(); } }); ``` ### Keeping the server's options `create()` takes the server options `@FrontMcp` takes. Here the server lets results contain `NaN`, with `output: { allowNonFinite: true }`, limits each tool to one call a minute with `throttle`, and defines the `admin` profile that `purge_closed`'s `authorities: "admin"` names. (A client would see the `NaN` as `null`, as the text block shows. In-process, `structuredContent` is the object itself, never serialized, so it's still `NaN`.) `createDirect()` takes the same options, with the entries in an app: ```ts options.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "average_reply_hours", description: "Average hours to first reply this week", inputSchema: {} }) export class AverageReplyHours extends ToolContext { async execute() { const replies: number[] = []; return { hours: replies.reduce((a, b) => a + b, 0) / replies.length }; // 0 / 0 is NaN } } // Stops a server without `authorities` from starting, so it's made in the tests, not served by the Playground. export const purgeClosed = () => { @Tool({ name: "purge_closed", description: "Delete closed tickets, for admins", inputSchema: {}, authorities: "admin" }) class PurgeClosed extends ToolContext { async execute() { return { purged: 31 }; } } return PurgeClosed; }; export const info = { name: "help-desk", version: "1.0.0" }; export const output = { allowNonFinite: true }; export const throttle = { enabled: true, defaultRateLimit: { maxRequests: 1, windowMs: 60_000 } }; export const authorities = { profiles: { admin: { roles: { any: ["admin"] } } } }; ``` ```ts options.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance, create } from "@frontmcp/sdk"; import { AverageReplyHours, authorities, info, output, purgeClosed, throttle } from "./options"; test("create() keeps output and throttle", async () => { const server = await create({ info, tools: [AverageReplyHours], output, throttle }); try { const result = await server.callTool("average_reply_hours"); expect(result.content).toEqual([{ type: "text", text: '{"hours":null}' }]); expect(result.structuredContent).toEqual({ hours: NaN }); // not serialized in-process await expect(server.callTool("average_reply_hours")).rejects.toMatchObject({ code: "RATE_LIMIT_EXCEEDED" }); } finally { await server.dispose(); } }); test("create() keeps authorities", async () => { const server = await create({ info, tools: [purgeClosed()], authorities }); try { const admin = { authContext: { user: { sub: "nour", roles: ["admin"] } } }; expect((await server.callTool("purge_closed", {}, admin)).structuredContent).toEqual({ purged: 31 }); const agent = { authContext: { user: { sub: "sam", roles: ["agent"] } } }; await expect(server.callTool("purge_closed", {}, agent)).rejects.toMatchObject({ code: "AUTHORITY_DENIED" }); } finally { await server.dispose(); } }); test("createDirect() takes the same options", async () => { @App({ id: "reports", name: "Reports", tools: [AverageReplyHours, purgeClosed()] }) class Reports {} const server = await FrontMcpInstance.createDirect({ info, apps: [Reports], output, throttle, authorities }); try { const admin = { authContext: { user: { sub: "nour", roles: ["admin"] } } }; expect((await server.callTool("purge_closed", {}, admin)).structuredContent).toEqual({ purged: 31 }); expect((await server.callTool("average_reply_hours")).structuredContent).toEqual({ hours: NaN }); await expect(server.callTool("average_reply_hours")).rejects.toMatchObject({ code: "RATE_LIMIT_EXCEEDED" }); } finally { await server.dispose(); } }); ``` ### Listing more than one page of tools `tools/list` returns 40 tools at a time once a server has more than 40, with a `nextCursor` for the rest (see [`pagination`](https://frontmcp.dev/reference/sdk/frontmcp#paging-long-tool-lists)). `listTools()` follows `nextCursor` to the end and returns every tool. To page through the list as a client does, pass `{ paginate: true }` for the first page and `{ cursor }` for each one after. A `DirectClient` from `server.connect()` reads every page too, with its `listTools()`, `listResources()`, `listResourceTemplates()` and `listPrompts()`: ```ts tools.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; function numberedTool(n: number) { @Tool({ name: `tool_${n}`, description: `Tool number ${n}`, inputSchema: {} }) class NumberedTool extends ToolContext { async execute() { return { n }; } } return NumberedTool; } export const numbered = (count: number) => Array.from({ length: count }, (_, i) => numberedTool(i + 1)); @Tool({ name: "ping", description: "Check that the server is up", inputSchema: {} }) export class Ping extends ToolContext { async execute() { return { ok: true }; } } ``` ```ts paging.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { numbered } from "./tools"; const info = { name: "help-desk", version: "1.0.0" }; test("listTools() reads every page", async () => { const server = await create({ info, tools: numbered(45) }); try { const all = await server.listTools(); expect(all.tools).toHaveLength(45); expect(all.nextCursor).toBeUndefined(); } finally { await server.dispose(); } }); test("`paginate: true` returns one page, and `cursor` the next", async () => { const server = await create({ info, tools: numbered(45) }); try { const first = await server.listTools({ paginate: true }); expect(first.tools).toHaveLength(40); expect(first.nextCursor).toBeDefined(); const second = await server.listTools({ cursor: first.nextCursor }); expect(second.tools).toHaveLength(5); expect(second.nextCursor).toBeUndefined(); } finally { await server.dispose(); } }); test("a client from connect() reads every page", async () => { const server = await create({ info, tools: numbered(45) }); const client = await server.connect(); try { expect(await client.listTools()).toHaveLength(45); } finally { await client.close(); await server.dispose(); } }); test("`pagination` changes the page size, or turns paging off", async () => { const small = await create({ info, tools: numbered(45), pagination: { tools: { mode: true, pageSize: 10 } } }); const off = await create({ info, tools: numbered(45), pagination: { tools: { mode: false } } }); try { expect((await small.listTools({ paginate: true })).tools).toHaveLength(10); const all = await off.listTools({ paginate: true }); expect(all.tools).toHaveLength(45); expect(all.nextCursor).toBeUndefined(); } finally { await small.dispose(); await off.dispose(); } }); ``` Changed in 1.9: before, `listTools()` returned only the first page, and its `nextCursor` couldn't be passed back. New in 1.8.7: `listResources()`, `listResourceTemplates()` and `listPrompts()` of a `DirectClient` follow every page too. Before, they returned one; `listTools()` already followed them. The server's `pagination` option covers tools only, and 45 resources or 45 prompts come back in one list, so with your own entries this changes nothing you can see. ### Running jobs With `jobDefinitions`, the server has [jobs](https://frontmcp.dev/reference/sdk/job), and the job methods run them. A run belongs to the user who started it, so ask for its status as the same user: ```ts count-open.job.ts active import { Job, JobContext, z } from "@frontmcp/sdk"; @Job({ name: "count-open-tickets", description: "Count a customer's open tickets", inputSchema: { customer: z.string() }, outputSchema: { customer: z.string(), open: z.number() }, }) export class CountOpenTickets extends JobContext { async execute({ customer }: { customer: string }) { return { customer, open: customer === "acme" ? 3 : 0 }; } } ``` ```ts jobs.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { CountOpenTickets } from "./count-open.job"; const asNour = { authContext: { user: { sub: "nour" } } }; test("runs a job and reads its status", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, jobDefinitions: [CountOpenTickets] }); try { const run = (await server.executeJob("count-open-tickets", { customer: "acme" }, asNour)).structuredContent as { runId: string }; expect(run).toMatchObject({ state: "completed", result: { customer: "acme", open: 3 } }); const status = await server.getJobStatus(run.runId, asNour); expect(status.structuredContent).toMatchObject({ jobName: "count-open-tickets", state: "completed" }); await expect(server.getJobStatus(run.runId, { authContext: { user: { sub: "sam" } } })).rejects.toMatchObject({ code: "INVALID_INPUT" }); } finally { await server.dispose(); } }); ``` ### Passing a Worker's bindings On Cloudflare, a server you build with `create()` in a Durable Object, a queue consumer or a route of your own gets no request from [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler#reading-a-workers-bindings), so nothing hands its tools the Worker's `env`. Pass it as `workerEnv`: on `create()` or `createDirect()` for every call, on one call for that call, or on `server.connect()` for one client. Tools, resources, prompts, jobs and agents read it as [`this.workerEnv`](https://frontmcp.dev/reference/sdk/contexts#thisworkerenv). Here a stand-in KV namespace plays the Worker's: ```ts tickets.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; type Env = { DESK?: string; TICKETS?: { get(key: string): Promise } }; @Tool({ name: "get_ticket", description: "Get one support ticket from the desk's KV namespace", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { const env = this.workerEnv as Env | undefined; return { id, title: env?.TICKETS ? await env.TICKETS.get(id) : null, desk: env?.DESK ?? null }; } } ``` ```ts bindings.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { GetTicket } from "./tickets.tool"; const info = { name: "help-desk", version: "1.0.0" }; const berlin = { DESK: "berlin", TICKETS: { get: async (id: string) => `Cannot log in (${id})` } }; test("every call reads the server's `workerEnv`", async () => { const server = await create({ info, tools: [GetTicket], workerEnv: berlin }); try { const result = await server.callTool("get_ticket", { id: "T-1" }); expect(result.structuredContent).toEqual({ id: "T-1", title: "Cannot log in (T-1)", desk: "berlin" }); expect(process.env.DESK).toBeUndefined(); } finally { await server.dispose(); } }); test("a call's own `workerEnv` replaces the server's, for that call only", async () => { const server = await create({ info, tools: [GetTicket], workerEnv: berlin }); try { const lisbon = await server.callTool("get_ticket", { id: "T-1" }, { workerEnv: { DESK: "lisbon" } }); expect(lisbon.structuredContent).toEqual({ id: "T-1", title: null, desk: "lisbon" }); expect((await server.callTool("get_ticket", { id: "T-1" })).structuredContent).toMatchObject({ desk: "berlin" }); } finally { await server.dispose(); } }); test("a client from `server.connect()` gets the server's, or its own", async () => { const server = await create({ info, tools: [GetTicket], workerEnv: berlin }); const usual = await server.connect(); const oslo = await server.connect({ workerEnv: { DESK: "oslo" } }); try { expect(((await usual.callTool("get_ticket", { id: "T-1" })) as any).structuredContent).toMatchObject({ desk: "berlin" }); expect(((await oslo.callTool("get_ticket", { id: "T-1" })) as any).structuredContent).toMatchObject({ desk: "oslo" }); } finally { await usual.close(); await oslo.close(); await server.dispose(); } }); test("without `workerEnv`, `this.workerEnv` is undefined", async () => { const server = await create({ info, tools: [GetTicket] }); try { expect((await server.callTool("get_ticket", { id: "T-1" })).structuredContent).toEqual({ id: "T-1", title: null, desk: null }); } finally { await server.dispose(); } }); ``` A call's or a client's bindings replace the server's: they aren't merged, so the Lisbon call has no `TICKETS`. They live in that request's context only, and FrontMCP never copies them into `process.env`. A job that `executeJob()` runs reads them too, in the background as well. [`connect()`](https://frontmcp.dev/reference/sdk/connect#parameters) takes `workerEnv` the same way. New in 1.9.4: before, `this.workerEnv` was `undefined` in every in-process call. ### Reusing one server Building a server takes a moment. When many parts of a process need the same one, like each test in a file, give them a `cacheKey`: every `create()` with that key gets the same server until it's disposed. ```ts server.ts active import { create } from "@frontmcp/sdk"; import { Ping } from "./ping.tool"; export function helpDesk() { return create({ info: { name: "help-desk", version: "1.0.0" }, tools: [Ping], cacheKey: "help-desk" }); } ``` ```ts ping.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "ping", description: "Check that the server is up", inputSchema: {} }) export class Ping extends ToolContext { async execute() { return { ok: true }; } } ``` ```ts cache.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { helpDesk } from "./server"; test("the same key gives the same server, until it's disposed", async () => { const first = await helpDesk(); expect(await helpDesk()).toBe(first); expect(await create({ info: { name: "other", version: "1.0.0" }, tools: [], cacheKey: "help-desk", workerEnv: { DESK: "oslo" } })).toBe(first); await first.dispose(); await expect(first.callTool("ping")).rejects.toThrow("DirectMcpServer has been disposed"); const next = await helpDesk(); try { expect(next).not.toBe(first); expect((await next.callTool("ping")).structuredContent).toEqual({ ok: true }); } finally { await next.dispose(); } }); ``` The key is the only thing compared: a second `create()` with the same key and different tools, or a different `workerEnv`, still gets the first server, with the first one's `workerEnv`. Pass bindings that change from call to call with each call's `workerEnv`. --- ## Troubleshooting ### `Flow "session:verify" is not registered` Changed in 1.10.0: before, an in-process server without explicit `auth` registered OAuth, OAuth discovery and session-verification flows. These flows and `http:*` flows are now omitted. Use an HTTP entry point for HTTP work; see [what the server initializes](https://frontmcp.dev/reference/sdk/create#what-the-server-initializes). Supplying `auth` keeps that mode's auth flows, but in-process calls still trust the caller your code supplies. ### `DirectMcpServer has been disposed` Something called the server after `dispose()`. Often a shared server with a `cacheKey` was disposed by one part of the code while another still used it; dispose a shared server once, when everything is done with it. ### `Invalid input: expected array, received undefined` The error's path is `apps`. You passed a `create()`-style configuration, with `tools` at the top level, to `createDirect()` or `connect()`, which need `apps`. Either call `create()`, or put the tools in an `@App` and list it in `apps`. ### A tool that refuses anonymous callers lets my script through Without `authContext`, the caller is a signed-in user called `direct`. Pass the user the script acts for: `{ authContext: { user: { sub: "nightly-cleanup" } } }`. ### `this.auth.hasScope()` is always `false` The call passed no `authContext.scopes`. In-process, a caller's scopes come only from there: a `scope` claim in `authContext.user` isn't read. Pass `{ authContext: { user: { sub: "nour" }, scopes: ["tickets:write"] } }`, as in [What a tool sees in-process](https://frontmcp.dev/reference/sdk/create#what-a-tool-sees-in-process). ### `Authorities configuration required` The full message starts `Authorities configuration required: Tool "purge_closed" declare 'authorities' metadata but authorities enforcement is not fully configured`. A tool has `authorities`, and the server has no `authorities` option. Pass the profiles it names, as in [Keeping the server's options](https://frontmcp.dev/reference/sdk/create#keeping-the-servers-options). Before 1.9.3, `create()` dropped `authorities`, so a server built with it always failed this way. ### `Run "…" not found` `getJobStatus()` was called as a different user from the one that started the run, or without `authContext` after starting it with one. Pass the same `authContext` to both. ### A tool that asks the user returns "This tool requires user input to continue" In-process there's no client to show a form, so `this.elicit()` can't ask. With `elicitation: { enabled: true }`, the call returns FrontMCP's fallback result instead, meant for clients without forms, and never gets an answer: ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a ticket once the user confirms", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() })); return { id, status: answer.status === "accept" && answer.content?.confirm ? "closed" : "open" }; } } ``` ```ts elicit.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; test("🚩 the call ends with the fallback text, not an answer", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [CloseTicket], elicitation: { enabled: true } }); try { const result = await server.callTool("close_ticket", { id: "T-1" }); expect(result.content).toEqual([{ type: "text", text: expect.stringContaining("This tool requires user input to continue") }]); expect(result.structuredContent).toBeUndefined(); } finally { await server.dispose(); } }); ``` The fallback can still be answered in-process: call `sendElicitationResult` with the `elicitId` from the result's `_meta.elicitationPending`, as the same user, and FrontMCP runs the tool again as that user, with `this.auth` (since 1.8.4). [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit#the-model-is-asked-to-call-sendelicitationresult) shows it with `createDirect()`. Test tools that ask the user through the [`@frontmcp/testing` client](https://frontmcp.dev/reference/testing), which answers with `mcp.onElicitation()`, or split the question from the work: a tool that takes the confirmation as an argument can be called in-process. --- # server.registerTool() > Add and remove tools on a running FrontMCP server with server.registerTool(), including WebMCP calls and disposal hooks. Source: https://frontmcp.dev/reference/sdk/register-tool [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `server.registerTool()` adds a tool to a server that's already running, from code outside the server: a page that shows a ticket, a React component, a script that finds out at run time what it can offer. The tool joins the server's app and from then on is a tool like the app's own: `tools/list` lists it, a call runs through the same flow, with the app's plugin hooks, limits and `availableWhen`, and connected clients get `notifications/tools/list_changed`. It returns a function that removes the tool. This page also covers the `"webmcp"` call surface, for tools only agents in the user's browser should see, and `scope.onDispose()`, which runs code when the server is disposed. All three are new in FrontMCP 1.9. ```ts const unregister = await server.registerTool({ name, description?, title?, inputSchema?, annotations?, availableWhen?, app?, execute }) unregister() availableWhen: { surface: ["webmcp"] } // only agents in the browser, through WebMCP const remove = scope.onDispose(callback) ``` --- ## Reference ### `server.registerTool(definition)` `registerTool()` is a method of the server [`create()` and `createDirect()`](https://frontmcp.dev/reference/sdk/create) return. Give it the tool's name, its JSON Schema and an `execute` function: ```ts ticket-view.ts import { create } from "@frontmcp/sdk"; import { SearchTickets } from "./search-tickets.tool"; const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [SearchTickets] }); const unregister = await server.registerTool({ name: "get_open_ticket", description: "The ticket the support agent has open on screen", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, execute: async () => ({ content: [{ type: "text", text: JSON.stringify(openTicket) }] }), }); // when the ticket is closed unregister(); ``` [See more examples below.](https://frontmcp.dev/reference/sdk/register-tool#usage) #### The definition | Field | Type | Description | | --- | --- | --- | | `name` | `string` | **Required.** 1 to 64 characters, and no other tool of the server may have it. | | `execute(args, { signal })` | function | **Required.** Runs the tool. Returns an MCP result, `{ content, structuredContent?, isError? }`, or a promise of one. `args` is what the caller sent; `signal` is aborted when the call is cancelled. [See below.](https://frontmcp.dev/reference/sdk/register-tool#how-execute-is-called) | | `description` | `string` | What the tool does, for the model choosing it. | | `title` | `string` | A name for people. | | `inputSchema` | JSON Schema | The arguments, as `tools/list` shows them. It must be an object schema (`type: "object"`). Defaults to `{ type: "object", properties: {} }`. **Not checked against what callers send**: validate in `execute`. | | `annotations` | `ToolAnnotations` | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, as on [`@Tool`](https://frontmcp.dev/reference/sdk/tool#describing-side-effects-with-annotations). | | `availableWhen` | `EntryAvailability` | As on `@Tool`: see [`availableWhen`](https://frontmcp.dev/reference/server/environment#availablewhen) and [the `"webmcp"` surface](https://frontmcp.dev/reference/sdk/register-tool#the-webmcp-call-surface). | | `app` | `string` | The id of the app the tool joins. Needed only when the server has more than one app; a `create()` server has exactly one. | #### Return value A promise of a function that removes the tool. Calling it again does nothing. Once removed, the tool isn't listed, a call answers `Tool "…" not found`, and connected clients get `notifications/tools/list_changed` again. #### What the tool gets - **It's one of the app's tools.** Its owner is the app it joined, so the app's [plugins](https://frontmcp.dev/reference/sdk/plugin) run their hooks for it and the server's [limits](https://frontmcp.dev/reference/sdk/guard) count it, as for the app's own tools. `this.scope.tools` lists it. - **Clients hear about it.** Registering and removing it send `notifications/tools/list_changed` to connected clients, like a [`DirectClient`](https://frontmcp.dev/reference/sdk/connect#the-directclient) from `server.connect()`. - **The name is shared with the whole server.** A tool of any app, FrontMCP's own included, takes the name: a runtime tool is never renamed to `:` the way two apps' tools with one name are. - **It lasts until it's removed or the server is disposed.** It isn't stored anywhere: a server built again, or another instance of it, doesn't have it. #### How `execute` is called - **Arguments aren't validated.** `execute` gets the arguments as the caller sent them, whatever `inputSchema` says, as for other tools with a JSON Schema. [Check them yourself](https://frontmcp.dev/reference/sdk/register-tool#checking-the-arguments). - **What it returns is the result.** An `isError: true` result is passed on as it is, so `server.callTool()` returns it and doesn't reject. From plain JavaScript, a value that isn't an MCP result is sent as a tool's return value is: an object as JSON text and `structuredContent`. Returning nothing fails the call with `Flow exited without producing output`. - **When it throws,** the call fails with `ToolExecutionError`, code `TOOL_EXECUTION_ERROR`, message `Tool "…" execution failed: ` and the error's message. `server.callTool()` rejects with it; a client gets an `isError` result with that text. - **It runs outside the call's turn.** In a browser, without `AsyncContext`, FrontMCP's browser build serves requests one at a time. `execute` doesn't hold that turn while it runs, so it can call the server back, or wait on the network, without blocking other requests: in Chrome, a runtime tool whose `execute` called `server.callTool()` got the other tool's result. On Node, nothing waits either way. #### Errors `registerTool()` rejects when the tool can't be added: | Problem | Rejects with | | --- | --- | | Another tool of the server has the name | `ToolNameConflictError`, code `TOOL_NAME_CONFLICT`: `A tool named "get_open_ticket" is already registered` | | The name is empty or longer than 64 characters | `EntryValidationError`, code `ENTRY_VALIDATION_FAILED`: `Tool entry validation failed: runtime tool name must be 1-64 characters` | | No `execute` function | `EntryValidationError`: `… runtime tool "x" has no execute function` | | `inputSchema` isn't an object schema | `EntryValidationError`: `… runtime tool "x" input schema must be an object schema: type: Invalid input: expected "object"` | | The server has several apps and the definition names none | `EntryValidationError`: `… runtime tool "x" must name its app (one of: help-desk, billing)` | | `app` names no app of the server | `EntryValidationError`: `… runtime tool "x" names app "crm", which is not here` | | The server was disposed | `InternalMcpError`: `DirectMcpServer has been disposed` | `ToolNameConflictError` and `EntryValidationError` are exported from `@frontmcp/sdk`, with the types `RuntimeToolDefinition` and `RuntimeToolExecuteContext`. #### Caveats - **Only on `create()` and `createDirect()` servers.** A server you run with `@FrontMcp` and serve over HTTP or stdio, a [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) handler and a client from [`connect()`](https://frontmcp.dev/reference/sdk/connect) have no `registerTool()`. Register every tool of those at startup, and hide what isn't ready with [`availableWhen`](https://frontmcp.dev/reference/server/environment#availablewhen) or [authorities](https://frontmcp.dev/learn/authorizing-calls). - **Arguments aren't validated.** - **A runtime tool is there for every caller.** It isn't per user or per session. - **React's dynamic tools are runtime tools.** Since 1.9, [`useDynamicTool()`](https://frontmcp.dev/reference/react/hooks#usedynamictool) registers its tool with `registerTool()` on the server you gave `FrontMcpProvider`, so `server.callTool()` reaches it and the same rules apply. A name a server tool already has is refused, and the provider's `onError` gets `Dynamic tool "search_tickets" was not registered: A tool named "search_tickets" is already registered`. ### The `"webmcp"` call surface [`availableWhen.surface`](https://frontmcp.dev/reference/server/environment#surfaces) says who a tool is offered to. FrontMCP 1.9 adds `"webmcp"`: the calls an agent in the user's browser makes through [`@frontmcp/plugin-webmcp`](https://frontmcp.dev/reference/plugins/webmcp), which offers a page's tools to the browser's agents through WebMCP (`document.modelContext`). | `availableWhen` | MCP clients and `server.callTool()` | Agents in the browser, through WebMCP | | --- | --- | --- | | no `surface` | Offered | Offered | | `{ surface: ["webmcp"] }` | Not offered: not listed, and a call answers `Tool "…" not found` | Offered | | `{ surface: ["mcp"] }` | Offered | Not offered | | `{ surface: ["mcp", "webmcp"] }` | Offered | Offered | It works on `@Tool`, `tool()` and runtime tools alike. Inside a tool, [`getCallSurface()`](https://frontmcp.dev/reference/server/environment#surfaces) returns `"webmcp"` while an agent in the browser calls it. ### `scope.onDispose(callback)` Registers a function to run when the server's scope is disposed, and returns a function that removes it. Plugins and providers use it to release what they hold outside the server: a timer, a subscription, the tools a page registered with the browser. `@frontmcp/plugin-webmcp` removes its WebMCP tools this way. ```ts ticket-feed.provider.ts import { ScopeEntry } from "@frontmcp/sdk"; export const ticketFeed = { name: "TicketFeed", provide: TicketFeed, inject: () => [ScopeEntry] as const, useFactory: (scope: ScopeEntry) => { const feed = new TicketFeed(); scope.onDispose(() => feed.stop()); return feed; }, }; ``` - **When it runs:** once, from `dispose()` on the server `create()` or `createDirect()` returned, from `close()` on the last client from [`connect()`](https://frontmcp.dev/reference/sdk/connect) of a configuration, which disposes the server it built, and when a Node server started with `@FrontMcp` shuts down on `SIGINT` or `SIGTERM`. - **In reverse order:** the last callback registered runs first. Each is awaited before the next. - **A callback that throws** is logged, `Scope dispose callback failed: …`, and the others still run. `dispose()` doesn't reject. - **Registered after the scope was disposed,** a callback runs right away. - **Where to get the scope:** inject `ScopeEntry` into a factory provider, as above, or use [`this.scope`](https://frontmcp.dev/reference/sdk/scope) in a tool. `scope.onServerStarted()`, the other lifecycle hook, never runs for a `create()` server, which starts no HTTP server. `onDispose()` does. --- ## Usage ### Adding a tool while something is on screen A support agent's console shows one ticket at a time. While a ticket is open, the model can read it with `get_open_ticket`; when the agent closes it, the tool goes away. The view registers the tool once and reads the current ticket in `execute`, so it never needs registering again: ```ts ticket-view.ts active import type { DirectMcpServer } from "@frontmcp/sdk"; type Ticket = { id: string; title: string; status: string }; export function openTicketView(server: DirectMcpServer) { let shown: Ticket | undefined; let unregister: (() => void) | undefined; return { async show(ticket: Ticket) { shown = ticket; unregister ??= await server.registerTool({ name: "get_open_ticket", description: "The ticket the support agent has open on screen", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, execute: async () => ({ content: [{ type: "text", text: JSON.stringify(shown) }], structuredContent: shown }), }); }, close() { unregister?.(); unregister = undefined; shown = undefined; }, }; } ``` ```ts search-tickets.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, tickets: [{ id: "T-1", title: "Cannot log in" }] }; } } ``` ```ts ticket-view.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { openTicketView } from "./ticket-view"; import { SearchTickets } from "./search-tickets.tool"; const info = { name: "help-desk", version: "1.0.0" }; test("the tool is listed and callable while a ticket is open", async () => { const server = await create({ info, tools: [SearchTickets] }); try { const view = openTicketView(server); await view.show({ id: "T-1", title: "Cannot log in", status: "open" }); const { tools } = await server.listTools(); expect(tools.map((t) => t.name)).toEqual(["get_open_ticket", "search_tickets"]); expect(tools[0]).toEqual({ name: "get_open_ticket", description: "The ticket the support agent has open on screen", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, }); expect((await server.callTool("get_open_ticket")).structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" }); await view.show({ id: "T-2", title: "Refund", status: "pending" }); expect((await server.callTool("get_open_ticket")).structuredContent).toMatchObject({ id: "T-2" }); } finally { await server.dispose(); } }); test("closing the ticket removes the tool", async () => { const server = await create({ info, tools: [SearchTickets] }); try { const view = openTicketView(server); await view.show({ id: "T-1", title: "Cannot log in", status: "open" }); view.close(); view.close(); expect((await server.listTools()).tools.map((t) => t.name)).toEqual(["search_tickets"]); await expect(server.callTool("get_open_ticket")).rejects.toMatchObject({ code: "TOOL_NOT_FOUND", message: 'Tool "get_open_ticket" not found' }); } finally { await server.dispose(); } }); test("a name that's taken is refused", async () => { const server = await create({ info, tools: [SearchTickets] }); try { const view = openTicketView(server); await view.show({ id: "T-1", title: "Cannot log in", status: "open" }); const again = server.registerTool({ name: "get_open_ticket", execute: () => ({ content: [] }) }); await expect(again).rejects.toMatchObject({ name: "ToolNameConflictError", code: "TOOL_NAME_CONFLICT", message: 'A tool named "get_open_ticket" is already registered' }); const static_ = server.registerTool({ name: "search_tickets", execute: () => ({ content: [] }) }); await expect(static_).rejects.toMatchObject({ code: "TOOL_NAME_CONFLICT" }); } finally { await server.dispose(); } }); ``` The Playground's own server, which its **Call** tab talks to, is built from the exported tools and has no `registerTool()`: only the tests' servers have the runtime tool. ### Telling connected clients A client connected with `server.connect()` gets `notifications/tools/list_changed` each time a tool is added or removed, and its next `listTools()` has the change: ```ts list-changed.test.ts active import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { SearchTickets } from "./search-tickets.tool"; test("each change reaches a connected client", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [SearchTickets] }); const client = await server.connect(); const heard: string[] = []; client.onNotification((notification) => heard.push(notification.method)); try { const unregister = await server.registerTool({ name: "get_open_ticket", execute: () => ({ content: [{ type: "text", text: "T-1" }] }) }); await new Promise((resolve) => setTimeout(resolve, 10)); expect(heard).toEqual(["notifications/tools/list_changed"]); expect((await client.listTools()).map((t: { name: string }) => t.name)).toContain("get_open_ticket"); unregister(); await new Promise((resolve) => setTimeout(resolve, 10)); expect(heard).toEqual(["notifications/tools/list_changed", "notifications/tools/list_changed"]); expect((await client.listTools()).map((t: { name: string }) => t.name)).not.toContain("get_open_ticket"); } finally { await client.close(); await server.dispose(); } }); ``` ```ts search-tickets.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, tickets: [] }; } } ``` ### Checking the arguments `inputSchema` is only what `tools/list` shows. FrontMCP doesn't check calls against it, so `execute` gets whatever the caller sent. Parse the arguments yourself, here with the SDK's `z`, and answer bad ones with an `isError` result the model can act on: ```ts add-note.ts active import { toJSONSchema, z, type DirectMcpServer } from "@frontmcp/sdk"; const AddNote = z.object({ ticketId: z.string().regex(/^T-\d+$/), note: z.string().min(1) }); export const notes: { ticketId: string; note: string }[] = []; export function registerAddNote(server: DirectMcpServer) { return server.registerTool({ name: "add_internal_note", description: "Add a note to a ticket, visible to the support team only", inputSchema: toJSONSchema(AddNote) as Record, execute: async (args) => { const parsed = AddNote.safeParse(args); if (!parsed.success) { return { isError: true, content: [{ type: "text", text: `Invalid arguments: ${z.prettifyError(parsed.error)}` }] }; } notes.push(parsed.data); return { content: [{ type: "text", text: `Note added to ${parsed.data.ticketId}` }] }; }, }); } ``` ```ts search-tickets.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, tickets: [] }; } } ``` ```ts add-note.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { notes, registerAddNote } from "./add-note"; import { SearchTickets } from "./search-tickets.tool"; const info = { name: "help-desk", version: "1.0.0" }; test("the schema is listed as given", async () => { const server = await create({ info, tools: [SearchTickets] }); try { await registerAddNote(server); const tool = (await server.listTools()).tools.find((t) => t.name === "add_internal_note"); expect(tool?.inputSchema).toMatchObject({ type: "object", required: ["ticketId", "note"] }); } finally { await server.dispose(); } }); test("bad arguments reach execute, which refuses them", async () => { const server = await create({ info, tools: [SearchTickets] }); try { await registerAddNote(server); const result = await server.callTool("add_internal_note", { ticketId: 7 }); expect(result.isError).toBe(true); expect(result.content).toEqual([{ type: "text", text: expect.stringContaining("Invalid arguments") }]); expect(notes).toEqual([]); await server.callTool("add_internal_note", { ticketId: "T-1", note: "Customer called back" }); expect(notes).toEqual([{ ticketId: "T-1", note: "Customer called back" }]); } finally { await server.dispose(); } }); test("a throw fails the call", async () => { const server = await create({ info, tools: [SearchTickets] }); try { await server.registerTool({ name: "sync_tickets", execute: () => { throw new Error("desk API is down"); } }); await expect(server.callTool("sync_tickets")).rejects.toMatchObject({ code: "TOOL_EXECUTION_ERROR", message: 'Tool "sync_tickets" execution failed: desk API is down', }); } finally { await server.dispose(); } }); ``` ### Joining one of several apps A server from `createDirect()` can have several apps. A runtime tool joins one, named by `app`, and that app's plugins run their hooks for it. Here the help-desk app counts every call with a plugin, and the billing app has none: ```ts usage.plugin.ts active import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk"; export const calls: string[] = []; @Plugin({ name: "usage", description: "Records every tool call" }) export class UsagePlugin extends DynamicPlugin { @ToolHook.Did("execute") async record(ctx: FlowCtxOf<"tools:call-tool">) { const name = ctx.state.tool?.metadata.name; if (name) calls.push(name); } } ``` ```ts apps.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { UsagePlugin } from "./usage.plugin"; @Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, tickets: [] }; } } @Tool({ name: "get_invoice", description: "Get an invoice by number", inputSchema: { number: z.string() } }) export class GetInvoice extends ToolContext { async execute({ number }: { number: string }) { return { number, total: 120 }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets], plugins: [UsagePlugin] }) export class HelpDeskApp {} @App({ id: "billing", name: "Billing", tools: [GetInvoice] }) export class BillingApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp] }; ``` ```ts apps.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./apps"; import { calls } from "./usage.plugin"; const openTicket = { content: [{ type: "text" as const, text: "T-1" }] }; test("with several apps, the tool names its app", async () => { const server = await FrontMcpInstance.createDirect(config); try { await expect(server.registerTool({ name: "get_open_ticket", execute: () => openTicket })).rejects.toMatchObject({ code: "ENTRY_VALIDATION_FAILED", message: 'Tool entry validation failed: runtime tool "get_open_ticket" must name its app (one of: help-desk, billing)', }); await expect(server.registerTool({ name: "get_open_ticket", app: "crm", execute: () => openTicket })).rejects.toThrow( 'runtime tool "get_open_ticket" names app "crm", which is not here', ); } finally { await server.dispose(); } }); test("the app's plugin hooks run for the runtime tool", async () => { const server = await FrontMcpInstance.createDirect(config); try { await server.registerTool({ name: "get_open_ticket", app: "help-desk", execute: () => openTicket }); await server.registerTool({ name: "get_open_invoice", app: "billing", execute: () => openTicket }); await server.callTool("get_open_ticket"); await server.callTool("get_open_invoice"); await server.callTool("search_tickets", { query: "log" }); expect(calls).toEqual(["get_open_ticket", "search_tickets"]); } finally { await server.dispose(); } }); ``` ### Tools only agents in the browser see A tool that fills in a form on the page is for an agent working in that page, not for an MCP client somewhere else. `availableWhen: { surface: ["webmcp"] }` keeps it from MCP clients, `server.callTool()` included, while [`@frontmcp/plugin-webmcp`](https://frontmcp.dev/reference/plugins/webmcp) offers it to the browser's agents. `surface: ["mcp"]` does the opposite: Initial call configuration: ```js { tool: "fill_reply_form", arguments: { text: "Thanks, try again now" } } ``` ```ts reply-form.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "fill_reply_form", description: "Type a reply into the open ticket's reply box, for the support agent to review and send", inputSchema: { text: z.string() }, availableWhen: { surface: ["webmcp"] }, }) export class FillReplyForm extends ToolContext { async execute({ text }: { text: string }) { return { filled: text.length }; } } @Tool({ name: "export_tickets", description: "Export every ticket as CSV", inputSchema: {}, availableWhen: { surface: ["mcp"] }, }) export class ExportTickets extends ToolContext { async execute() { return { csv: "id,title\nT-1,Cannot log in" }; } } ``` ```ts surface.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { ExportTickets, FillReplyForm } from "./reply-form.tool"; test("an MCP client isn't offered the webmcp-only tool", async ({ mcp }) => { expect((await mcp.tools.list()).map((t: { name: string }) => t.name)).toEqual(["export_tickets"]); expect((await mcp.tools.call("fill_reply_form", { text: "hi" })).text()).toBe('Tool "fill_reply_form" not found'); }); test("nor is server.callTool(), and runtime tools follow the same rule", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [FillReplyForm, ExportTickets] }); try { await server.registerTool({ name: "get_open_ticket", availableWhen: { surface: ["webmcp"] }, execute: () => ({ content: [{ type: "text", text: "T-1" }] }), }); expect((await server.listTools()).tools.map((t) => t.name)).toEqual(["export_tickets"]); await expect(server.callTool("get_open_ticket")).rejects.toMatchObject({ code: "TOOL_NOT_FOUND" }); } finally { await server.dispose(); } }); ``` ### Releasing what a provider holds A provider that starts a timer should stop it when the server is disposed. Inject `ScopeEntry` into its factory and register the stop with `onDispose()`: ```ts ticket-feed.provider.ts active import { ScopeEntry } from "@frontmcp/sdk"; export const log: string[] = []; export class TicketFeed { private timer = setInterval(() => this.poll(), 60_000); poll() {} stop() { clearInterval(this.timer); log.push("feed stopped"); } } export const ticketFeed = { name: "TicketFeed", provide: TicketFeed, inject: () => [ScopeEntry] as const, useFactory: (scope: ScopeEntry) => { const feed = new TicketFeed(); scope.onDispose(() => feed.stop()); return feed; }, }; ``` ```ts watch-queue.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; import { TicketFeed, log } from "./ticket-feed.provider"; @Tool({ name: "watch_queue", description: "Watch the ticket queue until the server stops", inputSchema: {} }) export class WatchQueue extends ToolContext { async execute() { this.get(TicketFeed); this.scope.onDispose(() => void log.push("watch ended")); return { watching: true }; } } ``` ```ts dispose.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { log, ticketFeed } from "./ticket-feed.provider"; import { WatchQueue } from "./watch-queue.tool"; test("callbacks run once, last registered first", async () => { log.length = 0; const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [WatchQueue], providers: [ticketFeed] }); await server.callTool("watch_queue"); expect(log).toEqual([]); await server.dispose(); await server.dispose(); expect(log).toEqual(["watch ended", "feed stopped"]); }); ``` --- ## Troubleshooting ### `A tool named "…" is already registered` Another tool of the server has the name: a tool of any of its apps, FrontMCP's own, or a runtime tool registered earlier and not removed. Runtime tools aren't renamed to `:`. Remove the earlier one with the function `registerTool()` returned, or pick another name. A component that registers on every render should register once and read its current state in `execute`, as in [Adding a tool while something is on screen](https://frontmcp.dev/reference/sdk/register-tool#adding-a-tool-while-something-is-on-screen). ### `Tool entry validation failed: runtime tool "…" must name its app (one of: …)` The server has more than one app, so FrontMCP can't tell which the tool joins. Add `app` with one of the ids listed. See [Joining one of several apps](https://frontmcp.dev/reference/sdk/register-tool#joining-one-of-several-apps). ### `Tool entry validation failed: runtime tool "…" input schema must be an object schema` `inputSchema` is a JSON Schema whose `type` isn't `"object"`, or a Zod schema. Pass a JSON Schema object, `{ type: "object", properties: { … } }`; from a Zod object, `toJSONSchema(schema)` from `@frontmcp/sdk` (Zod's own `z.toJSONSchema()` throws on an optional object inside the schema: see [`z`, `eagerZ` and `lazyZ`](https://frontmcp.dev/reference/sdk#z-eagerz-and-lazyz)). ### `server.registerTool is not a function` The object isn't a server from `create()` or `createDirect()`. A `@FrontMcp` class, a `createFetchHandler()` handler and a `connect()` client don't have it. See [Caveats](https://frontmcp.dev/reference/sdk/register-tool#caveats). ### My tool gets arguments its schema doesn't allow FrontMCP doesn't validate runtime tools' arguments. [Parse them in `execute`](https://frontmcp.dev/reference/sdk/register-tool#checking-the-arguments). ### `Tool "…" not found` for a tool with `surface: ["webmcp"]` The tool is offered only to agents in the browser, through [`@frontmcp/plugin-webmcp`](https://frontmcp.dev/reference/plugins/webmcp). MCP clients and `server.callTool()` call on the `"mcp"` surface. Add `"mcp"` to `surface` if they should reach it. ### An `onDispose()` callback never runs The scope wasn't disposed: nothing called `dispose()` on the `create()` or `createDirect()` server, or `close()` on the last `connect()` client of its configuration. Call `dispose()` in a `finally`, as in [`create()`](https://frontmcp.dev/reference/sdk/create#disposing). A Node server started with `@FrontMcp` disposes its scopes when it shuts down on `SIGINT` or `SIGTERM`. --- # connect() and client adapters > Connect an MCP client to your server in memory, and get its tools and results in the shape Claude, OpenAI, LangChain or the Vercel AI SDK expects. Source: https://frontmcp.dev/reference/sdk/connect [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `connect()` builds your server inside your process and connects an MCP client to it in memory. The client does what a client over HTTP does, from the `initialize` handshake to every call, without a network. The adapters, `connectClaude()`, `connectOpenAI()`, `connectLangChain()` and `connectVercelAI()`, connect the same way, then convert the server's tools, and the results of calling them, to the shape each LLM API or framework takes. Use them when your own code is the MCP client: an agent loop that calls a model's API, a command-line tool, a test. ```ts const client = await connect(config, options?) const client = await connectClaude(config, options?) const tools = await client.listTools() const result = await client.callTool(name, args?, { onProgress }?) await client.close() ``` --- ## Reference ### `connect(config, options?)` ```ts agent.ts import { connect } from "@frontmcp/sdk"; import { config } from "./config"; const client = await connect(config, { clientInfo: { name: "desk-agent", version: "1.0.0" }, session: { user: { sub: "nour" } }, }); try { const result = await client.callTool("get_ticket", { id: "T-1" }); } finally { await client.close(); } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/connect#usage) #### Parameters | Parameter | Type | Description | | --- | --- | --- | | `config` | `FrontMcpConfigInput` | The configuration you give [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp#options), with `apps`. The decorated class works too. A `create()`-style configuration, with `tools` at the top level, fails with [`expected array, received undefined`](https://frontmcp.dev/reference/sdk/connect#invalid-input-expected-array-received-undefined). | | `options.clientInfo` | `{ name, version }` | Who the client says it is. Becomes [`this.clientInfo`](https://frontmcp.dev/learn/reading-the-request) in your tools, and chooses [the format](https://frontmcp.dev/reference/sdk/connect#the-adapters) of `listTools()` and `callTool()`. Defaults to `{ name: "mcp-client", version: "1.0.0" }`. | | `options.session.user` | `{ sub?, name?, email?, … }` | The signed-in user, as claims. [See below.](https://frontmcp.dev/reference/sdk/connect#who-is-calling) | | `options.session.scopes` | `string[]` | The caller's scopes: `this.auth.scopes` and `this.context.authInfo.scopes`, so `this.auth.hasScope()` works. `[]` without it. New in 1.9.3: before, a `connect()` caller had no scopes. | | `options.session.id` | `string` | The session id, `this.context.sessionId`. Defaults to `direct:` and a UUID. | | `options.authToken` | `string` | The caller's token, `this.context.authInfo.token`. | | `options.capabilities` | `ClientCapabilities` | The capabilities the client declares in `initialize`. | | `options.onElicitation` | `(request) => { action, content? }` | Answers the tools' [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit) questions, as the user would. Passing it declares the `elicitation` capability. [See below.](https://frontmcp.dev/reference/sdk/connect#answering-the-tools-questions) | | `options.workerEnv` | `Record` | Platform bindings, like a Cloudflare Worker's `env`, for every request of this client: the tools, resources, prompts, jobs and agents it calls read them as [`this.workerEnv`](https://frontmcp.dev/reference/sdk/contexts#thisworkerenv). Never copied into `process.env`. A client from [`server.connect()`](https://frontmcp.dev/reference/sdk/create#passing-a-workers-bindings) without its own gets the server's. The adapters take it too. New in 1.9.4. | | `options.app` | `string` | The id of an app with an endpoint of its own: each app's with `splitByApp`, a `standalone` app's otherwise. The client connects to that endpoint instead of the main one. [See below.](https://frontmcp.dev/reference/sdk/connect#connecting-to-one-app) New in 1.9.3. | #### Returns A promise of a [`DirectClient`](https://frontmcp.dev/reference/sdk/connect#the-directclient), connected and past the handshake. #### One server per configuration object `connect()` builds the server the first time it sees a configuration object, and every `connect()` with the **same object** while one of its clients is open connects to that server, whichever `app` it names. Providers, and anything else the server keeps, are shared between those clients. A copy, like `{ ...config }`, builds a separate server. [The sharing example](https://frontmcp.dev/reference/sdk/connect#sharing-one-server-between-clients) shows both. `close()` ends one client, and the server goes on serving the others. When the last client closes, the server is disposed, every endpoint of it, and the next `connect()` with that object builds a new one, with new providers. Keep a client open for as long as the server's state should last. (Changed in 1.9.2: before, the server stayed in memory after its last client closed.) #### Who is calling | Options | `this.auth` in your tools | | --- | --- | | No `session.user` | An anonymous caller: `isAnonymous` is `true`, and `user.sub` is `""`. | | `session: { user: { sub: "nour", roles: ["lead"] } }` | `user.sub` is `"nour"`, `isAnonymous` is `false`, `hasRole("lead")` is `true`. `scopes` is `[]`. | | `session: { user: { sub: "nour" }, scopes: ["tickets:write"] }` | `scopes` is `["tickets:write"]`, so `hasScope("tickets:write")` is `true`. A `scope` claim in `user` isn't read. | | `authToken: "…"` alone | Still anonymous. The token is passed along as `this.context.authInfo.token`, never checked or decoded. | Nothing checks the user or the scopes either: in-process, your code is the authentication, so pass only what your code has verified. `this.context.authInfo.clientId` is empty; read the caller from `this.auth`. ### The adapters Each adapter is `connect()` with a preset `clientInfo`, which picks a format for `listTools()` and `callTool()`. They take the same options as `connect()`, except `clientInfo`. | Function | Client name | `listTools()` returns | `callTool()` returns | | --- | --- | --- | --- | | `connect()` | `mcp-client` | MCP tools: `{ name, description, inputSchema, … }[]` | The MCP result: `content`, `structuredContent`, `isError`, `_meta`. | | `connectClaude()` | `claude` | `{ name, description, input_schema }[]`, for the Messages API's `tools`. | The result's `content` blocks, for a `tool_result`. | | `connectOpenAI()` | `openai` | `{ type: "function", function: { name, description, parameters, strict: true } }[]`, for Chat Completions' `tools`. | The text, parsed: an object for an object result, a string for a string. | | `connectLangChain()` | `langchain` | `{ name, description, schema }[]`. | As `connectOpenAI()`. | | `connectVercelAI()` | `vercel-ai` | `{ [name]: { description, parameters } }`, one object keyed by tool name. | As `connectOpenAI()`. | The format follows the client's name, so `connect(config, { clientInfo: { name: "gpt-researcher", version: "1.0.0" } })` gets OpenAI's format too. A name containing `openai` or `gpt` means OpenAI; `claude` or `anthropic`, Claude; `langchain`, LangChain; `vercel` or `ai-sdk`, the Vercel AI SDK; anything else, plain MCP. `client.getDetectedPlatform()` says which one applies. The same name also sets `this.platform` in your tools, by FrontMCP's own rules: `claude` is `"claude"`, `mcp-client` is `"generic-mcp"`. What the conversion changes: - **A failed call rejects**, because these formats have nowhere to put `isError`. The error is a `ToolCallError`: its `message` is the tool's error text, `toolName` the tool's name, `code` `"TOOL_CALL_ERROR"`, and `result` the MCP result, with `isError` and the tool's `_meta.code`. [Catch it and tell the model.](https://frontmcp.dev/reference/sdk/connect#keeping-failures-visible-to-claude) Plain `connect()` returns the failure as a result. (Changed in 1.9.2: before, the adapters returned a failure exactly like a successful result.) - **Every block but the text**, with OpenAI and LangChain: images are dropped, and several text blocks are joined with newlines. The Vercel format keeps images: a result with several blocks becomes `{ text: [...], images: [...] }`. Claude's keeps every block. - **A predictable type.** An object result is returned as an object, except when it has a `text` property holding a string: then it's returned as its JSON text. A tool returning `{ text: "Call the customer back" }` gives you `'{"text":"Call the customer back"}'`. `connectOpenAI()` also changes each schema: every object gets `additionalProperties: false`, and each tool gets `strict: true`. Its `required` list isn't changed, though, and OpenAI's strict mode requires every property to be in `required`, so a tool with an optional argument [may be refused](https://frontmcp.dev/reference/sdk/connect#openai-refuses-a-tools-schema). ### The `DirectClient` `connect()`, the adapters and [`DirectMcpServer.connect()`](https://frontmcp.dev/reference/sdk/create#the-directmcpserver) all return one. Tools, resources and prompts. Only the two tool methods are converted; the rest return MCP results as they are: | Method | Returns | | --- | --- | | `listTools()` | The tools, in [the client's format](https://frontmcp.dev/reference/sdk/connect#the-adapters). Every page: a server with more than 40 tools returns them all. | | `callTool(name, args?, options?)` | The result, in the client's format. With plain MCP results, a failure is a result too; with an adapter's format, it rejects with `ToolCallError`. `options.onProgress` gets the tool's [progress](https://frontmcp.dev/reference/sdk/connect#hearing-a-tools-progress). | | `listResources()`, `listResourceTemplates()` | `{ resources }`, `{ resourceTemplates }`, every page of them. | | `readResource(uri)` | `{ contents }`. An unknown URI throws `McpError` `-32002`. | | `listPrompts()`, `getPrompt(name, args?)` | `{ prompts }`, every page of them, and `{ description, messages }`. An unknown prompt throws `McpError` `-32602`. | | `complete({ ref, argument })` | Suggestions for a prompt or resource argument. | | `subscribeResource(uri)`, `unsubscribeResource(uri)`, `onResourceUpdated(handler)` | Resource subscriptions. `subscribeResource()` fails as `readResource()` would for a URI the server can't serve, like one no resource matches (`-32002`). `onResourceUpdated` returns a function that removes the handler. | Changed in 1.8.7: `listResources()`, `listResourceTemplates()` and `listPrompts()` follow every page, as `listTools()` already did; before, they returned one. [`create()`](https://frontmcp.dev/reference/sdk/create#listing-more-than-one-page-of-tools) shows paging with 45 tools. Skills, jobs and workflows. These parse the result for you: | Method | Returns | | --- | --- | | `listSkills(options?)` | `{ skills, total, hasMore }`. On a server without skills, it throws `McpError` `-32601` `Method not found`. | | `searchSkills(query, options?)` | `{ skills, total, hasMore, guidance }`, each skill with a `score` and its tools' availability. | | `loadSkills(ids, options?)` | `{ skills, summary, nextSteps }`, each skill with its instructions and tools, and with `activateSession: true` a `session`, like `{ activated: false }` (since 1.9.3). | | `listSep2640Skills()`, `readSkillTextUri(uri)` | The `skill://index.json` entries, and the text of a `skill://` resource. | | `listJobs()`, `executeJob(name, input?, options?)`, `getJobStatus(runId)` | `{ jobs, count }`; `{ runId, state, result?, logs }`; the run's status. | | `listWorkflows()`, `executeWorkflow(name, input?, options?)`, `getWorkflowStatus(runId)` | The same for [workflows](https://frontmcp.dev/reference/sdk/workflow). | About the connection: | Method | Returns | | --- | --- | | `getSessionId()` | `session.id`, or the generated one. | | `getClientInfo()`, `getServerInfo()`, `getCapabilities()` | The client's info, and the server's info and capabilities from the handshake. | | `getDetectedPlatform()` | `"openai"`, `"claude"`, `"langchain"`, `"vercel-ai"` or `"raw"`. | | `close()` | Ends the connection, and disposes the server when no other client is connected to it. A client from a [`DirectMcpServer`](https://frontmcp.dev/reference/sdk/create#the-directmcpserver)'s `connect()` never disposes that server: `server.dispose()` does. Calls after it throw `Not connected`; a second `close()` does nothing. | | `onNotification(handler)` | Registers a handler for notifications from the server, like `notifications/tools/list_changed` when a tool is added with [`registerTool()`](https://frontmcp.dev/reference/sdk/register-tool) or removed, and a tool's log messages once you've called `setLogLevel()`. Returns a function that removes it. See [Hearing that the tools changed](https://frontmcp.dev/reference/sdk/connect#hearing-that-the-tools-changed). | | `setLogLevel(level)` | Sets the lowest level of log message the server sends this client, like `"info"`. Until you call it, a tool's [`this.notify()`](https://frontmcp.dev/reference/sdk/notify) sends this client nothing. See [Hearing a tool's log messages](https://frontmcp.dev/reference/sdk/connect#hearing-a-tools-log-messages). | | `onElicitation(handler)` | Sets the handler that answers `this.elicit()`, replacing the one passed to `connect()`. It's only asked if the client declared elicitation when it connected. Returns a function that removes it. See [Answering the tools' questions](https://frontmcp.dev/reference/sdk/connect#answering-the-tools-questions). | | `submitElicitationResult(id, response)` | Answers a question from [the fallback flow](https://frontmcp.dev/reference/sdk/connect#a-tool-that-asks-the-user-returns-this-tool-requires-user-input-to-continue), whose `elicitId` is in the result's `_meta.elicitationPending`. It calls the `sendElicitationResult` tool, which runs the waiting tool again with the answer, and returns that tool's result, in the client's format. Changed in 1.9.3: before, it failed with `McpError` `-32601` `Method not found`. | #### Errors With `connect()`, a tool that fails returns a result with `isError: true` and its `_meta.code`, exactly as over HTTP: `this.fail()` codes, `INVALID_INPUT` for bad arguments, `TOOL_NOT_FOUND` for an unknown tool. In development, `_meta` also has the error's `stack`. The adapters reject with a `ToolCallError` instead, whose `result` is that same result. Resources and prompts throw: `McpError` with the JSON-RPC code, like `-32002` for an unknown resource. #### Caveats - Changed in 1.10.0: before, `connect()` and its adapters initialized HTTP services. In their default mode they now build no HTTP transport, health checks, HA or relay, custom routes or webhook routes. `http.routes` are neither registered nor checked for reserved-path collisions. Without explicit `auth`, the public auth has no OAuth, OAuth discovery or `session:verify` flows; with it, that mode's auth flows are kept. See [what an in-process server initializes](https://frontmcp.dev/reference/sdk/create#what-the-server-initializes). CLI mode keeps its existing initialization. - **The adapters reject a failed call** with `ToolCallError`. Catch it around `callTool()`, or the first tool that fails ends your agent loop. - **Nothing is checked.** `session.user`, `session.scopes` and `authToken` are taken as given; without a user, the caller is anonymous. - **One server per configuration object**, shared by every client connected with it, and disposed when the last of them closes. - **Requests carry no headers**: `this.context.metadata` is `{ customHeaders: {} }`, and every call starts a new trace. - **What reaches back to the client.** `this.elicit()` asks the `onElicitation` handler, with `elicitation: { enabled: true }` on the server; without a handler, the tool returns FrontMCP's [fallback](https://frontmcp.dev/reference/sdk/connect#a-tool-that-asks-the-user-returns-this-tool-requires-user-input-to-continue). `this.notify()` sends once the client has called `setLogLevel()`. `this.progress()` sends to a call made with `onProgress`, and returns `false` in any other. The server's own notifications, like `notifications/tools/list_changed`, arrive. - For a server with no client at all, where failures are exceptions, use [`create()` or `createDirect()`](https://frontmcp.dev/reference/sdk/create). --- ## Usage ### Keeping failures visible to Claude An agent loop runs each `tool_use` block Claude asks for and sends back a `tool_result`. `connectClaude()` gives you the tools in Claude's shape, and its `callTool()` returns the content blocks a `tool_result` takes. When the tool fails, `callTool()` rejects with a `ToolCallError` instead. Catch it, and send the error's content blocks with `is_error: true`, so the model knows the call failed and can try something else: ```ts agent.ts active import { ToolCallError, connectClaude } from "@frontmcp/sdk"; import { config } from "./config"; type ToolUse = { type: "tool_use"; id: string; name: string; input: Record }; // The `tools` to send to Claude's Messages API. export async function claudeTools() { const client = await connectClaude(config); try { return await client.listTools(); } finally { await client.close(); } } // The `tool_result` block to send back for one `tool_use` block. export async function toolResult(block: ToolUse) { const client = await connectClaude(config); try { const content = await client.callTool(block.name, block.input); return { type: "tool_result", tool_use_id: block.id, content, is_error: false }; } catch (err) { if (!(err instanceof ToolCallError)) throw err; return { type: "tool_result", tool_use_id: block.id, content: err.result.content, is_error: true }; } finally { await client.close(); } } ``` ```ts agent.test.ts import { test, expect } from "@frontmcp/testing"; import { ToolCallError, connectClaude } from "@frontmcp/sdk"; import { claudeTools, toolResult } from "./agent"; import { config } from "./config"; test("tools come in the Messages API's shape", async () => { expect(await claudeTools()).toEqual([ { name: "get_ticket", description: "Get one support ticket by its id", input_schema: expect.objectContaining({ type: "object", required: ["id"] }) }, ]); }); test("a good call is a tool_result with its content blocks", async () => { expect(await toolResult({ type: "tool_use", id: "toolu_1", name: "get_ticket", input: { id: "T-1" } })).toEqual({ type: "tool_result", tool_use_id: "toolu_1", content: [{ type: "text", text: '{"id":"T-1","title":"Cannot log in","status":"open"}' }], is_error: false, }); }); test("a failed call is a tool_result with is_error", async () => { expect(await toolResult({ type: "tool_use", id: "toolu_2", name: "get_ticket", input: { id: "T-9" } })).toEqual({ type: "tool_result", tool_use_id: "toolu_2", content: [{ type: "text", text: "There's no ticket T-9." }], is_error: true, }); }); test("connectClaude() rejects a failed call with ToolCallError", async () => { const client = await connectClaude(config); try { const error = await client.callTool("get_ticket", { id: "T-9" }).catch((err: unknown) => err); expect(error).toBeInstanceOf(ToolCallError); expect(error).toMatchObject({ message: "There's no ticket T-9.", toolName: "get_ticket", code: "TOOL_CALL_ERROR", result: { isError: true, _meta: { code: "TICKET_NOT_FOUND" } }, }); } finally { await client.close(); } }); ``` ```ts config.ts import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], }; ``` ```ts help-desk.app.ts import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets = [{ id: "T-1", title: "Cannot log in", status: "open" }]; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) 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}.`, "TICKET_NOT_FOUND")); return ticket; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` The Messages API call itself needs a key and a network, so it isn't here; `claudeTools()` goes in its `tools`, and each `toolResult()` in the next user message. [Running FrontMCP Anywhere](https://frontmcp.dev/learn/running-frontmcp-anywhere) builds the same agent step by step. Each function here connects and closes, and with no other client open, each `close()` disposes the server, so every call builds it again. That's fine for a few calls. An agent that runs for a while connects once, and closes when it's done. ### Giving tools to OpenAI `connectOpenAI()` returns tools ready for Chat Completions' `tools`. Its `callTool()` results need care: a tool message's `content` is a string, and the adapter returns an object for most results, a string for others, and rejects a failed call with `ToolCallError`. Taking the text from `connect()` gives you the same string every time, failures included: ```ts openai.ts active import { connect, connectOpenAI } from "@frontmcp/sdk"; import { config } from "./config"; type ToolCall = { id: string; function: { name: string; arguments: string } }; type CallToolResult = { content: { type: string; text?: string }[]; isError?: boolean }; export async function openAITools() { const client = await connectOpenAI(config); try { return await client.listTools(); } finally { await client.close(); } } // The `tool` message to send back for one of the model's tool calls. export async function toolMessage(call: ToolCall) { const client = await connect(config); try { const result = (await client.callTool(call.function.name, JSON.parse(call.function.arguments))) as CallToolResult; const text = result.content.flatMap((block) => (block.type === "text" && block.text ? [block.text] : [])).join("\n"); return { role: "tool", tool_call_id: call.id, content: result.isError ? `Error: ${text}` : text }; } finally { await client.close(); } } ``` ```ts openai.test.ts import { test, expect } from "@frontmcp/testing"; import { connectOpenAI } from "@frontmcp/sdk"; import { openAITools, toolMessage } from "./openai"; import { config } from "./config"; test("tools are functions, with strict schemas", async () => { const tools = (await openAITools()) as { function: { name: string; parameters: Record; strict: boolean } }[]; const search = tools.find((tool) => tool.function.name === "search_tickets")!; expect(search.function.strict).toBe(true); expect(search.function.parameters).toMatchObject({ type: "object", additionalProperties: false, required: ["query"], // 🚩 `status` is optional, so it isn't listed }); }); test("tool messages are always text", async () => { const call = (name: string, args: object) => ({ id: "call_1", function: { name, arguments: JSON.stringify(args) } }); expect(await toolMessage(call("search_tickets", { query: "log" }))).toEqual({ role: "tool", tool_call_id: "call_1", content: '{"tickets":[{"id":"T-1","title":"Cannot log in"}]}', }); expect((await toolMessage(call("latest_note", {}))).content).toBe('{"text":"Call the customer back"}'); expect((await toolMessage(call("search_tickets", {}))).content).toMatch(/^Error: /); }); test("🚩 the adapter's results change type with the result's shape", async () => { const client = await connectOpenAI(config); try { expect(await client.callTool("search_tickets", { query: "log" })).toEqual({ tickets: [{ id: "T-1", title: "Cannot log in" }] }); expect(await client.callTool("latest_note", {})).toBe('{"text":"Call the customer back"}'); } finally { await client.close(); } }); ``` ```ts config.ts import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], }; ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string(), status: z.enum(["open", "closed"]).optional() }, }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [{ id: "T-1", title: "Cannot log in" }].filter((t) => t.title.toLowerCase().includes(query)) }; } } @Tool({ name: "latest_note", description: "The newest internal note", inputSchema: {} }) class LatestNote extends ToolContext { async execute() { return { text: "Call the customer back" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, LatestNote] }) export class HelpDesk {} ``` ### Tools for LangChain and the Vercel AI SDK `connectLangChain()` and `connectVercelAI()` return plain data: tool names, descriptions and JSON Schemas, in each library's layout. Build the library's own tool objects from them. Their `callTool()` results follow the same rules as `connectOpenAI()`'s, so the `toolMessage()` approach above works for them too. Any client whose name contains `gpt` gets OpenAI's format: ```ts formats.test.ts active import { test, expect } from "@frontmcp/testing"; import { connect, connectLangChain, connectVercelAI } from "@frontmcp/sdk"; import { config } from "./config"; test("LangChain: a list of { name, description, schema }", async () => { const client = await connectLangChain(config); try { expect(await client.listTools()).toEqual([ { name: "get_ticket", description: "Get one support ticket by its id", schema: expect.objectContaining({ type: "object", required: ["id"] }) }, ]); expect(await client.callTool("get_ticket", { id: "T-1" })).toEqual({ id: "T-1", title: "Cannot log in" }); } finally { await client.close(); } }); test("Vercel AI SDK: one object keyed by tool name", async () => { const client = await connectVercelAI(config); try { expect(await client.listTools()).toEqual({ get_ticket: { description: "Get one support ticket by its id", parameters: expect.objectContaining({ type: "object" }) }, }); } finally { await client.close(); } }); test("the client's name picks the format", async () => { const client = await connect(config, { clientInfo: { name: "gpt-researcher", version: "1.0.0" } }); try { expect(client.getDetectedPlatform()).toBe("openai"); expect(await client.listTools()).toEqual([expect.objectContaining({ type: "function" })]); } finally { await client.close(); } }); ``` ```ts config.ts import { HelpDesk } from "./help-desk.app"; export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], }; ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` ### Reading resources and prompts Only the tool methods are converted. Resources and prompts come back as MCP results, from every adapter, and failures throw an `McpError` with the JSON-RPC code: ```ts resources.test.ts active import { test, expect } from "@frontmcp/testing"; import { connect } from "@frontmcp/sdk"; import { config } from "./config"; test("resources and prompts are MCP results", async () => { const client = await connect(config); try { expect(await client.readResource("tickets://open")).toEqual({ contents: [{ uri: "tickets://open", mimeType: "application/json", text: '{"tickets":["T-1"]}' }], }); expect((await client.getPrompt("triage", { id: "T-1" })).messages).toEqual([ { role: "user", content: { type: "text", text: "Triage ticket T-1." } }, ]); } finally { await client.close(); } }); test("failures throw, with the JSON-RPC code", async () => { const client = await connect(config); try { await expect(client.readResource("tickets://closed")).rejects.toMatchObject({ code: -32002 }); await expect(client.subscribeResource("tickets://closed")).rejects.toMatchObject({ code: -32002 }); await expect(client.getPrompt("escalate", {})).rejects.toMatchObject({ code: -32602 }); await expect(client.listSkills()).rejects.toMatchObject({ code: -32601 }); // this server has no skills await expect(client.listJobs()).rejects.toThrow("is not valid JSON"); // …and no jobs expect(await client.callTool("reopen_ticket", {})).toMatchObject({ isError: true, _meta: { code: "TOOL_NOT_FOUND" } }); } finally { await client.close(); } }); ``` ```ts config.ts import { App, Prompt, PromptContext, Resource, ResourceContext } from "@frontmcp/sdk"; @Resource({ uri: "tickets://open", name: "open-tickets", mimeType: "application/json" }) class OpenTickets extends ResourceContext { async execute() { return { tickets: ["T-1"] }; } } @Prompt({ name: "triage", description: "Triage a ticket", arguments: [{ name: "id", required: true }] }) class Triage extends PromptContext { async execute({ id }: { id: string }) { return { messages: [{ role: "user" as const, content: { type: "text" as const, text: `Triage ticket ${id}.` } }] }; } } @App({ id: "help-desk", name: "Help Desk", resources: [OpenTickets], prompts: [Triage] }) export class HelpDesk {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] }; ``` ### Choosing who the client is `clientInfo` and `session` decide what your tools see. Without a user, the caller is anonymous, and without `session.scopes`, the caller has none. `workerEnv` hands them a Worker's bindings (since 1.9.4): ```ts whoami.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "whoami", description: "Who is calling, and from which client. For debugging.", inputSchema: {} }) export class WhoAmI extends ToolContext { async execute() { return { user: this.auth.user.sub, anonymous: this.auth.isAnonymous, scopes: this.auth.scopes, canWrite: this.auth.hasScope("tickets:write"), client: this.clientInfo?.name ?? null, platform: this.platform, session: this.context.sessionId, bindings: Object.keys(this.workerEnv ?? {}), }; } } ``` ```ts whoami.test.ts import { test, expect } from "@frontmcp/testing"; import { App, connect } from "@frontmcp/sdk"; import { WhoAmI } from "./whoami.tool"; @App({ id: "desk", name: "Desk", tools: [WhoAmI] }) class Desk {} const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }; async function whoami(options?: Parameters[1]) { const client = await connect(config, options); try { return (await client.callTool("whoami", {})) as { structuredContent: Record }; } finally { await client.close(); } } test("by default: anonymous, through a generic client", async () => { expect((await whoami()).structuredContent).toMatchObject({ user: "", anonymous: true, client: "mcp-client", platform: "generic-mcp" }); }); test("session.user signs the caller in, and clientInfo names the client", async () => { const result = await whoami({ clientInfo: { name: "cursor", version: "1.4.0" }, session: { id: "desk-42", user: { sub: "nour" } }, }); expect(result.structuredContent).toEqual({ user: "nour", anonymous: false, scopes: [], canWrite: false, client: "cursor", platform: "cursor", session: "desk-42", bindings: [] }); }); test("a token alone signs no one in", async () => { expect((await whoami({ authToken: "tok_123" })).structuredContent).toMatchObject({ user: "", anonymous: true }); }); test("session.scopes are the caller's scopes", async () => { expect((await whoami({ session: { user: { sub: "nour" } } })).structuredContent).toMatchObject({ scopes: [], canWrite: false }); const result = await whoami({ session: { user: { sub: "nour", scope: "tickets:read" }, scopes: ["tickets:write"] } }); expect(result.structuredContent).toMatchObject({ scopes: ["tickets:write"], canWrite: true }); }); test("workerEnv is what `this.workerEnv` reads", async () => { expect((await whoami({ workerEnv: { DESK: "berlin" } })).structuredContent).toMatchObject({ bindings: ["DESK"] }); }); ``` ### Sharing one server between clients Every `connect()` with the same configuration object connects to one server, for as long as one of its clients is open. This tool counts calls in a provider, so the count shows which server answered: ```ts counter.ts active import { App, Provider, Tool, ToolContext } from "@frontmcp/sdk"; @Provider({ name: "call-counter" }) export class CallCounter { private calls = 0; next() { return ++this.calls; } } @Tool({ name: "count_call", description: "Count this call", inputSchema: {} }) export class CountCall extends ToolContext { async execute() { return { call: this.get(CallCounter).next(), client: this.clientInfo?.name ?? null }; } } @App({ id: "desk", name: "Desk", tools: [CountCall], providers: [CallCounter] }) export class Desk {} ``` ```ts sharing.test.ts import { test, expect } from "@frontmcp/testing"; import { connect } from "@frontmcp/sdk"; import { Desk } from "./counter"; type Result = { structuredContent: { call: number; client: string | null } }; test("the same object means the same server; a copy means another", async () => { const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }; const a = await connect(config); const b = await connect(config); const c = await connect({ ...config }); expect(((await a.callTool("count_call")) as Result).structuredContent.call).toBe(1); expect(((await b.callTool("count_call")) as Result).structuredContent.call).toBe(2); expect(((await c.callTool("count_call")) as Result).structuredContent.call).toBe(1); for (const client of [a, b, c]) await client.close(); }); test("closing one client leaves the server to the others", async () => { const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }; const a = await connect(config, { clientInfo: { name: "desk-agent", version: "1.0.0" } }); const b = await connect(config, { clientInfo: { name: "desk-cli", version: "1.0.0" } }); expect(((await b.callTool("count_call")) as Result).structuredContent).toEqual({ call: 1, client: "desk-cli" }); await a.close(); expect(((await b.callTool("count_call")) as Result).structuredContent).toEqual({ call: 2, client: "desk-cli" }); await b.close(); await expect(b.callTool("count_call")).rejects.toThrow("Not connected"); }); test("after the last client closes, the next connect() builds a new server", async () => { const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }; const first = await connect(config); await first.callTool("count_call"); expect(((await first.callTool("count_call")) as Result).structuredContent.call).toBe(2); await first.close(); const second = await connect(config); expect(((await second.callTool("count_call")) as Result).structuredContent.call).toBe(1); await second.close(); }); ``` Share a server when clients should see the same state, and keep a client open for as long as that state matters. When they should be independent, give each a copy of the configuration. ### Connecting to one app With [`splitByApp: true`](https://frontmcp.dev/reference/server/apps#endpoints-standalone-and-splitbyapp), each app is an endpoint of its own, and so is a `standalone` app. Over HTTP each is at `/`. In-process, `app` picks one; without it, the client gets the main endpoint, which with `splitByApp` is the first app's: ```ts apps.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "open" }; } } @Tool({ name: "refund_invoice", description: "Refund an invoice", inputSchema: { invoice: z.string() } }) class RefundInvoice extends ToolContext { async execute({ invoice }: { invoice: string }) { return { invoice, refunded: true }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} @App({ id: "billing", name: "Billing", tools: [RefundInvoice] }) export class Billing {} export const config = { info: { name: "support", version: "1.0.0" }, apps: [HelpDesk, Billing], splitByApp: true }; ``` ```ts apps.test.ts import { test, expect } from "@frontmcp/testing"; import { connect, connectClaude } from "@frontmcp/sdk"; import { config } from "./apps"; const toolNames = (tools: unknown) => (tools as { name: string }[]).map((tool) => tool.name); test("`app` picks the endpoint; without it, the first app's", async () => { const desk = await connect(config); const billing = await connect(config, { app: "billing" }); const claude = await connectClaude(config, { app: "billing" }); try { expect(toolNames(await desk.listTools())).toEqual(["get_ticket"]); expect(toolNames(await billing.listTools())).toEqual(["refund_invoice"]); expect(toolNames(await claude.listTools())).toEqual(["refund_invoice"]); } finally { for (const client of [desk, billing, claude]) await client.close(); } }); test("an app without an endpoint of its own is refused", async () => { const shared = { info: config.info, apps: config.apps }; await expect(connect(shared, { app: "billing" })).rejects.toMatchObject({ name: "ScopeConfigurationError", message: expect.stringContaining('No endpoint serves app "billing" on its own.'), }); }); ``` The clients share one server, as clients of one configuration object always do, whichever endpoint each is on. The adapters take `app` too. [`createDirect()`](https://frontmcp.dev/reference/sdk/create#frontmcpinstancecreatedirectconfig) takes the same option. New in 1.9.3: before, `connect()` reached only the main endpoint, so with `splitByApp` only the first app's tools could be called in-process. ### Answering the tools' questions A tool that asks the user with [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit) needs a client that answers. Pass `onElicitation` to `connect()`: FrontMCP calls it with each question, and the tool gets what it returns, as if the user had filled in the form. The server needs `elicitation: { enabled: true }`, as over HTTP: ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a ticket once the user confirms", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() })); return { id, status: answer.status === "accept" && answer.content?.confirm ? "closed" : "open" }; } } ``` ```ts elicit.test.ts import { test, expect } from "@frontmcp/testing"; import { App, Tool, ToolContext, connect, z } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; @Tool({ name: "connect_billing", description: "Connect the user's billing account", inputSchema: {} }) class ConnectBilling extends ToolContext { async execute() { const url = "https://billing.example/connect?state=abc123"; const answer = await this.elicit("Sign in to your billing account", z.object({}), { mode: "url", url, elicitationId: "billing-abc123" }); return { connected: answer.status === "accept" }; } } @App({ id: "desk", name: "Desk", tools: [CloseTicket, ConnectBilling] }) class Desk {} const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [Desk], elicitation: { enabled: true } }; test("the handler gets the question, and the tool gets the answer", async () => { const questions: unknown[] = []; const client = await connect(config, { onElicitation: (question) => { questions.push(question); return { action: "accept", content: { confirm: true } }; }, }); try { expect(await client.callTool("close_ticket", { id: "T-1" })).toMatchObject({ structuredContent: { id: "T-1", status: "closed" } }); expect(questions).toEqual([ { mode: "form", message: "Close ticket T-1?", requestedSchema: { type: "object", properties: { confirm: { type: "boolean" } }, required: ["confirm"] }, elicitId: expect.stringMatching(/^elicit-/), expiresAt: expect.any(Number), }, ]); const [{ expiresAt }] = questions as { expiresAt: number }[]; expect(expiresAt - Date.now()).toBeGreaterThan(290_000); // five minutes } finally { await client.close(); } }); test("in URL mode, the handler gets the page to open", async () => { const questions: unknown[] = []; const client = await connect(config, { onElicitation: (question) => { questions.push(question); return { action: "accept" }; }, }); try { expect(await client.callTool("connect_billing")).toMatchObject({ structuredContent: { connected: true } }); expect(questions).toEqual([ expect.objectContaining({ mode: "url", url: "https://billing.example/connect?state=abc123", elicitId: "billing-abc123" }), ]); } finally { await client.close(); } }); test("an answer that doesn't fit the schema fails the call", async () => { const client = await connect(config, { onElicitation: () => ({ action: "accept", content: { confirm: "yes" } }) }); try { expect(await client.callTool("close_ticket", { id: "T-3" })).toMatchObject({ isError: true, content: [{ type: "text", text: expect.stringContaining("Invalid elicitation result content") }], _meta: { code: "INVALID_INPUT" }, }); } finally { await client.close(); } }); test("a decline, or a handler that throws, reaches the tool as a decline", async () => { for (const onElicitation of [() => ({ action: "decline" as const }), () => Promise.reject(new Error("no one at the desk"))]) { const client = await connect(config, { onElicitation }); try { expect(await client.callTool("close_ticket", { id: "T-2" })).toMatchObject({ structuredContent: { id: "T-2", status: "open" } }); } finally { await client.close(); } } }); ``` The handler gets the question's `mode`, `message` and `requestedSchema`, its `elicitId`, and `expiresAt`, when the tool stops waiting, in milliseconds since 1970 (five minutes from now, unless the tool set a `ttl`). In URL mode it also gets the `url` to send the user to, and `elicitId` is the tool's `elicitationId`. (Changed in 1.9.3: before, `elicitId` and `expiresAt` were missing.) An accepted answer is checked against the tool's schema: `content` that doesn't fit fails the call with `INVALID_INPUT` and `Invalid elicitation result content`. `client.onElicitation(handler)` replaces the handler later, as long as the client declared elicitation when it connected, with `onElicitation` or `capabilities: { elicitation: { form: {} } }`. Without either, the tool gets [FrontMCP's fallback](https://frontmcp.dev/reference/sdk/connect#a-tool-that-asks-the-user-returns-this-tool-requires-user-input-to-continue) instead of asking. The `elicitation:request` and `elicitation:result` [flows](https://frontmcp.dev/reference/server/flows#other-flows) run for each question, so their hooks see it. (New in 1.9.2: before, `this.elicit()` couldn't reach a client connected with `connect()`.) ### Hearing a tool's log messages A tool's [`this.notify()`](https://frontmcp.dev/reference/sdk/notify) sends a log message only to a client that asked for them. Call `setLogLevel()` with the lowest level you want, and the messages arrive through `onNotification`: ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { await this.notify(`Closing ${id}`); await this.notify(`Loaded ${id} from the database`, "debug"); return { id, status: "closed" }; } } ``` ```ts log.test.ts import { test, expect } from "@frontmcp/testing"; import { App, connect } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; @App({ id: "desk", name: "Desk", tools: [CloseTicket] }) class Desk {} const settle = () => new Promise((resolve) => setTimeout(resolve, 20)); test("after setLogLevel(), messages at that level and up arrive", async () => { const client = await connect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }); const messages: unknown[] = []; client.onNotification((notification: { method: string; params?: unknown }) => { if (notification.method === "notifications/message") messages.push(notification.params); }); try { await client.callTool("close_ticket", { id: "T-1" }); // no level yet: nothing is sent await client.setLogLevel("info"); await client.callTool("close_ticket", { id: "T-2" }); await settle(); expect(messages).toEqual([{ level: "info", logger: "close_ticket", data: { message: "Closing T-2" } }]); } finally { await client.close(); } }); ``` `this.notify()` returns `false` for each message it didn't send. (Changed in 1.9.2: before, `setLogLevel()` always failed.) ### Hearing a tool's progress A tool's [`this.progress()`](https://frontmcp.dev/reference/sdk/progress) reports only to a call that asks for it. Pass `onProgress` in `callTool()`'s third argument: the call then carries a progress token, and the function gets each update as `{ progress, total, message }`. A call without it gets none, and `this.progress()` returns `false`: ```ts bulk-close.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "bulk_close", description: "Close several tickets", inputSchema: { ids: z.array(z.string()) } }) export class BulkClose extends ToolContext { async execute({ ids }: { ids: string[] }) { let progressSent = 0; for (const [i, id] of ids.entries()) { if (await this.progress(i + 1, ids.length, `Closed ${id}`)) progressSent++; } return { closed: ids, progressSent }; } } ``` ```ts progress.test.ts import { test, expect } from "@frontmcp/testing"; import { App, connect } from "@frontmcp/sdk"; import { BulkClose } from "./bulk-close.tool"; @App({ id: "desk", name: "Desk", tools: [BulkClose] }) class Desk {} test("onProgress gets each update", async () => { const client = await connect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }); const updates: unknown[] = []; try { const result = await client.callTool("bulk_close", { ids: ["T-1", "T-2"] }, { onProgress: (update) => updates.push(update) }); expect(result).toMatchObject({ structuredContent: { closed: ["T-1", "T-2"], progressSent: 2 } }); expect(updates).toEqual([ { progress: 1, total: 2, message: "Closed T-1" }, { progress: 2, total: 2, message: "Closed T-2" }, ]); expect(await client.callTool("bulk_close", { ids: ["T-3"] })).toMatchObject({ structuredContent: { progressSent: 0 } }); } finally { await client.close(); } }); ``` Changed in 1.9.3: before, `callTool()` took no options and sent no progress token, so `this.progress()` always returned `false` under `connect()`. ### Hearing that the tools changed A server from [`create()`](https://frontmcp.dev/reference/sdk/create) can add a tool while it runs, with `registerTool()` (new in 1.9). Each client connected to it with `server.connect()` gets `notifications/tools/list_changed`, then and when the tool is removed, so it knows to list the tools again: ```ts ping.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "ping", description: "Check that the server is up", inputSchema: {} }) export class Ping extends ToolContext { async execute() { return { ok: true }; } } ``` ```ts list-changed.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { Ping } from "./ping.tool"; const settle = () => new Promise((resolve) => setTimeout(resolve, 20)); test("the client hears when a tool is added and removed", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [Ping] }); const client = await server.connect(); const heard: string[] = []; client.onNotification((notification: { method: string }) => { heard.push(notification.method); }); try { const remove = await server.registerTool({ name: "get_cart", description: "The items in the shopping cart", inputSchema: { type: "object", properties: {} }, execute: async () => ({ content: [{ type: "text", text: "[]" }] }), }); await settle(); expect(heard).toEqual(["notifications/tools/list_changed"]); expect(((await client.listTools()) as { name: string }[]).map((tool) => tool.name).sort()).toEqual(["get_cart", "ping"]); remove(); await settle(); expect(heard).toEqual(["notifications/tools/list_changed", "notifications/tools/list_changed"]); } finally { await client.close(); await server.dispose(); } }); test("closing one client leaves the server, and the other clients, as they were", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [Ping] }); const first = await server.connect(); const second = await server.connect(); const heard: string[] = []; second.onNotification((notification: { method: string }) => { heard.push(notification.method); }); try { await first.close(); await server.registerTool({ name: "get_cart", description: "The items in the shopping cart", inputSchema: { type: "object", properties: {} }, execute: async () => ({ content: [{ type: "text", text: "[]" }] }), }); await settle(); expect(heard).toEqual(["notifications/tools/list_changed"]); } finally { await second.close(); await server.dispose(); } }); ``` [`registerTool()`](https://frontmcp.dev/reference/sdk/register-tool) covers what a tool added this way can do. A client from `server.connect()` can close at any time: only `server.dispose()` ends the server. (Changed in 1.9.3: before, closing such a client disposed part of the server, so `registerTool()` then failed with `the server has been disposed`, and the other clients stopped getting notifications.) --- ## Troubleshooting ### `ToolCallError` An adapter's `callTool()` rejects when the tool fails, because its format has no place for `isError`. The error's `message` is the tool's error text, and `result` is the MCP result, `_meta.code` included. Catch it and pass the failure to the model, as in [Keeping failures visible to Claude](https://frontmcp.dev/reference/sdk/connect#keeping-failures-visible-to-claude), or call the tool through plain `connect()`, which returns the failure as a result with `isError: true`. ### OpenAI refuses a tool's schema `connectOpenAI()` marks every tool `strict: true` but leaves optional arguments out of `required`, and OpenAI's strict mode requires every property there. Either turn strict mode off for those tools, or make every argument required (`.nullable()` instead of `.optional()` for the ones that may be empty): ```ts const tools = ((await client.listTools()) as { function: { strict: boolean } }[]).map((tool) => ({ ...tool, function: { ...tool.function, strict: false }, })); ``` ### A tool that asks the user returns "This tool requires user input to continue" The client didn't declare elicitation when it connected, so FrontMCP took its fallback for clients without forms: the tool's result asks the model to collect the answer and call the `sendElicitationResult` tool. A handler registered with `client.onElicitation()` after connecting isn't asked. Pass `onElicitation` to `connect()` instead, as in [Answering the tools' questions](https://frontmcp.dev/reference/sdk/connect#answering-the-tools-questions), or declare `capabilities: { elicitation: { form: {} } }` there. To answer the fallback from your code, pass the `elicitId` from the result's `_meta.elicitationPending` to `submitElicitationResult()`, which returns the tool's result: ```ts close-ticket.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a ticket once the user confirms", inputSchema: { id: z.string() } }) export class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() })); return { id, status: answer.status === "accept" && answer.content?.confirm ? "closed" : "open" }; } } ``` ```ts elicit.test.ts import { test, expect } from "@frontmcp/testing"; import { App, connect } from "@frontmcp/sdk"; import { CloseTicket } from "./close-ticket.tool"; @App({ id: "desk", name: "Desk", tools: [CloseTicket] }) class Desk {} const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [Desk], elicitation: { enabled: true } }; const yes = () => ({ action: "accept" as const, content: { confirm: true } }); test("🚩 a handler added after connecting, without the capability, isn't asked", async () => { const client = await connect(config); client.onElicitation(yes); try { expect(await client.callTool("close_ticket", { id: "T-1" })).toMatchObject({ content: [{ type: "text", text: expect.stringContaining("This tool requires user input to continue") }], _meta: { elicitationPending: { elicitId: expect.any(String), message: "Close ticket T-1?" } }, }); } finally { await client.close(); } }); test("✅ declaring the capability at connect time lets it answer", async () => { const client = await connect(config, { capabilities: { elicitation: { form: {} } } }); client.onElicitation(yes); try { expect(await client.callTool("close_ticket", { id: "T-1" })).toMatchObject({ structuredContent: { status: "closed" } }); } finally { await client.close(); } }); test("submitElicitationResult() answers the fallback, and returns the tool's result", async () => { const client = await connect(config); try { const pending = (await client.callTool("close_ticket", { id: "T-2" })) as { _meta: { elicitationPending: { elicitId: string } } }; const { elicitId } = pending._meta.elicitationPending; const answered = await client.submitElicitationResult(elicitId, { action: "accept", content: { confirm: true } }); expect(answered).toMatchObject({ structuredContent: { id: "T-2", status: "closed" } }); } finally { await client.close(); } }); ``` `submitElicitationResult()` calls the `sendElicitationResult` tool for you, with the `elicitId`, the `action` and the `content`, and FrontMCP runs the tool again with the answer. Changed in 1.9.3: before, it sent an `elicitation/result` request, which failed with `McpError` `-32601` `Method not found`, and the `sendElicitationResult` tool had to be called by hand. ### `Elicitation is disabled in server configuration` The tool called `this.elicit()` on a server without `elicitation: { enabled: true }`, and the call failed with code `ELICITATION_DISABLED`. Turn it on in the configuration you pass to `connect()`. ### `this.notify()` sends nothing The client hasn't called `setLogLevel()`, or the message's level is below the one it set. See [Hearing a tool's log messages](https://frontmcp.dev/reference/sdk/connect#hearing-a-tools-log-messages). ### `No endpoint serves app "…" on its own` `connect()` was given an `app` that has no endpoint of its own, so it rejected with a `ScopeConfigurationError`. Only a server with `splitByApp: true` gives every app one, and otherwise only a `standalone` app has one. The rest of the message lists the apps that do, like `Apps with an endpoint of their own (splitByApp or standalone): "billing"`. Leave `app` out to reach the main endpoint. See [Connecting to one app](https://frontmcp.dev/reference/sdk/connect#connecting-to-one-app). ### `Not connected` The client was used after `close()`. Connect again. With the same configuration object, you get the same server if another of its clients is still open, and a new one otherwise. ### `Invalid input: expected array, received undefined` The error's path is `apps`. `connect()` needs the configuration you give `@FrontMcp`, with your tools in an `@App`. `connect({ info, tools })` doesn't work; for a server built from bare tools, use [`create()`](https://frontmcp.dev/reference/sdk/create). ### A provider's state is gone after connecting again The last client of that configuration object closed, which disposed the server, so `connect()` built a new one with new providers. Keep one client open while the state matters, or keep the state outside the server. See [one server per configuration object](https://frontmcp.dev/reference/sdk/connect#one-server-per-configuration-object). ### `Unexpected token 'T', "Tool "list"... is not valid JSON` `listJobs()` or `listWorkflows()` on a server without jobs or workflows. The tool they call doesn't exist, and the client tries to parse the error message as JSON. [Reading resources and prompts](https://frontmcp.dev/reference/sdk/connect#reading-resources-and-prompts) shows it. --- # Guard options > Every FrontMCP option that limits tool calls, a tool's rateLimit, concurrency and timeout and @FrontMcp's throttle, whose calls each limit counts, and the error each one produces. Source: https://frontmcp.dev/reference/sdk/guard [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 FrontMCP's guard limits tool calls: how often a tool runs (`rateLimit`), how many calls of it run at once (`concurrency`), and how long one call may take (`timeout`). A tool sets its own limits in `@Tool`. `@FrontMcp({ throttle })` sets defaults for every tool, adds limits for the server as a whole, and filters requests by IP address. A call over a limit never reaches `execute()`, and the caller gets an error that says which limit it hit. [Limiting and Timing Out Calls](https://frontmcp.dev/learn/limiting-calls) teaches the same options step by step. ```ts @Tool({ name, inputSchema, rateLimit, concurrency, timeout }) @FrontMcp({ info, apps, throttle: { enabled, defaultRateLimit, defaultConcurrency, defaultTimeout, global, globalConcurrency, ipFilter, storage, keyPrefix }, }) ``` --- ## Reference ### A tool's limits Set `rateLimit`, `concurrency` and `timeout` in a tool's options. They work without any `throttle` on the server. ```ts export-tickets.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "export_tickets", description: "Export every support ticket as CSV. At most 2 exports an hour per caller, one at a time.", inputSchema: {}, rateLimit: { maxRequests: 2, windowMs: 60 * 60_000, partitionBy: "userId" }, concurrency: { maxConcurrent: 1, queueTimeoutMs: 5_000 }, timeout: { executeMs: 30_000 }, }) export class ExportTickets extends ToolContext { async execute() { return { csv: await buildCsv({ signal: this.signal }) }; } } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/guard#usage) #### `rateLimit` How many calls of the tool a window allows. | Field | Type | Default | Description | | --- | --- | --- | --- | | `maxRequests` | `number` | required | Calls allowed per window. A positive integer. | | `windowMs` | `number` | `60_000` | The window's length, in milliseconds. | | `partitionBy` | `PartitionKey` | `"global"` | Whose calls share a count. See [`partitionBy`](https://frontmcp.dev/reference/sdk/guard#partitionby). | A call over the limit fails with `RATE_LIMIT_EXCEEDED` and `Rate limit exceeded. Retry after N seconds`. Windows are aligned to the clock (an hourly window runs from one full UTC hour to the next), and N is the time left in the current one. The count is an estimate over two windows: calls in the current window, plus the previous window's calls weighted by how much of the previous window is still within `windowMs` of now. So right after a busy window, fewer calls get through than `maxRequests`, and once "Retry after N seconds" has passed, one call can get through and the next be refused again. (That depends on the clock, so no example below shows it.) #### `concurrency` How many calls of the tool may run at the same moment. | Field | Type | Default | Description | | --- | --- | --- | --- | | `maxConcurrent` | `number` | required | Calls allowed to run at once. A positive integer. | | `queueTimeoutMs` | `number` | `0` | How long a call waits for a free slot, in milliseconds. `0` refuses it at once. | | `partitionBy` | `PartitionKey` | `"global"` | Whose calls share the slots. See [`partitionBy`](https://frontmcp.dev/reference/sdk/guard#partitionby). | A call that finds no free slot fails with `CONCURRENCY_LIMIT` and `Concurrency limit reached for "merge_customers" (max: 1)`. With a queue, it checks for a free slot every 100 ms at first, backing off to once a second, and fails with `QUEUE_TIMEOUT` and `Queue timeout for "merge_customers" after waiting 5000ms for a concurrency slot` if none frees up in time. A slot is taken before the input is validated and given back when `execute()` ends, whether it succeeded, failed or timed out. The slot's ticket has a 300-second TTL, renewed every third of that time until release. A running call keeps its slot beyond that TTL; when its process dies and stops renewing, the slot frees once the TTL expires. Release and renewal only touch a slot that still holds that ticket. Changed in 1.9.7: before, an expired ticket left a counter increment behind, so a lost release could block the tool until a manual reset. Concurrency now uses `:slot:` keys instead of `:count` and `:ticket:`. During a rolling upgrade in a shared store, instances on the old and new versions enforce separate limits, allowing up to twice `maxConcurrent` until all instances are upgraded. Old counter and ticket keys are no longer read and can be deleted. #### `timeout` | Field | Type | Default | Description | | --- | --- | --- | --- | | `executeMs` | `number` | required | How long `execute()` may run, in milliseconds. A positive integer. | When time runs out, the call fails with `EXECUTION_TIMEOUT` and `Execution of "lookup_customer" timed out after 500ms`, and `this.signal` is aborted with that same error. `execute()` itself keeps running unless it listens to the signal, and it keeps its `concurrency` slot until it ends. See [A timeout ends the call, not the work](https://frontmcp.dev/learn/limiting-calls#a-timeout-ends-the-call-not-the-work). #### `partitionBy` `partitionBy` decides which calls share a count (for `rateLimit`) or a set of slots (for `concurrency`). FrontMCP turns it into a key, and calls with the same key share: | `partitionBy` | Key | | --- | --- | | `"global"` | `"global"`: every call shares one count. | | `"userId"` | The caller's `userId`, or `sessionId` when there's none. | | `"session"` | `sessionId`. | | `"ip"` | The client IP address; without one, `"user:"` and the caller's `userId`, or else `"ip:unresolved"`. | | `(ctx) => string` | Whatever the function returns. | The key is built from a `PartitionKeyContext`: | Field | What it holds | | --- | --- | | `userId` | The signed-in caller's client id from [`authInfo`](https://frontmcp.dev/reference/sdk/context), like `"static:6ab9f1eb8f7d"` for a static token. Missing for anonymous callers. | | `sessionId` | For MCP 2026-07-28 clients, which have no session: `"user:"` and the `userId`, or `"anonymous"`. For older clients in a session, over Streamable HTTP or the old SSE transport, the session id. (Before 1.8.5, SSE clients counted as having none.) | | `clientIp` | The address the request came from. Behind a proxy, set `throttle.ipFilter.trustProxy: true` and `trustedProxyDepth` (default `1`), or the `FRONTMCP_TRUST_PROXY=true` and `FRONTMCP_TRUSTED_PROXY_DEPTH` environment variables, to read `X-Forwarded-For` instead. Under `createFetchHandler()`, it's the address the runtime reports, when it reports one (Cloudflare Workers, Bun, Deno). The Playground has none. | So under MCP 2026-07-28, `"userId"` and `"session"` both give each signed-in caller a count of their own, and **all anonymous callers share one**, the key `"anonymous"`. Nobody gets around a limit by not signing in, but one busy anonymous client can use up the limit for every other one. [See it run.](https://frontmcp.dev/reference/sdk/guard#choosing-whose-calls-count) ### `@FrontMcp({ throttle })` `throttle` configures the guard for the whole server. ```ts main.ts @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], throttle: { enabled: true, defaultRateLimit: { maxRequests: 60, windowMs: 60_000, partitionBy: "userId" }, defaultConcurrency: { maxConcurrent: 5 }, defaultTimeout: { executeMs: 30_000 }, global: { maxRequests: 1_000, windowMs: 60_000, partitionBy: "ip" }, ipFilter: { denyList: ["192.0.2.0/24"] }, }, }) export default class Server {} ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | required | `true` turns every option below on. `false` turns the guard off: no rate limits or concurrency limits anywhere, including each tool's own. | | `defaultRateLimit` | `RateLimitConfig` | none | A [`rateLimit`](https://frontmcp.dev/reference/sdk/guard#ratelimit) for every tool that doesn't set its own. Each tool counts separately. | | `defaultConcurrency` | `ConcurrencyConfig` | none | A [`concurrency`](https://frontmcp.dev/reference/sdk/guard#concurrency) for every tool that doesn't set its own. Each tool has its own slots. | | `defaultTimeout` | `TimeoutConfig` | none | A [`timeout`](https://frontmcp.dev/reference/sdk/guard#timeout) for every tool that doesn't set its own. | | `global` | `RateLimitConfig` | none | One rate limit for every request the server gets, of any kind. See [`global`](https://frontmcp.dev/reference/sdk/guard#global). | | `globalConcurrency` | `ConcurrencyConfig` | none | How many tool calls may run at once, across every tool. See [`globalConcurrency`](https://frontmcp.dev/reference/sdk/guard#globalconcurrency). | | `ipFilter` | `IpFilterConfig` | none | Allow or refuse requests by client IP. See [`ipFilter`](https://frontmcp.dev/reference/sdk/guard#ipfilter). | | `storage` | `StorageConfig` | memory | Where counts and slots are kept. See [`storage`](https://frontmcp.dev/reference/sdk/guard#storage-and-keyprefix). | | `keyPrefix` | `string` | `"mcp:guard:"` | Prefix for every key the guard stores. | A tool's own `rateLimit`, `concurrency` or `timeout` replaces the default: it doesn't add to it. #### When the guard runs | Server configuration | What's enforced | | --- | --- | | No `throttle` | Each tool's own `rateLimit`, `concurrency` and `timeout`. | | `throttle: { enabled: true, … }` | Each tool's own limits, and every `throttle` option. | | `throttle: { enabled: false, … }` | Only each tool's own `timeout`. Every other limit, and every `throttle` option, is ignored. | Only tool calls are limited. Resources, prompts and other requests don't count toward a tool's `rateLimit` or a default one; `global` is the one limit that counts them. An agent is a tool to its clients, `invoke_`, and `@Agent` takes the same three options, which limit it like any tool (since FrontMCP 1.8.3): see [Limiting who calls an agent, and how often](https://frontmcp.dev/reference/sdk/agent#limiting-who-calls-an-agent-and-how-often). The limits are checked after the caller's [authorization](https://frontmcp.dev/learn/authorizing-calls) and before the arguments are validated, so a call with invalid arguments counts toward `rateLimit` like any other. [See it run.](https://frontmcp.dev/reference/sdk/guard#setting-defaults-for-every-tool) #### `global` A rate limit for the whole server, `{ maxRequests, windowMs?, partitionBy? }`, like [`rateLimit`](https://frontmcp.dev/reference/sdk/guard#ratelimit). Every request counts once, whatever its method: `tools/list` and `resources/read` as much as `tools/call`. It's checked when the request arrives, before FrontMCP knows which tool it's for, so a request over it isn't a tool error: it gets HTTP `429`, a `Retry-After` header with the seconds to wait, and a JSON-RPC error: ```json { "jsonrpc": "2.0", "id": 7, "error": { "code": -32029, "message": "Rate limit exceeded. Retry after 36 seconds" } } ``` With `partitionBy` `"global"` or `"ip"`, it's checked before authentication; with `"userId"`, `"session"` or a function, right after it, so the caller is known. #### `globalConcurrency` A cap on tool calls running at once across every tool, `{ maxConcurrent, queueTimeoutMs?, partitionBy? }`. It's checked before the tool's own `concurrency`. A call over it fails with `CONCURRENCY_LIMIT` and `Concurrency limit reached for "global" (max: 2)`. A tool that calls another with [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool) doesn't take a second global slot for the inner call, so `maxConcurrent: 1` doesn't deadlock it. #### `ipFilter` | Field | Type | Default | Description | | --- | --- | --- | --- | | `allowList` | `string[]` | `[]` | IP addresses or CIDR ranges, IPv4 or IPv6, to allow. | | `denyList` | `string[]` | `[]` | IP addresses or CIDR ranges to refuse. | | `defaultAction` | `"allow" \| "deny"` | `"allow"` | What happens to an address on neither list, and to a request whose address isn't known. | | `trustProxy` | `boolean` | `false` | `true` reads the client IP from `X-Forwarded-For`, counting back `trustedProxyDepth` hops. `false` leaves the decision to `FRONTMCP_TRUST_PROXY`. Read since 1.9.2. | | `trustedProxyDepth` | `number` | `1` | How many proxies append to `X-Forwarded-For`. Read when `trustProxy` is `true`; otherwise `FRONTMCP_TRUSTED_PROXY_DEPTH` applies. | Each request is checked before anything else: first the deny list, then the allow list, then `defaultAction`. An `allowList` on its own therefore refuses nothing: add `defaultAction: "deny"` to refuse everyone else. A refused request gets HTTP `403` and JSON-RPC error `-32001`, `Forbidden: client IP rejected by ipFilter`. A request whose address isn't known gets `defaultAction`. With `"deny"`, that refuses every request from a runtime that doesn't report addresses, including the Playground. Behind a proxy that isn't trusted, every request has the proxy's address. [See it run.](https://frontmcp.dev/reference/sdk/guard#filtering-by-ip-address) #### `storage` and `keyPrefix` By default the guard keeps its counts and slots in memory, so each server process has its own, and FrontMCP logs a warning that memory storage isn't suitable for several instances. To share limits between instances, give it a store: ```ts throttle: { enabled: true, storage: { type: "redis", redis: { url: process.env.REDIS_URL } }, defaultRateLimit: { maxRequests: 60, windowMs: 60_000, partitionBy: "userId" }, }, ``` `storage` is `@frontmcp/utils`' `StorageConfig`: `type` is `"memory"`, `"redis"`, `"upstash"`, `"vercel-kv"`, `"cloudflare-kv"` or `"auto"`, with that backend's options under its own key (`redis: { url }` or `redis: { config: { host, port, password, db, tls } }`, `upstash`, `vercelKv`, `cloudflareKv`). A `storage` without `type` is `"auto"`: Upstash if `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` are set, Vercel KV for `KV_REST_API_URL` and `KV_REST_API_TOKEN`, Redis for `REDIS_URL` or `REDIS_HOST`, and memory otherwise. If the store can't be reached, the server fails to start in production (`NODE_ENV=production`), with a `GuardStorageUnavailableError` that names `throttle.storage`, and falls back to memory, with a warning, otherwise. Rate limits fail closed on purpose: `fallback: "memory"` inside `storage` starts the server anyway, with counts of its own for each instance, and `fallback: "error"` refuses to start in every environment. [See both.](https://frontmcp.dev/reference/sdk/guard#the-server-doesnt-start-throttlestorage-redis-is-unavailable) The same choice applies if the store stops answering while the server runs: [see that too.](https://frontmcp.dev/reference/sdk/guard#calls-fail-with-guard_storage_unavailable-while-the-server-runs) The top-level `redis` option isn't used by the guard. The Playground can't reach Redis, so a shared store runs on a real server only. Concurrency needs atomic `deleteIfEquals` and `expireIfEquals` storage methods: memory, Redis, Upstash or Vercel KV support both. Cloudflare KV does not. With `globalConcurrency` or `defaultConcurrency`, an unsupported store stops startup with `GuardStorageUnsupportedError` (`GUARD_STORAGE_UNSUPPORTED`); a tool's own `concurrency` checks on its first call. Changed in 1.9.7: before, concurrency accepted stores without these atomic operations. See [the error message](https://frontmcp.dev/reference/sdk/guard#concurrency-limits-need-storage-with-an-atomic-compare-and-delete-and-compare-and-expire). `keyPrefix` is put before every key the guard stores, like `mcp:guard:export_tickets:global:rl:…` for a rate count. Give servers that share a store different prefixes if their limits should be separate. Before 1.8.6 the default prefix left an empty segment, `mcp:guard::export_tickets:…`, so an instance on 1.8.6 or later doesn't read the counts an older one wrote, and limits briefly split during a rolling deploy. ### What a caller gets back A tool's limits, and every default, answer with an ordinary tool error: an `isError` result over HTTP `200`, with no `Retry-After` header. The code is in `_meta.code`, and the message is kept in production. `global` and `ipFilter` answer the whole request instead: | Limit | Answer | Code | Message | | --- | --- | --- | --- | | `rateLimit`, `defaultRateLimit` | Tool error | `RATE_LIMIT_EXCEEDED` | `Rate limit exceeded. Retry after 1200 seconds` | | `concurrency`, `defaultConcurrency` | Tool error | `CONCURRENCY_LIMIT` | `Concurrency limit reached for "merge_customers" (max: 1)` | | `globalConcurrency` | Tool error | `CONCURRENCY_LIMIT` | `Concurrency limit reached for "global" (max: 2)` | | Any of them, with `queueTimeoutMs` | Tool error | `QUEUE_TIMEOUT` | `Queue timeout for "merge_customers" after waiting 5000ms for a concurrency slot` | | `timeout`, `defaultTimeout` | Tool error | `EXECUTION_TIMEOUT` | `Execution of "lookup_customer" timed out after 500ms` | | `global` | HTTP `429`, `Retry-After` | JSON-RPC `-32029` | `Rate limit exceeded. Retry after 36 seconds` | | `ipFilter` | HTTP `403` | JSON-RPC `-32001` | `Forbidden: client IP rejected by ipFilter` | | A tool's limit, while `throttle.storage` is down | Tool error | `GUARD_STORAGE_UNAVAILABLE` | In production, `Service temporarily unavailable: the rate-limit store cannot be reached. Retry shortly.` See [while the server runs](https://frontmcp.dev/reference/sdk/guard#calls-fail-with-guard_storage_unavailable-while-the-server-runs). | | `global`, while `throttle.storage` is down | HTTP `503`, `Retry-After: 1` | JSON-RPC `-32603`, `data.code` `GUARD_STORAGE_UNAVAILABLE` | `Service temporarily unavailable: the rate-limit store cannot be reached` | The [error classes](https://frontmcp.dev/reference/sdk/error-classes#rate-limits-and-the-guard) page lists the classes behind these codes. ### Building blocks `@frontmcp/sdk` re-exports the guard's own library, `@frontmcp/guard`, for limits the options can't express, like a limit per ticket rather than per caller: | Export | What it does | | --- | --- | | `createGuardManager({ config, logger? })` | Returns a `Promise` for a `GuardConfig` (`enabled` is required but not read). Memory storage unless `config.storage` says otherwise. | | `GuardManager` | `checkRateLimit(name, config?, ctx?)` → `{ allowed, remaining, resetMs, retryAfterMs? }`, counting the call when it's allowed. `checkGlobalRateLimit(ctx?)`. `acquireSemaphore(name, config?, ctx?)` and `acquireGlobalSemaphore(ctx?)` → a ticket with `release()`, or `null` when no slot is free. `checkIpFilter(ip?)`, `isIpAllowListed(ip?)`, `destroy()`. Without a `config`, each falls back to the manager's defaults. | | `withTimeout(fn, ms, name)` | Runs `fn(signal)` and rejects with `ExecutionTimeoutError` after `ms`, aborting `signal`. | | `IpFilter` | `new IpFilter(ipFilterConfig).check(ip)` → `{ allowed, reason, matchedRule? }`, where `reason` is `"denylisted"`, `"allowlisted"` or `"default"`. | | `SlidingWindowRateLimiter` | The rate counter the manager uses, over a storage adapter. | | `DistributedSemaphore(storage, ticketTtlSeconds?)` | A slot pool over a storage adapter; the ticket TTL defaults to `300` seconds and is renewed while held. `acquire(key, maxConcurrent, queueTimeoutMs, entityName)` returns a ticket with `release()`, or `null` without a queue when full. `verifyAtomicSlots()` checks the adapter once and rejects unsupported atomic operations; `acquire()` calls it before claiming a slot. `getActiveCount(key)` counts that key's held slots; `forceReset(key)` frees only that key's slots. | | `resolvePartitionKey(partitionBy, ctx)`, `buildStorageKey(name, key, suffix?)` | How keys are made, as in the [`partitionBy`](https://frontmcp.dev/reference/sdk/guard#partitionby) table. | | `guardConfigSchema`, `rateLimitConfigSchema`, `concurrencyConfigSchema`, `timeoutConfigSchema`, `ipFilterConfigSchema`, `partitionKeySchema` | The Zod schemas FrontMCP validates these options with. `ThrottleConfig` is the type of `throttle`. | | `GuardError` and its subclasses | `ExecutionTimeoutError`, `ConcurrencyLimitError`, `QueueTimeoutError`, `IpBlockedError`, `IpNotAllowedError`, `GuardStorageUnavailableError`, `GuardStorageUnsupportedError`, each with `code` and `statusCode`. | #### `StorageAdapter.expireIfEquals(key, expected, ttlSeconds)` ```ts expireIfEquals(key: string, expected: string, ttlSeconds: number): Promise ``` | Parameter | Type | Default | Description | | --- | --- | --- | --- | | `key` | `string` | required | The key to renew; namespaced storage adds its prefix. | | `expected` | `string` | required | The value the key must still hold. | | `ttlSeconds` | `number` | required | The new time to live, in seconds. | For a custom `@frontmcp/utils` storage adapter, `expireIfEquals()` atomically renews a matching key's TTL, returning `false` for a missing, expired or different value. Like `deleteIfEquals`, it is optional on `StorageAdapter`, but concurrency requires both. `BaseStorageAdapter` throws `StorageNotSupportedError` for an unsupported operation; namespaced storage forwards the prefixed key. Memory implements the comparison in process; Redis, Upstash and Vercel KV use one Lua script call. New in 1.9.7. To refuse a call from your own limit, fail with `RateLimitError` (a public error, `RATE_LIMIT_EXCEEDED`), or pass a guard error to [`this.fail()`](https://frontmcp.dev/reference/sdk/fail). Don't `throw` a guard error from `execute()`: only `ExecutionTimeoutError` keeps its code that way; the others arrive as `TOOL_EXECUTION_ERROR`, hidden in production. [See it run.](https://frontmcp.dev/reference/sdk/guard#limiting-by-an-argument) #### Caveats - **`enabled` is required** in a `throttle` object, even to set only defaults, and the defaults, `global`, `globalConcurrency` and `ipFilter` only work with `enabled: true`. - **`enabled: false` turns off each tool's own limits too**, all but `timeout`. Keep it for local testing. - **Counts live in memory by default**, one set per process. Several instances, or serverless functions, need [`storage`](https://frontmcp.dev/reference/sdk/guard#storage-and-keyprefix). - An in-process server from [`create()`](https://frontmcp.dev/reference/sdk/create#keeping-the-servers-options) takes `throttle` as `@FrontMcp` does. - **Anonymous callers share one count** under `"userId"` and `"session"` (see [`partitionBy`](https://frontmcp.dev/reference/sdk/guard#partitionby)). - **A timeout doesn't stop `execute()`**. Pass `this.signal` on to the work, and don't give a tool that changes things a timeout shorter than its work. - **Say the limit in the tool's description**, so the model can plan around it instead of learning it from an error. - **In a browser, calls may not overlap**: without the TC39 `AsyncContext`, FrontMCP's browser build serves [one request at a time](https://frontmcp.dev/reference/sdk/create-fetch-handler#in-a-browser), so `concurrency` and `globalConcurrency` have nothing to limit there. The Playground provides a minimal `AsyncContext`, so its calls overlap as they do on Node. --- ## Usage ### Limiting one tool ### A tool's limits ### Example: Rate limit At most two exports an hour. The third call is refused, with the time until the window ends. Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts export-tickets.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "export_tickets", description: "Export every support ticket as CSV. At most 2 exports an hour.", inputSchema: {}, annotations: { readOnlyHint: true }, rateLimit: { maxRequests: 2, windowMs: 60 * 60_000 }, }) export class ExportTickets extends ToolContext { async execute() { return { csv: "id,title,status\nT-1,Cannot log in,open\nT-2,Invoice total is wrong,closed" }; } } ``` ```ts export.test.ts import { test, expect } from "@frontmcp/testing"; test("the third export in an hour is refused", async ({ mcp }) => { expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful(); expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful(); const third = await mcp.tools.call("export_tickets", {}); expect(third).toBeError("RATE_LIMIT_EXCEEDED"); expect(third.text()).toMatch(/^Rate limit exceeded\. Retry after \d+ seconds$/); }); ``` ### Example: Concurrency One merge at a time, and a merge that finds another running waits up to a second for it. Three merges sent together: the first runs, one of the other two gets the slot when it frees up, and the last runs out of time. Initial call configuration: ```js { tool: "merge_customers", arguments: { from: "C-7", into: "C-2" } } ``` ```ts merge-customers.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "merge_customers", description: "Merge one customer record into another. One merge runs at a time; another waits up to a second for its turn.", inputSchema: { from: z.string(), into: z.string() }, annotations: { destructiveHint: true }, concurrency: { maxConcurrent: 1, queueTimeoutMs: 1_000 }, }) export class MergeCustomers extends ToolContext { async execute({ from, into }: { from: string; into: string }) { await new Promise((resolve) => setTimeout(resolve, 600)); // moving tickets takes a while return { merged: from, into }; } } ``` ```ts merge.test.ts import { test, expect } from "@frontmcp/testing"; test("two of three merges run, one after the other; the third runs out of time", async ({ mcp }) => { const results = await Promise.all( ["C-7", "C-8", "C-9"].map((from) => mcp.tools.call("merge_customers", { from, into: "C-2" })), ); const codes = results.map((r) => r.raw._meta?.code ?? "ok"); expect(codes.filter((c) => c === "ok")).toHaveLength(2); expect(codes.filter((c) => c === "QUEUE_TIMEOUT")).toHaveLength(1); const timedOut = results.find((r) => r.raw._meta?.code === "QUEUE_TIMEOUT")!; expect(timedOut.text()).toBe('Queue timeout for "merge_customers" after waiting 1000ms for a concurrency slot'); }); ``` ### Example: Timeout The CRM sometimes takes two seconds. The tool gives up after half a second, and passes `this.signal` on, so the request stops too. Initial call configuration: ```js { tool: "lookup_customer", arguments: { id: "C-9" } } ``` ```ts lookup-customer.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { crmLookup } from "./crm"; @Tool({ name: "lookup_customer", description: "Look up a customer in the CRM. Gives up after half a second if the CRM is slow.", inputSchema: { id: z.string() }, annotations: { readOnlyHint: true, openWorldHint: true }, timeout: { executeMs: 500 }, }) export class LookupCustomer extends ToolContext { async execute({ id }: { id: string }) { return crmLookup(id, this.signal); } } ``` ```ts crm.ts export const cancelled: string[] = []; // Stands in for a CRM client that takes an AbortSignal, like fetch() does. export function crmLookup(id: string, signal?: AbortSignal) { return new Promise<{ id: string; name: string }>((resolve, reject) => { const timer = setTimeout(() => resolve({ id, name: "Globex" }), id === "C-9" ? 2_000 : 50); signal?.addEventListener("abort", () => { clearTimeout(timer); cancelled.push(`${id}: ${signal.reason.message}`); reject(signal.reason); }); }); } ``` ```ts lookup.test.ts import { test, expect } from "@frontmcp/testing"; import { cancelled } from "./crm"; test("a slow lookup times out, and its request is cancelled", async ({ mcp }) => { const result = await mcp.tools.call("lookup_customer", { id: "C-9" }); expect(result).toBeError("EXECUTION_TIMEOUT"); expect(result.text()).toBe('Execution of "lookup_customer" timed out after 500ms'); expect(cancelled).toEqual(['C-9: Execution of "lookup_customer" timed out after 500ms']); }); test("a fast one answers", async ({ mcp }) => { expect(await mcp.tools.call("lookup_customer", { id: "C-1" })).toBeSuccessful(); }); ``` ### Setting defaults for every tool With `enabled: true`, `throttle` gives every tool the limits it doesn't set itself. Each tool counts on its own: `search_tickets` and `get_ticket` get five calls an hour each, not five between them. `export_tickets` sets its own `rateLimit`, which replaces the default. `sync_crm` runs into the default timeout, and `weekly_report` into the default concurrency: Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { ExportTickets, GetTicket, SearchTickets, SyncCrm, WeeklyReport } from "./tickets.tools"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, GetTicket, ExportTickets, SyncCrm, WeeklyReport] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], throttle: { enabled: true, defaultRateLimit: { maxRequests: 5, windowMs: 60 * 60_000 }, defaultConcurrency: { maxConcurrent: 1 }, defaultTimeout: { executeMs: 300 }, }, }) export default class Server {} ``` ```ts tickets.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, tickets: [{ id: "T-1", title: "Cannot log in" }] }; } } @Tool({ name: "get_ticket", description: "Get one ticket", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in" }; } } @Tool({ name: "export_tickets", description: "Export every ticket as CSV. Once an hour.", inputSchema: {}, rateLimit: { maxRequests: 1, windowMs: 60 * 60_000 }, // replaces defaultRateLimit }) export class ExportTickets extends ToolContext { async execute() { return { csv: "id,title\nT-1,Cannot log in" }; } } @Tool({ name: "sync_crm", description: "Copy tickets to the CRM", inputSchema: {} }) export class SyncCrm extends ToolContext { async execute() { await wait(1_000); return { synced: true }; } } @Tool({ name: "weekly_report", description: "Ticket counts for the last week", inputSchema: {} }) export class WeeklyReport extends ToolContext { async execute() { await wait(200); return { opened: 41, closed: 38 }; } } ``` ```ts defaults.test.ts import { test, expect } from "@frontmcp/testing"; test("each tool has its own count of five", async ({ mcp }) => { for (let i = 0; i < 5; i++) expect(await mcp.tools.call("search_tickets", { query: "log" })).toBeSuccessful(); expect(await mcp.tools.call("search_tickets", { query: "log" })).toBeError("RATE_LIMIT_EXCEEDED"); expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeSuccessful(); }); test("a tool's own rateLimit replaces the default", async ({ mcp }) => { expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful(); expect(await mcp.tools.call("export_tickets", {})).toBeError("RATE_LIMIT_EXCEEDED"); }); test("defaultTimeout applies to tools without a timeout", async ({ mcp }) => { const result = await mcp.tools.call("sync_crm", {}); expect(result).toBeError("EXECUTION_TIMEOUT"); expect(result.text()).toBe('Execution of "sync_crm" timed out after 300ms'); }); test("calls with invalid arguments count too", async ({ mcp }) => { // get_ticket has been called once, above. for (let i = 0; i < 4; i++) expect(await mcp.tools.call("get_ticket", { id: 5 })).toBeError("INVALID_INPUT"); expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeError("RATE_LIMIT_EXCEEDED"); }); test("other requests don't count toward a tool's limit", async ({ mcp }) => { for (let i = 0; i < 10; i++) await mcp.tools.list(); expect(await mcp.tools.call("weekly_report", {})).toBeSuccessful(); }); test("defaultConcurrency lets one call of each tool run at a time", async ({ mcp }) => { const [first, second] = await Promise.all([mcp.tools.call("weekly_report", {}), mcp.tools.call("weekly_report", {})]); expect(first).toBeSuccessful(); expect(second).toBeError("CONCURRENCY_LIMIT"); expect(second.text()).toBe('Concurrency limit reached for "weekly_report" (max: 1)'); }); ``` ### Limiting the whole server ### Server-wide limits ### Example: Requests `global` counts every request, whatever it is. Here the server takes ten an hour, a tiny limit to make the point: the Playground used six when it started (five discovery and list requests, and the call), so press **Call** until it's refused. In the first test, nine lists and a call get through, and the eleventh request is refused before FrontMCP looks at what it is. The second test sends raw HTTP, to see the status and the `Retry-After` header, which `mcp` doesn't show. Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { SearchTickets } from "./search-tickets.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] }) export class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], throttle: { enabled: true, global: { maxRequests: 10, windowMs: 60 * 60_000 } }, }) export default class Server {} ``` ```ts search-tickets.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, tickets: [] }; } } ``` ```ts send.ts // Sends one MCP 2026-07-28 request to a fetch handler, as a client would. export async function send( handler: (request: Request) => Promise, method: string, params: Record = {}, headers: Record = {}, ) { const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(typeof params.name === "string" ? { "mcp-name": params.name } : {}), ...headers, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return { status: response.status, retryAfter: response.headers.get("retry-after"), body: await response.json() }; } ``` ```ts global.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDeskApp } from "./main"; import { send } from "./send"; test("the eleventh request of any kind is refused", async ({ mcp }) => { for (let i = 0; i < 5; i++) await mcp.tools.list(); for (let i = 0; i < 4; i++) await mcp.resources.list(); expect(await mcp.tools.call("search_tickets", { query: "log" })).toBeSuccessful(); const eleventh = await mcp.tools.call("search_tickets", { query: "log" }); expect(eleventh.error).toMatchObject({ code: -32029, message: expect.stringMatching(/^Rate limit exceeded\. Retry after \d+ seconds$/) }); }); test("over HTTP, it's a 429 with Retry-After", async () => { const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], throttle: { enabled: true, global: { maxRequests: 1, windowMs: 60_000 } }, }); expect((await send(handler, "tools/list")).status).toBe(200); const refused = await send(handler, "tools/list"); expect(refused.status).toBe(429); expect(refused.retryAfter).toMatch(/^\d+$/); expect(refused.body.error).toEqual({ code: -32029, message: `Rate limit exceeded. Retry after ${refused.retryAfter} seconds` }); }); ``` ### Example: Concurrency `globalConcurrency` caps tool calls running at once, whichever tools they are. Two reports run; a third call, to a different tool, is refused. `report_digest` calls both reports with `this.callTool()`, and those inner calls don't take slots of their own. Initial call configuration: ```js { tool: "weekly_report", arguments: {} } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { MonthlyReport, ReportDigest, SearchTickets, WeeklyReport } from "./reports.tools"; @App({ id: "help-desk", name: "Help Desk", tools: [WeeklyReport, MonthlyReport, ReportDigest, SearchTickets] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], throttle: { enabled: true, globalConcurrency: { maxConcurrent: 2 } }, }) export default class Server {} ``` ```ts reports.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const slowQuery = () => new Promise((resolve) => setTimeout(resolve, 300)); @Tool({ name: "weekly_report", description: "Ticket counts for the last week", inputSchema: {} }) export class WeeklyReport extends ToolContext { async execute() { await slowQuery(); return { opened: 41, closed: 38 }; } } @Tool({ name: "monthly_report", description: "Ticket counts for the last month", inputSchema: {} }) export class MonthlyReport extends ToolContext { async execute() { await slowQuery(); return { opened: 170, closed: 162 }; } } @Tool({ name: "report_digest", description: "Weekly and monthly ticket counts together", inputSchema: {} }) export class ReportDigest extends ToolContext { async execute() { const weekly = await this.callTool("weekly_report", {}); const monthly = await this.callTool("monthly_report", {}); return { weekly: weekly.structuredContent, monthly: monthly.structuredContent }; } } @Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, tickets: [] }; } } ``` ```ts concurrency.test.ts import { test, expect } from "@frontmcp/testing"; test("a third call at once is refused, whichever tool it's for", async ({ mcp }) => { const [weekly, monthly, search] = await Promise.all([ mcp.tools.call("weekly_report", {}), mcp.tools.call("monthly_report", {}), mcp.tools.call("search_tickets", { query: "log" }), ]); expect(weekly).toBeSuccessful(); expect(monthly).toBeSuccessful(); expect(search).toBeError("CONCURRENCY_LIMIT"); expect(search.text()).toBe('Concurrency limit reached for "global" (max: 2)'); }); test("a tool's this.callTool() calls don't take global slots", async ({ mcp }) => { const [digest, weekly] = await Promise.all([mcp.tools.call("report_digest", {}), mcp.tools.call("weekly_report", {})]); expect(weekly).toBeSuccessful(); expect(digest.json()).toEqual({ weekly: { opened: 41, closed: 38 }, monthly: { opened: 170, closed: 162 } }); }); ``` ### Choosing whose calls count With `partitionBy: "userId"`, each signed-in caller gets a count of their own, and every anonymous caller shares one. The Playground's client is anonymous, so the first test shows the shared count. The second builds the same server with [static API keys](https://frontmcp.dev/learn/authenticating-clients) and calls it as two different callers. The `partitionBy` function in `get_ticket` records the context it's given, to show what a function can key on: Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts tickets.tools.ts active import { Tool, ToolContext, z, type PartitionKeyContext } from "@frontmcp/sdk"; @Tool({ name: "export_tickets", description: "Export every ticket as CSV. Once an hour per signed-in caller; once an hour for all anonymous callers together.", inputSchema: {}, rateLimit: { maxRequests: 1, windowMs: 60 * 60_000, partitionBy: "userId" }, }) export class ExportTickets extends ToolContext { async execute() { return { csv: "id,title\nT-1,Cannot log in" }; } } export const seen: PartitionKeyContext[] = []; @Tool({ name: "get_ticket", description: "Get one ticket", inputSchema: { id: z.string() }, rateLimit: { maxRequests: 100, partitionBy: (ctx) => { seen.push(ctx); return ctx.userId ?? ctx.clientIp ?? "anonymous"; }, }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in" }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { ExportTickets, GetTicket } from "./tickets.tools"; @App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets, GetTicket] }) export class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts send.ts // Sends one MCP 2026-07-28 request to a fetch handler, as a client would. export async function send( handler: (request: Request) => Promise, method: string, params: Record = {}, headers: Record = {}, ) { const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(typeof params.name === "string" ? { "mcp-name": params.name } : {}), ...headers, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return { status: response.status, retryAfter: response.headers.get("retry-after"), body: await response.json() }; } ``` ```ts partition.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDeskApp } from "./main"; import { seen } from "./tickets.tools"; import { send } from "./send"; test("anonymous callers share one count", async ({ mcp }) => { expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful(); expect(await mcp.tools.call("export_tickets", {})).toBeError("RATE_LIMIT_EXCEEDED"); }); test("an anonymous caller's context has only a sessionId", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-1" }); expect(seen[seen.length - 1]).toEqual({ sessionId: "anonymous" }); }); test("each signed-in caller has a count of their own", async () => { const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth: { mode: "static", tokens: ["key-ana", "key-ben"] }, }); const exportAs = async (key: string) => (await send(handler, "tools/call", { name: "export_tickets", arguments: {} }, { authorization: `Bearer ${key}` })).body.result; expect((await exportAs("key-ana")).isError).toBeUndefined(); expect((await exportAs("key-ana"))._meta.code).toBe("RATE_LIMIT_EXCEEDED"); expect((await exportAs("key-ben")).isError).toBeUndefined(); await send(handler, "tools/call", { name: "get_ticket", arguments: { id: "T-1" } }, { authorization: "Bearer key-ana" }); const ctx = seen[seen.length - 1]; expect(ctx.userId).toMatch(/^static:/); expect(ctx.sessionId).toBe(`user:${ctx.userId}`); }); ``` ### Filtering by IP address `ipFilter` checks each request's address before anything else. The Playground's requests have no address, so a deny list lets them through (they get `defaultAction`, `"allow"`), and the tests use `IpFilter`, the class behind the option, to show how addresses are matched. The last test builds the server with `defaultAction: "deny"`, which refuses every request whose address isn't known: Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { SearchTickets } from "./search-tickets.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] }) export class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], throttle: { enabled: true, ipFilter: { denyList: ["192.0.2.0/24", "2001:db8::/32"] }, }, }) export default class Server {} ``` ```ts search-tickets.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, tickets: [] }; } } ``` ```ts send.ts // Sends one MCP 2026-07-28 request to a fetch handler, as a client would. export async function send( handler: (request: Request) => Promise, method: string, params: Record = {}, headers: Record = {}, ) { const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(typeof params.name === "string" ? { "mcp-name": params.name } : {}), ...headers, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }), ); return { status: response.status, retryAfter: response.headers.get("retry-after"), body: await response.json() }; } ``` ```ts ip-filter.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance, IpFilter } from "@frontmcp/sdk"; import { HelpDeskApp } from "./main"; import { send } from "./send"; test("the deny list comes first, then the allow list, then defaultAction", () => { const filter = new IpFilter({ allowList: ["10.0.0.0/8"], denyList: ["10.0.9.0/24"], defaultAction: "deny" }); expect(filter.check("10.0.9.4")).toEqual({ allowed: false, reason: "denylisted", matchedRule: "10.0.9.0/24" }); expect(filter.check("10.2.0.1")).toEqual({ allowed: true, reason: "allowlisted", matchedRule: "10.0.0.0/8" }); expect(filter.check("203.0.113.5")).toEqual({ allowed: false, reason: "default" }); }); test("an allow list alone refuses nobody", () => { const filter = new IpFilter({ allowList: ["10.0.0.0/8"] }); expect(filter.check("203.0.113.5")).toEqual({ allowed: true, reason: "default" }); }); test("IPv6 ranges, and IPv4 addresses written as IPv6, match too", () => { const filter = new IpFilter({ denyList: ["2001:db8::/32", "192.0.2.0/24"] }); expect(filter.check("2001:db8::17").allowed).toBe(false); expect(filter.check("::ffff:192.0.2.9").allowed).toBe(false); }); test("with defaultAction: 'deny', a request with no known address is refused", async () => { const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], throttle: { enabled: true, ipFilter: { allowList: ["10.0.0.0/8"], defaultAction: "deny" } }, }); const refused = await send(handler, "tools/list"); expect(refused.status).toBe(403); expect(refused.body.error).toEqual({ code: -32001, message: "Forbidden: client IP rejected by ipFilter" }); }); ``` On a real server behind a load balancer, set `FRONTMCP_TRUST_PROXY=true` so FrontMCP reads the address from `X-Forwarded-For`; otherwise every request has the load balancer's address, or none. ### Limiting by an argument `partitionBy` sees the caller, not the arguments. For "each ticket can be escalated once an hour", keep a `GuardManager` of your own and key it by ticket. Fail with `RateLimitError`, so the model reads the same message and code as for any rate limit: Initial call configuration: ```js { tool: "escalate_ticket", arguments: { id: "T-1" } } ``` ```ts escalate-ticket.tool.ts active import { createGuardManager, RateLimitError, Tool, ToolContext, z } from "@frontmcp/sdk"; const limits = createGuardManager({ config: { enabled: true } }); @Tool({ name: "escalate_ticket", description: "Page the on-call engineer about a ticket. Each ticket can be escalated once an hour.", inputSchema: { id: z.string().describe("Ticket id, like T-1") }, }) export class EscalateTicket extends ToolContext { async execute({ id }: { id: string }) { const check = await (await limits).checkRateLimit(`escalate:${id}`, { maxRequests: 1, windowMs: 60 * 60_000 }); if (!check.allowed) this.fail(new RateLimitError(Math.ceil(check.retryAfterMs! / 1000))); return { id, escalated: true }; } } ``` ```ts escalate.test.ts import { test, expect } from "@frontmcp/testing"; test("each ticket can be escalated once an hour", async ({ mcp }) => { expect(await mcp.tools.call("escalate_ticket", { id: "T-1" })).toBeSuccessful(); const again = await mcp.tools.call("escalate_ticket", { id: "T-1" }); expect(again).toBeError("RATE_LIMIT_EXCEEDED"); expect(again.text()).toMatch(/^Rate limit exceeded\. Retry after \d+ seconds$/); expect(await mcp.tools.call("escalate_ticket", { id: "T-2" })).toBeSuccessful(); }); ``` The manager above keeps its counts in memory, per process. Give it the same `storage` as `throttle` to share them. ### Turning the guard off `throttle: { enabled: false }` switches off every rate limit and concurrency limit on the server, the tools' own included, and every `throttle` default. A tool's own `timeout` still applies: Initial call configuration: ```js { tool: "export_tickets", arguments: {} } ``` ```ts main.ts active import { App, FrontMcp } from "@frontmcp/sdk"; import { ExportTickets, LookupCustomer, SyncCrm } from "./tickets.tools"; @App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets, SyncCrm, LookupCustomer] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], throttle: { enabled: false, defaultTimeout: { executeMs: 100 } }, // 🚩 for local testing only }) export default class Server {} ``` ```ts tickets.tools.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const slowly = () => new Promise((resolve) => setTimeout(resolve, 300)); @Tool({ name: "export_tickets", description: "Export every ticket as CSV. Once an hour.", inputSchema: {}, rateLimit: { maxRequests: 1, windowMs: 60 * 60_000 }, }) export class ExportTickets extends ToolContext { async execute() { return { csv: "id,title\nT-1,Cannot log in" }; } } @Tool({ name: "sync_crm", description: "Copy tickets to the CRM", inputSchema: {} }) export class SyncCrm extends ToolContext { async execute() { await slowly(); return { synced: true }; } } @Tool({ name: "lookup_customer", description: "Look up a customer", inputSchema: { id: z.string() }, timeout: { executeMs: 100 } }) export class LookupCustomer extends ToolContext { async execute({ id }: { id: string }) { await slowly(); return { id, name: "Globex" }; } } ``` ```ts off.test.ts import { test, expect } from "@frontmcp/testing"; test("the tool's own rateLimit is ignored", async ({ mcp }) => { for (let i = 0; i < 3; i++) expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful(); }); test("so is defaultTimeout", async ({ mcp }) => { expect(await mcp.tools.call("sync_crm", {})).toBeSuccessful(); }); test("a tool's own timeout still applies", async ({ mcp }) => { expect(await mcp.tools.call("lookup_customer", { id: "C-9" })).toBeError("EXECUTION_TIMEOUT"); }); ``` --- ## Troubleshooting ### `Rate limit exceeded. Retry after N seconds`, and after N seconds it's refused again The count is an [estimate over two windows](https://frontmcp.dev/reference/sdk/guard#ratelimit): when the window ends, the calls from the one before still count, less and less as time goes on. The first call after the wait gets through; the next may not, until enough of the old window has passed. If callers retry exactly on time, use a longer window with a higher `maxRequests`, or tell the model in the description to space its calls out. ### `Invalid input: expected boolean, received undefined` at `throttle.enabled` The server's class declaration throws this when `throttle` has no `enabled`. It's required, even when you only set defaults: add `enabled: true`. TypeScript reports it too. ### `Too small: expected number to be >0` at `rateLimit.maxRequests` `maxRequests`, `maxConcurrent`, `windowMs` and `executeMs` must be positive integers; the tool fails when it's declared. To turn a tool off, remove it from its app, or use [`availableWhen`](https://frontmcp.dev/reference/sdk/tool). To lift its limit, remove `rateLimit`. FrontMCP checks these options with the schemas it exports, so you can check a config the same way: ```ts search-tickets.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, tickets: [] }; } } ``` ```ts config.test.ts import { test, expect } from "@frontmcp/testing"; import { guardConfigSchema, rateLimitConfigSchema } from "@frontmcp/sdk"; test("throttle needs enabled", () => { const result = guardConfigSchema.safeParse({ defaultRateLimit: { maxRequests: 60 } }); expect(result.error?.issues[0]).toMatchObject({ path: ["enabled"], message: "Invalid input: expected boolean, received undefined" }); }); test("maxRequests must be above zero", () => { const result = rateLimitConfigSchema.safeParse({ maxRequests: 0 }); expect(result.error?.issues[0]).toMatchObject({ path: ["maxRequests"], message: "Too small: expected number to be >0" }); }); test("defaults are filled in", () => { expect(rateLimitConfigSchema.parse({ maxRequests: 60 })).toEqual({ maxRequests: 60, windowMs: 60_000, partitionBy: "global" }); }); ``` ### The `throttle` defaults have no effect `defaultRateLimit`, `defaultConcurrency`, `defaultTimeout`, `global`, `globalConcurrency` and `ipFilter` need `enabled: true`. With `enabled: false`, they're all ignored, and so are the tools' own limits except `timeout`. Also check that the tool doesn't set its own limit: that replaces the default. ### Limits aren't shared between server instances Each process keeps its own counts unless `throttle.storage` points them at a shared store. Check its shape: it's `{ type: "redis", redis: { url } }` (or `redis: { config: { host, port } }`), not `{ provider: "redis", host, port }`. A `storage` without `type` auto-detects from environment variables (`REDIS_URL`, `REDIS_HOST`, `UPSTASH_REDIS_REST_URL`, `KV_REST_API_URL`) and quietly uses memory if none is set, so the second form looks like it works. The top-level `redis` option isn't used by the guard. See [`storage`](https://frontmcp.dev/reference/sdk/guard#storage-and-keyprefix). ### The server doesn't start: `throttle.storage (redis) is unavailable` `throttle.storage` points at a store the server can't reach. Rate limits are a security control, so they fail closed: in production, unless `fallback` says otherwise, the server refuses to start instead of running with limits that nobody shares. The error is a `GuardStorageUnavailableError`, with code `GUARD_STORAGE_UNAVAILABLE`, and its message ends with the way out: `Set throttle.storage.fallback: 'memory' to start with per-instance counters instead.` Fix the address or the credentials, or choose the fallback on purpose, knowing that each instance then counts alone. Before 1.8.6, the error was the storage client's own `Failed to connect to Redis`, with nothing about `throttle.storage`. The tests use a Redis address that nothing listens on: ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` ```ts storage.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance, GuardStorageUnavailableError } from "@frontmcp/sdk"; import { HelpDesk } from "./help-desk.app"; const info = { name: "help-desk", version: "1.0.0" }; const unreachableRedis = { type: "redis", redis: { config: { host: "127.0.0.1", port: 1 } } } as const; test('`fallback: "error"` stops the server, and the error names `throttle.storage`', async () => { const starting = FrontMcpInstance.createDirect({ info, apps: [HelpDesk], throttle: { enabled: true, storage: { ...unreachableRedis, fallback: "error" } }, }); await expect(starting).rejects.toBeInstanceOf(GuardStorageUnavailableError); await expect(starting).rejects.toMatchObject({ code: "GUARD_STORAGE_UNAVAILABLE", statusCode: 503, storageType: "redis" }); await expect(starting).rejects.toThrow(/^throttle\.storage \(redis\) is unavailable: /); await expect(starting).rejects.toThrow("Rate limits fail closed"); }); test('`fallback: "memory"` starts the server with counts of its own', async () => { const server = await FrontMcpInstance.createDirect({ info, apps: [HelpDesk], throttle: { enabled: true, storage: { ...unreachableRedis, fallback: "memory" } }, }); const result = await server.callTool("get_ticket", { id: "T-1" }); await server.dispose(); expect(result.isError).toBeFalsy(); }); ``` ### Calls fail with `GUARD_STORAGE_UNAVAILABLE` while the server runs The store answered at startup and then stopped, like a Redis that went down. A call that needs a limit check or a concurrency slot can't be counted, so with `fallback: "error"`, the default in production, it is refused: a `GuardStorageUnavailableError`, code `GUARD_STORAGE_UNAVAILABLE`, status `503`. In development, its message says what failed: `throttle.storage (redis) is unavailable: . Rate limits fail closed, so this call was refused. Set throttle.storage.fallback: 'memory' to keep serving with per-instance counters while it is down.` In production a client reads `Service temporarily unavailable: the rate-limit store cannot be reached. Retry shortly.`, which doesn't give away where the store is. With `throttle.global`, the whole request is refused instead, with HTTP `503` and `Retry-After: 1`. With `fallback: "memory"`, the default outside production, the guard switches to counters of its own for each instance, logs it once, tries the store again every 30 seconds, and logs when it answers. Calls made while the store is down are counted per instance, so the limit can be reached on every instance in turn. The tests pass their own Redis client, one that answers until a test takes it down: ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id. Two calls a minute.", inputSchema: { id: z.string() }, rateLimit: { maxRequests: 2, windowMs: 60_000 }, }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in", status: "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] }) export class HelpDesk {} ``` ```ts redis.example.ts // Stands in for a Redis client, which the Playground can't reach. It answers the few // commands the rate limiter sends, until `down` is set. A real server doesn't need this file. export class FakeRedis { down = false; private values = new Map(); private check() { if (this.down) throw new Error("connect ECONNREFUSED 127.0.0.1:6379"); } async ping() { this.check(); return "PONG"; } async mget(...keys: (string | string[])[]) { this.check(); return keys.flat().map((key) => this.values.get(key) ?? null); } async incr(key: string) { this.check(); const next = Number(this.values.get(key) ?? 0) + 1; this.values.set(key, String(next)); return next; } async expire() { this.check(); return 1; } async quit() { return "OK"; } on() { return this; } removeListener() { return this; } } ``` ```ts outage.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance, GuardStorageUnavailableError, createErrorHandler, toMcpError } from "@frontmcp/sdk"; import { HelpDesk } from "./help-desk.app"; import { FakeRedis } from "./redis.example"; const start = (redis: FakeRedis, fallback: "error" | "memory") => FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], throttle: { enabled: true, storage: { type: "redis", redis: { client: redis as never }, fallback } }, }); test('`fallback: "error"` refuses calls while the store is down, and serves again when it answers', async () => { const redis = new FakeRedis(); const server = await start(redis, "error"); try { expect((await server.callTool("get_ticket", { id: "T-1" })).structuredContent).toMatchObject({ id: "T-1" }); redis.down = true; const refused = await server.callTool("get_ticket", { id: "T-1" }).catch((error) => error); expect(refused).toBeInstanceOf(GuardStorageUnavailableError); expect(refused).toMatchObject({ code: "GUARD_STORAGE_UNAVAILABLE", statusCode: 503, storageType: "redis" }); expect(refused.message).toContain("Rate limits fail closed, so this call was refused."); expect(toMcpError(refused).isPublic).toBe(true); const production = createErrorHandler({ isDevelopment: false }).handle(refused); expect(production.content).toEqual([ { type: "text", text: "Service temporarily unavailable: the rate-limit store cannot be reached. Retry shortly." }, ]); redis.down = false; expect((await server.callTool("get_ticket", { id: "T-1" })).structuredContent).toMatchObject({ id: "T-1" }); } finally { await server.dispose(); } }); test("with `global`, the whole request gets 503 and Retry-After", async () => { const redis = new FakeRedis(); const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], throttle: { enabled: true, global: { maxRequests: 100, windowMs: 60_000 }, storage: { type: "redis", redis: { client: redis as never }, fallback: "error" } }, }); redis.down = true; const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }), }), ); expect(response.status).toBe(503); expect(response.headers.get("retry-after")).toBe("1"); expect(await response.json()).toMatchObject({ error: { code: -32603, message: "Service temporarily unavailable: the rate-limit store cannot be reached", data: { code: "GUARD_STORAGE_UNAVAILABLE" } }, }); }); test('`fallback: "memory"` keeps serving, with counts of its own', async () => { const redis = new FakeRedis(); const server = await start(redis, "memory"); try { redis.down = true; expect((await server.callTool("get_ticket", { id: "T-1" })).structuredContent).toMatchObject({ id: "T-1" }); expect((await server.callTool("get_ticket", { id: "T-1" })).structuredContent).toMatchObject({ id: "T-1" }); await expect(server.callTool("get_ticket", { id: "T-1" })).rejects.toMatchObject({ code: "RATE_LIMIT_EXCEEDED" }); } finally { await server.dispose(); } }); ``` New in 1.8.7: this runtime case. Starting without the store was already handled in 1.8.6. Changed in 1.9: before, production clients read the development message, with the store's error and address, and with `throttle.global` the request failed with a bare `500`. ### Every request gets `-32001 Forbidden: client IP rejected by ipFilter` `ipFilter` has `defaultAction: "deny"`, and the requests' address isn't on the allow list: the server is behind a proxy it doesn't trust, so every request has the proxy's address, or it runs under `createFetchHandler()` in a runtime that doesn't report addresses, so it has none. Set `throttle.ipFilter.trustProxy: true` and `trustedProxyDepth` for more than one proxy, or `FRONTMCP_TRUST_PROXY=true` and `FRONTMCP_TRUSTED_PROXY_DEPTH`, so it reads `X-Forwarded-For`. ### An `allowList` doesn't keep other addresses out An address on neither list gets `defaultAction`, which is `"allow"`. Add `defaultAction: "deny"`. See [the test above](https://frontmcp.dev/reference/sdk/guard#filtering-by-ip-address). ### `Tool "…" execution failed: Concurrency limit reached for …`, with `TOOL_EXECUTION_ERROR` `execute()` threw a guard error of its own, like a `ConcurrencyLimitError` for a slot from a `GuardManager`. Thrown, only `ExecutionTimeoutError` keeps its code; the others are wrapped as `TOOL_EXECUTION_ERROR` and hidden in production. Pass the error to `this.fail()`, which keeps its code and message, or fail with `RateLimitError`, as in [Limiting by an argument](https://frontmcp.dev/reference/sdk/guard#limiting-by-an-argument): Initial call configuration: ```js { tool: "start_export", arguments: {} } ``` ```ts start-export.tool.ts active import { ConcurrencyLimitError, createGuardManager, Tool, ToolContext } from "@frontmcp/sdk"; const limits = createGuardManager({ config: { enabled: true } }); const oneAtATime = { maxConcurrent: 1 }; @Tool({ name: "start_export", description: "Export tickets. One export at a time.", inputSchema: {} }) export class StartExport extends ToolContext { async execute() { const slot = await (await limits).acquireSemaphore("export", oneAtATime); if (!slot) throw new ConcurrencyLimitError("export", 1); // 🚩 thrown: TOOL_EXECUTION_ERROR await new Promise((resolve) => setTimeout(resolve, 200)); await slot.release(); return { exported: true }; } } @Tool({ name: "start_import", description: "Import tickets. One import at a time.", inputSchema: {} }) export class StartImport extends ToolContext { async execute() { const slot = await (await limits).acquireSemaphore("import", oneAtATime); if (!slot) this.fail(new ConcurrencyLimitError("import", 1)); // ✅ keeps CONCURRENCY_LIMIT await new Promise((resolve) => setTimeout(resolve, 200)); await slot.release(); return { imported: true }; } } ``` ```ts slots.test.ts import { test, expect } from "@frontmcp/testing"; test("a thrown guard error is wrapped", async ({ mcp }) => { const [, second] = await Promise.all([mcp.tools.call("start_export", {}), mcp.tools.call("start_export", {})]); expect(second).toBeError("TOOL_EXECUTION_ERROR"); expect(second.text()).toMatch(/^Tool "start_export" execution failed: Concurrency limit reached for "export" \(max: 1\)/); }); test("passed to this.fail(), it keeps its code", async ({ mcp }) => { const [, second] = await Promise.all([mcp.tools.call("start_import", {}), mcp.tools.call("start_import", {})]); expect(second).toBeError("CONCURRENCY_LIMIT"); expect(second.text()).toBe('Concurrency limit reached for "import" (max: 1)'); }); ``` ### One anonymous client used up everyone's limit Under `"userId"` and `"session"`, all anonymous callers share the key `"anonymous"`. Have callers sign in, so each gets a count of their own, or, on a real server, count anonymous callers by address: `partitionBy: (ctx) => ctx.userId ?? ctx.clientIp ?? "anonymous"`. ### `Concurrency limits need storage with an atomic compare-and-delete and compare-and-expire` `GuardStorageUnsupportedError`, code `GUARD_STORAGE_UNSUPPORTED`, status `500`: the store cannot release and renew concurrency slots atomically. Use memory, Redis, Upstash or Vercel KV, or implement both `deleteIfEquals` and `expireIfEquals` atomically in your adapter. `fallback: "memory"` is for an unavailable store and does not bypass this check. A default or global concurrency limit refuses startup; a tool's own limit fails its first call. The message ends with the unsupported operation and adapter name. --- # Error classes > The error classes of @frontmcp/sdk, grouped by area, with the code a client receives, whether its message survives production, and whether you throw it or FrontMCP does. Source: https://frontmcp.dev/reference/sdk/error-classes [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `@frontmcp/sdk` exports an error class for most things that can go wrong, from a model sending bad arguments to a provider that can't be built. This page groups them by area; a few other exported classes aren't listed. Each one has a string `code`, which clients receive, and is either **public**, so its message reaches the client in production, or **internal**, so production replaces it with an error ID. A handful are for you to fail with. Most are what FrontMCP throws itself, and knowing them tells you what a client saw and why. For how to fail a call, see [`this.fail()`](https://frontmcp.dev/reference/sdk/fail); for every code a client can receive, see [Error codes](https://frontmcp.dev/reference/errors). ```ts this.fail(new PublicMcpError(message, code?, statusCode?)) throw new InvalidInputError(message?, details?) ``` --- ## Reference ### `McpError` Every FrontMCP error extends the abstract `McpError`, itself an `Error`, except the guard's errors and `@frontmcp/auth`'s internal ones (see [below](https://frontmcp.dev/reference/sdk/error-classes#errors-that-arent-mcperrors)). | Member | Type | Description | | --- | --- | --- | | `code` | `string` | What kind of error it is, like `"INVALID_INPUT"`. Clients get it as a tool result's `_meta.code`, or as `data.code` in a JSON-RPC error. | | `isPublic` | `boolean` | Whether the message may reach clients in production. | | `statusCode` | `number` | An HTTP-style status. It picks the JSON-RPC code for [resources and prompts](https://frontmcp.dev/reference/sdk/error-classes#from-a-resource-or-a-prompt); a tool result is always HTTP `200`. | | `errorId` | `string` | `err_` and 16 hex digits, new for every error. Clients get it in `_meta.errorId` or `data.errorId`, and FrontMCP logs it with the error, so a user's report can be found in the log. | | `message` | `string` | The message as written, for your logs. | | `getPublicMessage()` | `string` | What a production client reads. | | `getInternalMessage()` | `string` | What a development client reads in a tool result. | | `toMcpError(isDevelopment?)` | `CallToolResult` | The `isError` tool result for this error. | #### `PublicMcpError(message, code?, statusCode?, wwwAuthenticate?)` The error to fail with when the model should read the message. `code` defaults to `"PUBLIC_ERROR"`, and `statusCode` to `400`. `wwwAuthenticate` is only used on FrontMCP's own HTTP endpoints. Subclass it for errors of your own: ```ts export class TicketArchivedError extends PublicMcpError { constructor(id: string) { super(`Ticket ${id} is archived. Ask a lead to restore it.`, "TICKET_ARCHIVED", 409); } } ``` #### `InternalMcpError(message, code?)` An error whose message must not leave the server. `code` defaults to `"INTERNAL_ERROR"`, and `statusCode` is `500`. Production clients get `Internal FrontMCP error. Please contact support with error ID: err_…`; the message goes to the log. ### What the client receives #### From a tool A tool that fails, with `this.fail()` or by throwing, answers with a normal result: `isError: true`, the text, and `_meta` with `errorId`, `code`, `timestamp`, and in development, `stack`. - **`_meta.code` is the error's `code`.** `this.fail()` passes every `McpError` through as it is. A thrown error keeps its code too if it's public. - **A thrown error that isn't public is wrapped** as `ToolExecutionError`: code `TOOL_EXECUTION_ERROR`, text `Tool "…" execution failed: ` and the message. Passed to `this.fail()`, the same `InternalMcpError` keeps its code, and a plain `Error` becomes `SERVER_ERROR`. [See it run.](https://frontmcp.dev/reference/sdk/error-classes#failing-vs-throwing) - **The text** is `getInternalMessage()` in development and `getPublicMessage()` in production. For most public errors they're the same; the few that differ are marked below. - **A few refusals are JSON-RPC errors instead**, all before `execute()` runs: a thrown `ToolCredentialsRequiredError` (`-32001`, with `data.authUrl`); for clients before MCP 2026-07-28, `TaskAugmentationNotSupportedError` and `TaskAugmentationRequiredError` (`-32601`); and under 2026-07-28, a call to a tool with `execution.taskSupport: "required"` from a client that didn't declare the tasks extension (`-32602`, `Tool "…" runs as a task; declare the io.modelcontextprotocol/tasks extension in clientCapabilities`). #### From a resource or a prompt `resources/read` and `prompts/get` answer with a JSON-RPC error. Its `message` is `getPublicMessage()`, except that an internal error's message is kept in development, and `data` holds `errorId` and `code`. The JSON-RPC code comes from the class: | Error | JSON-RPC code | | --- | --- | | A class with its own JSON-RPC mapping (marked "own" below) | Its own code, and its own `data`, without `code` | | Public, `statusCode` 401 | `-32001` | | Public, `statusCode` 403 | `-32003` | | Public, `statusCode` below 500 | `-32602` | | Public, `statusCode` 500 or more | `-32603`, with the message kept | | Internal | `-32603` | A thrown error that isn't public is wrapped, as `ResourceReadError` (`Resource "…" read failed: `) or `PromptExecutionError` (`Prompt execution failed: `). [See it run.](https://frontmcp.dev/reference/sdk/error-classes#failing-in-a-resource-or-a-prompt) #### Before any handler A few refusals happen before FrontMCP knows what the request is for. They're HTTP errors with a JSON-RPC body, and no error class of yours is involved: `throttle.global` gives HTTP `429` and `-32029`, `throttle.ipFilter` HTTP `403` and `-32001` (see [Guard options](https://frontmcp.dev/reference/sdk/guard#what-a-caller-gets-back)), and a body over `http.bodyLimit` gives HTTP `413` and `-32600`, `Payload Too Large`, on the Node HTTP server and the fetch handler. Limits above 4 MiB apply on Workers and edge runtimes too; see [body size limits](https://frontmcp.dev/reference/deployment/security#body-size-limits). #### In production With `NODE_ENV=production`, a public error's message is sent, and an internal error's is replaced with `Internal FrontMCP error. Please contact support with error ID: err_…`; `InvalidOutputError` says `Output validation failed. Please contact support.` instead. No stack is sent. The Playground runs in development, so the examples below [format errors the way production does](https://frontmcp.dev/reference/sdk/error-classes#what-production-sends) with `createErrorHandler({ isDevelopment: false })`. #### Errors that aren't `McpError`s - **Any other `Error`**, like a `TypeError` or a driver's error, is internal. Passed to `this.fail()`, it becomes a `GenericServerError`, code `SERVER_ERROR`; in development the text adds `Original error:` and the stack. - **The guard's errors** (`ExecutionTimeoutError`, `ConcurrencyLimitError` and the rest) extend `GuardError`, not `McpError`. Passed to `this.fail()`, they keep their code and message, as public errors. Thrown, only `ExecutionTimeoutError` keeps its code in a tool; the others are wrapped. In a resource or prompt, all of them are wrapped when thrown, like any error that isn't public. - **`@frontmcp/auth`'s internal errors** (`JwtSecretRequiredError`, `SessionSecretRequiredError` and the rest) extend `AuthInternalError`. Their own code is lost: clients get `SERVER_ERROR`. - **`AuthorityDeniedError`**, from `@frontmcp/auth`, is how [`authorities`](https://frontmcp.dev/learn/authorizing-calls) refuses a call. Clients get code `AUTHORITY_DENIED`, and its message is kept in production. ### Helper functions | Function | What it does | | --- | --- | | `isPublicError(error)` | `true` for an `McpError` with `isPublic`. `false` for guard errors and `AuthorityDeniedError`, which FrontMCP still sends as public. | | `isClientFacingError(error)` | `isPublicError()`, or an `AuthorityDeniedError`. | | `toMcpError(error)` | The `McpError` FrontMCP would send for `error`: an `McpError` as it is, a guard error as a public `GuardLimitMcpError` with the same code, anything else as a `GenericServerError`. | | `formatMcpErrorResponse(error, isDevelopment?)` | The `isError` tool result for `error`. `isDevelopment` defaults to `NODE_ENV !== "production"`. | | `createErrorHandler({ isDevelopment?, logger?, errorTransformer? })` | An `ErrorHandler`: `handle(error, context?)` formats a tool result as `tools/call` does (and logs it with `logger`); `wrap(fn)` makes a function throw `McpError`s only. | | `extractPublicMessage(error)` | The most specific message in an error's `cause` chain. FrontMCP doesn't use it. | | `shouldStopExecution(error)` | Whether an error ends a flow: `true` except for a `FlowControl` that responds or continues. | | `MCP_ERROR_CODES` | The JSON-RPC codes: `UNAUTHORIZED` `-32001`, `RESOURCE_NOT_FOUND` `-32002`, `FORBIDDEN` `-32003`, `INVALID_REQUEST` `-32600`, `METHOD_NOT_FOUND` `-32601`, `INVALID_PARAMS` `-32602`, `INTERNAL_ERROR` `-32603`, `PARSE_ERROR` `-32700`. | ### The classes The tables give each class's `code`, the JSON-RPC code a resource or prompt gets for it (a tool gets the `code` in `_meta.code`, [as above](https://frontmcp.dev/reference/sdk/error-classes#from-a-tool)), whether its message is kept in production, and who throws it. Unless a table says otherwise, a class's constructor takes what its message needs, in the order the message uses it. #### Base classes | Class | `code` | JSON-RPC | Production | Who throws it | | --- | --- | --- | --- | --- | | `PublicMcpError(message, code?, statusCode?)` | `PUBLIC_ERROR`, or `code` | by `statusCode` (`-32602` by default) | Kept | **You**, for anything the model should read. FrontMCP, for skill and channel requests it can't serve. | | `InternalMcpError(message, code?)` | `INTERNAL_ERROR`, or `code` | `-32603` | Hidden | You, for failures to keep in the log. FrontMCP, for its own failures. | | `GenericServerError(message, originalError?)` | `SERVER_ERROR` | `-32603` | Hidden | FrontMCP, for any other `Error` passed to `this.fail()`. | #### Tools and prompts | Class | `code` | JSON-RPC | Production | Who throws it | | --- | --- | --- | --- | --- | | `ToolNotFoundError(name)` | `TOOL_NOT_FOUND` | `-32602` | Kept: `Tool "x" not found` | FrontMCP, for a name no tool has, or an `internal` tool called by a client. | | `ToolExecutionError(name, cause?)` | `TOOL_EXECUTION_ERROR` | `-32603` | Hidden | FrontMCP, around an error thrown from `execute()` that isn't public. | | `EntryUnavailableError(type, name, …)` | `ENTRY_UNAVAILABLE` | `-32003` (own) | Kept. It describes the server's environment: OS, runtime, deployment. | FrontMCP, for a tool, resource, resource template or prompt whose [`availableWhen`](https://frontmcp.dev/reference/server/environment) doesn't match where the server runs. One whose `surface` leaves out `"mcp"` gets the not-found error instead. | | `PromptNotFoundError(name)` | `PROMPT_NOT_FOUND` | `-32602` | Kept: `Prompt not found: x` | FrontMCP, for an unknown prompt. | | `PromptExecutionError(name, cause?)` | `PROMPT_EXECUTION_FAILED` | `-32603` | Hidden | FrontMCP, around an error thrown from a prompt that isn't public. | | `MissingPromptArgumentError(name)` | `MISSING_PROMPT_ARGUMENT` | `-32602` | Kept: `Missing required argument: id` | FrontMCP, for a required prompt argument that wasn't sent. | | `ToolCallError(toolName, result)` | `TOOL_CALL_ERROR` | | Kept | FrontMCP's in-process clients with an LLM format, like [`connectClaude()`](https://frontmcp.dev/reference/sdk/connect#the-adapters): their `callTool()` rejects with it when the tool fails. `message` is the tool's error text, and `result` the MCP result, `isError` and `_meta.code` included. It never reaches a client over MCP. New in 1.9.2. | #### Resources | Class | `code` | JSON-RPC | Production | Who throws it | | --- | --- | --- | --- | --- | | `ResourceNotFoundError(uri)` | `RESOURCE_NOT_FOUND` | `-32602` (own, `data: { uri }`). `-32002` for clients before MCP 2026-07-28. | Kept: `Resource not found: tickets://T-9` | FrontMCP, for a URI nothing serves. You, for a template whose item doesn't exist, so the client gets the same error as for an unknown URI. | | `ResourceReadError(uri, cause?)` | `RESOURCE_READ_ERROR` | `-32603` | Hidden | FrontMCP, around an error thrown from a resource that isn't public. | | `InvalidResourceUriError(uri, reason?)` | `INVALID_RESOURCE_URI` | `-32602` | Kept | You. FrontMCP never throws it. | #### Validation | Class | `code` | JSON-RPC | Production | Who throws it | | --- | --- | --- | --- | --- | | `InvalidInputError(message?, details?)` | `INVALID_INPUT` | `-32602` | Kept, with `details` appended as `Details:` and JSON | FrontMCP, for arguments that don't match `inputSchema` (`Invalid tool input`, then the Zod issues). **You**, for rules a schema can't express; see [`this.fail`](https://frontmcp.dev/reference/sdk/fail#choosing-an-error-code). | | `InvalidOutputError(details?)` | `INVALID_OUTPUT` | `-32603` | Hidden: `Output validation failed. Please contact support.` | FrontMCP, for a result that doesn't match `outputSchema`, or holds `NaN`, and since 1.8.7 for an [agent's](https://frontmcp.dev/reference/sdk/agent#returning-structured-output) answer that doesn't match. In development the text is `Tool output validation failed (output does not match outputSchema at status)`, naming the first field that didn't match. | | `InvalidMethodError(method, expected)` | `INVALID_METHOD` | `-32602` | Kept | FrontMCP, inside its own request handling. Clients don't normally see it. | #### Auth | Class | `code` | JSON-RPC | Production | Who throws it | | --- | --- | --- | --- | --- | | `UnauthorizedError(message?, wwwAuthenticate?)` | `UNAUTHORIZED` | `-32001` | Kept (default `Unauthorized`) | **You**, when the caller must sign in again. FrontMCP, for an older client's session that can't be restored from its token. | | `AuthorizationRequiredError({ appId, toolId, authUrl?, requiredScopes?, … })` | `AUTHORIZATION_REQUIRED` | `-32003` | Kept; production adds `To authorize, click: ` and `authUrl` | FrontMCP, when the caller hasn't authorized the tool's app yet. See [Progressive auth](https://frontmcp.dev/reference/auth/progressive). | | `ToolNotConsentedError(name)` | `TOOL_NOT_CONSENTED` | `-32003` (own) | Kept | FrontMCP, for a tool the user didn't select when consenting. See [Tools: consent](https://frontmcp.dev/reference/auth/progressive#tools-consent). | | `ToolCredentialsRequiredError({ toolId, providers, authUrl? })` | `TOOL_CREDENTIALS_REQUIRED` | `-32001` (own), also in `tools/call` | Kept | FrontMCP, for a tool whose required [`authProviders`](https://frontmcp.dev/reference/sdk/tool) credential isn't connected. See [Progressive auth](https://frontmcp.dev/reference/auth/progressive#asking-for-a-credential-when-a-tool-needs-it). | | `AuthConfigurationError(message, { errors?, suggestion? })` | `AUTH_CONFIGURATION_ERROR` | `-32603` | Kept; production adds `To fix this issue:` and `suggestion` | FrontMCP, at startup, for an `auth` or `authorities` configuration that can't work, including [an `authorities` rule that checks nothing](https://frontmcp.dev/reference/sdk/frontmcp#authorities-configuration-required-or-invalid-authorities-rule). | | `UnsupportedClientVersionError(version)` | `UNSUPPORTED_CLIENT_VERSION` | | Kept | FrontMCP, for an older client's `initialize` whose protocol version isn't a date. | | `SessionMissingError(message?)` | `SESSION_MISSING` | `-32001` | Kept | Nobody: FrontMCP never throws it. | | `PublicAccessDeniedError(kind, name)` | `PUBLIC_ACCESS_DENIED` | `-32003` (own) | Kept: `The tool "help-desk:close_ticket" is not available to anonymous callers` | FrontMCP, for an anonymous caller's call to a tool or prompt that the server's [`publicAccess`](https://frontmcp.dev/reference/auth/modes#publicaccess) doesn't list. New in 1.9.2. | | `SessionIdentityRequiredError(what?)` | `SESSION_IDENTITY_REQUIRED` | `-32003` | Kept: `The secure store's session scope needs a verified session or a signed-in caller. Authenticate the request, or use a session transport (an MCP revision before 2026-07-28).` | FrontMCP, when [`this.secureStore`](https://frontmcp.dev/reference/auth/local#thissecurestore) with `scope: "session"` is used by a caller with neither a verified session nor a sign-in, like an anonymous MCP 2026-07-28 caller. New in 1.8.5. | | `AuthorityDeniedError` (`@frontmcp/auth`) | `AUTHORITY_DENIED` | `-32003` (own, `data: { entryType, entryName, deniedBy, denial }`) | Kept: `Access denied to Tool "help-desk:close_ticket": …` | FrontMCP, when a caller fails an [`authorities`](https://frontmcp.dev/learn/authorizing-calls) rule. Not exported by `@frontmcp/sdk`. | `@frontmcp/sdk` also re-exports `@frontmcp/auth`'s internal errors, which FrontMCP throws while starting up or handling tokens. Clients get `SERVER_ERROR` for all of them, hidden in production; the server log has the real code. The ones you're most likely to meet: `JwtSecretRequiredError` (`JWT_SECRET_REQUIRED`: production with `auth.mode: "local"` and no `JWT_SECRET`), `JwtSecretWeakError` (`JWT_SECRET_INVALID`: a secret under 32 bytes), `SessionSecretRequiredError` (`SESSION_SECRET_REQUIRED`), `ElicitationSecretRequiredError`, `TokenNotAvailableError`, `TokenStoreRequiredError`, `TokenLeakDetectedError`, `EncryptionKeyNotConfiguredError`, `EncryptionContextNotSetError`, `VaultLoadError`, `VaultNotFoundError`, `SessionIdEmptyError`, `CredentialProviderAlreadyRegisteredError`, `OrchestratedAuthNotAvailableError`, `OrchestratorJwksNotAvailableError` and `NoProviderIdError`. `AuthProvidersNotConfiguredError`, `ScopeDeniedError` and `InMemoryStoreRequiredError` are never thrown. #### Rate limits and the guard | Class | `code` | JSON-RPC | Production | Who throws it | | --- | --- | --- | --- | --- | | `RateLimitError(retryAfter?)` | `RATE_LIMIT_EXCEEDED` | `-32602` | Kept: `Rate limit exceeded. Retry after 30 seconds` | FrontMCP, for a tool's [`rateLimit`](https://frontmcp.dev/reference/sdk/guard#ratelimit). **You**, for limits of your own. | | `QuotaExceededError(quotaType?)` | `QUOTA_EXCEEDED` | `-32602` | Kept: `export quota exceeded` (`usage` by default) | You. FrontMCP never throws it. | | `ExecutionTimeoutError(name, ms)` | `EXECUTION_TIMEOUT` | `-32602` through `this.fail()` | Kept | FrontMCP, for a tool's [`timeout`](https://frontmcp.dev/reference/sdk/guard#timeout). | | `ConcurrencyLimitError(name, max)` | `CONCURRENCY_LIMIT` | `-32602` through `this.fail()` | Kept | FrontMCP, for [`concurrency`](https://frontmcp.dev/reference/sdk/guard#concurrency) and `globalConcurrency`. | | `QueueTimeoutError(name, ms)` | `QUEUE_TIMEOUT` | `-32602` through `this.fail()` | Kept | FrontMCP, when a queued call waits longer than `queueTimeoutMs`. | | `IpBlockedError(ip)`, `IpNotAllowedError(ip)` | `IP_BLOCKED`, `IP_NOT_ALLOWED` | `-32003` through `this.fail()` | Kept | Nobody: `ipFilter` answers the request itself. | | `GuardStorageUnavailableError(storageType, cause?)` | `GUARD_STORAGE_UNAVAILABLE` | | Replaced, since 1.9: `Service temporarily unavailable: the rate-limit store cannot be reached. Retry shortly.`, with status `503` | FrontMCP, when `throttle.storage` can't be reached and its `fallback` isn't `"memory"`: while the server starts, where it stops the server (new in 1.8.6), and since 1.8.7 on a call whose limit check or concurrency slot the store can't serve, where the client gets it. See [Guard troubleshooting](https://frontmcp.dev/reference/sdk/guard#the-server-doesnt-start-throttlestorage-redis-is-unavailable) and [while the server runs](https://frontmcp.dev/reference/sdk/guard#calls-fail-with-guard_storage_unavailable-while-the-server-runs). | | `GuardStorageUnsupportedError(cause?)` | `GUARD_STORAGE_UNSUPPORTED` | `-32603` through `this.fail()` | Kept; status `500` | FrontMCP, when concurrency storage lacks atomic compare-and-delete or compare-and-expire. Global/default concurrency checks at startup; a tool's own concurrency checks on its first call. See [Guard troubleshooting](https://frontmcp.dev/reference/sdk/guard#concurrency-limits-need-storage-with-an-atomic-compare-and-delete-and-compare-and-expire). New in 1.9.7. | | `GuardError(message, code, statusCode)` | as given | | | The base of the guard errors above. | | `GuardLimitMcpError(guardError)` | the guard error's | by status | Kept | FrontMCP, when it turns a guard error into an `McpError`. | See [Guard options](https://frontmcp.dev/reference/sdk/guard#what-a-caller-gets-back) for when each limit trips. #### Agents | Class | `code` | Production | Who throws it | | --- | --- | --- | --- | | `AgentNotFoundError(id)` | `AGENT_NOT_FOUND` | Kept | FrontMCP, for an unknown agent, like a name [`this.invokeAgent()`](https://frontmcp.dev/reference/sdk/agent#agentcontext) doesn't know: `Agent not found: …`. | | `AgentVisibilityError` | `AGENT_VISIBILITY_DENIED` | Kept | FrontMCP, when `invokeAgent()` names an agent this one can't see, like one with `swarm.isVisible: false`. New in 1.9. | | `AgentCallDepthExceededError` | `AGENT_CALL_DEPTH_EXCEEDED` | Kept | FrontMCP, for a call from one agent to another past [`swarm.maxCallDepth`](https://frontmcp.dev/reference/sdk/agent#swarm). New in 1.9. | | `AgentConfigurationError` | `AGENT_CONFIGURATION_ERROR` | | FrontMCP, at startup, for an agent's `exports` that names something the agent doesn't have. New in 1.9. | | `AgentExecutionError(id, cause?)` | `AGENT_EXECUTION_FAILED` | Hidden | FrontMCP, around an agent's failures. | | `AgentNotConfiguredError(name)`, `AgentToolNotFoundError(agent, tool, available)` | `AGENT_NOT_CONFIGURED`, `AGENT_TOOL_NOT_FOUND` | Hidden | FrontMCP, for an agent without an LLM adapter, or asking for a tool it doesn't have. | | `AgentLoopExceededError`, `AgentTimeoutError`, `AgentLlmError` | `AGENT_LOOP_EXCEEDED`, `AGENT_TIMEOUT`, `AGENT_LLM_ERROR` | | Nobody: FrontMCP never throws them. | A client calling an agent gets `TOOL_EXECUTION_ERROR` when its loop runs out of turns or time, or its model fails; [`@Agent`](https://frontmcp.dev/reference/sdk/agent#errors) lists the texts. The exceptions are an answer that doesn't match the agent's `outputSchema`, which is `InvalidOutputError`, code `INVALID_OUTPUT`, as for a tool (in 1.8.5 and 1.8.6 it was `TOOL_EXECUTION_ERROR` too, with a stack trace in development), and the three agent-to-agent errors above, which keep their codes. #### Elicitation and multi-round-trip requests | Class | `code` | JSON-RPC | Production | Who throws it | | --- | --- | --- | --- | --- | | `ElicitationDisabledError()` | `ELICITATION_DISABLED` | `-32602` | Kept, shorter: `Elicitation is disabled on this server.` Development: `Elicitation is disabled in server configuration. Enable it via @FrontMcp({ elicitation: { enabled: true } })` | FrontMCP, for [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit) on a server without `elicitation.enabled`. | | `ElicitationNotSupportedError(message?)` | `ELICITATION_NOT_SUPPORTED` | `-32602` | Kept | FrontMCP, when an older client has no session or transport to ask through. | | `ElicitationNotOwnedError(elicitId)` | `ELICITATION_NOT_OWNED` | `-32003` (own, `data: { elicitId }`) | Kept: `Elicitation "elicit-…" was not requested by this caller` | FrontMCP, when `sendElicitationResult` answers a question that another caller was asked. See [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit#the-model-is-asked-to-call-sendelicitationresult). | | `ElicitationTimeoutError(id, ttl)` | `ELICITATION_TIMEOUT` | `-32602` | Kept, reworded: `… The user did not respond within the allowed time (300 seconds).` | FrontMCP, when an older client's user doesn't answer in time. | | `ElicitationFallbackRequired(…)` | `ELICITATION_FALLBACK` | | | FrontMCP, internally, for clients that can't show forms. The call answers with instructions instead of an error. | | `ElicitationStoreNotInitializedError`, `ElicitationEncryptionError`, `ElicitationSubscriptionError` | `ELICITATION_STORE_NOT_INITIALIZED`, `ELICITATION_ENCRYPTION_ERROR`, `ELICITATION_SUBSCRIPTION_ERROR` | `-32603` | Hidden | FrontMCP, for failures of its elicitation store. | | `SamplingNotAvailableError(feature?)` | `MRTR_REQUIRED` | `-32602` | Kept | FrontMCP, for sampling or `roots/list` from a client older than MCP 2026-07-28. | | `InputRequiredSignal`, `MissingClientCapabilityError` | `INPUT_REQUIRED`, `MISSING_REQUIRED_CLIENT_CAPABILITY` | | | FrontMCP, under MCP 2026-07-28: the first becomes an `input_required` result, the second HTTP `400` with `-32021`. `isMrtrSignal(error)` recognizes both. | #### Tasks | Class | `code` | JSON-RPC | Who throws it | | --- | --- | --- | --- | | `TaskConcurrencyLimitError(maxConcurrent)` | `CONCURRENCY_LIMIT` | Tool `isError` result | FrontMCP, when the caller reaches `tasks.maxConcurrentPerSession`. `maxConcurrent` is also available on the error. | | `TaskNotFoundError(taskId)` | `TASK_NOT_FOUND` | `-32602` (own) | FrontMCP, for an unknown task id: `Failed to retrieve task: Task not found`. | | `TaskAlreadyTerminalError(status)` | `TASK_ALREADY_TERMINAL` | `-32602` (own) | FrontMCP, when cancelling a task that has `completed`, `failed` or been `cancelled`. | | `TaskAugmentationNotSupportedError(tool)`, `TaskAugmentationRequiredError(tool)` | `TASK_AUGMENTATION_NOT_SUPPORTED`, `TASK_AUGMENTATION_REQUIRED` | `-32601` (own), also in `tools/call` | FrontMCP, for a client before MCP 2026-07-28 that asks for a task on a tool with `taskSupport: "forbidden"`, or doesn't on one with `"required"`. [Under 2026-07-28](https://frontmcp.dev/reference/sdk/error-classes#from-a-tool) the second is a `-32602`. | | `TaskStoreNotInitializedError()` | `TASK_STORE_NOT_INITIALIZED` | `-32603` | FrontMCP, for its own task store. Hidden in production. | All but the last are public. #### Jobs and workflows | Class | `code` | Production | Who throws it | | --- | --- | --- | --- | | `JobNotAuthorizedError(name)` | `JOB_NOT_AUTHORIZED` | Kept: `Job or workflow "x" not found or not permitted` | FrontMCP's job tools, for a job that doesn't exist or that the caller may not run. See [`@Job`](https://frontmcp.dev/reference/sdk/job#restricting-who-can-run-a-job). | | `DynamicJobRegistrationDisabledError(kind?)` | `DYNAMIC_REGISTRATION_DISABLED` | Kept | FrontMCP's `register_job` and `register_workflow`, without `jobs.allowDynamicRegistration`. | | `WorkflowTimeoutError`, `WorkflowJobTimeoutError`, `WorkflowStepNotFoundError`, `WorkflowDagValidationError` | `WORKFLOW_TIMEOUT`, `WORKFLOW_JOB_TIMEOUT`, `WORKFLOW_STEP_NOT_FOUND`, `WORKFLOW_DAG_VALIDATION` | Hidden | FrontMCP, while running a workflow. Clients get `TOOL_EXECUTION_ERROR` from `execute_workflow`; see [`@Workflow`](https://frontmcp.dev/reference/sdk/workflow). | | `DynamicJobDirectExecutionError(name?)` | `DYNAMIC_JOB_DIRECT_EXECUTION` | Hidden | FrontMCP, for a dynamic job run outside its sandbox. | #### Remote apps [Remote servers](https://frontmcp.dev/reference/server/remote) covers how a remote app fails, and what each failure looks like. For [`App.remote()`](https://frontmcp.dev/reference/sdk/app#apps-from-outside-your-code). The public ones tell the model which remote server failed. | Class | `code` | Production | Who throws it | | --- | --- | --- | --- | | `RemoteTimeoutError(app, operation, ms)` | `REMOTE_TIMEOUT_ERROR` | Kept | FrontMCP, when a remote tool call takes too long (30 seconds by default) on every try. | | `RemoteToolNotFoundError`, `RemoteResourceNotFoundError`, `RemotePromptNotFoundError` | `REMOTE_TOOL_NOT_FOUND`, `REMOTE_RESOURCE_NOT_FOUND`, `REMOTE_PROMPT_NOT_FOUND` | Kept | FrontMCP, when the remote server doesn't have it. | | `RemoteToolExecutionError`, `RemoteResourceReadError`, `RemotePromptGetError` | `REMOTE_TOOL_EXECUTION_ERROR`, `REMOTE_RESOURCE_READ_ERROR`, `REMOTE_PROMPT_GET_ERROR` | Kept, with the remote error's message | FrontMCP, when the remote call fails. | | `RemoteAuthError(app, details?)` | `REMOTE_AUTH_ERROR` | Kept: `Authentication failed for remote server "status": the remote server refused the credentials (HTTP 401)` | FrontMCP, when the remote answers a tool call, resource read or prompt request with `401`, or a [`remoteAuth: { mode: "mapped" }`](https://frontmcp.dev/reference/server/remote#credentials-for-each-caller) mapper throws. A remote that refuses the connection while the server starts is a `RemoteConnectionError` instead. (Changed in 1.9.3: before, it was effectively never thrown.) | | `RemoteNotConnectedError(app)` | `REMOTE_NOT_CONNECTED` | Kept | FrontMCP, before the remote app has connected. | | `RemoteConnectionError`, `RemoteCapabilityDiscoveryError` | `REMOTE_CONNECTION_ERROR`, `REMOTE_CAPABILITY_DISCOVERY_ERROR` | Hidden | FrontMCP, when connecting or listing the remote server's capabilities fails. | | `InvalidRetryOptionsError` | `INVALID_RETRY_OPTIONS` | Hidden | FrontMCP, for bad retry options. | | `RemoteDisconnectError`, `RemoteAuthorizationError`, `RemoteTransportError`, `RemoteUnsupportedTransportError`, `RemoteCapabilityNotSupportedError`, `RemoteConfigurationError` | | | Nobody: FrontMCP never throws them. | The remote errors also have an `mcpErrorCode` field. FrontMCP doesn't use it for `tools/call`, `resources/read` or `prompts/get`: the JSON-RPC code follows `statusCode` like any other public error, so a remote "not found" is `-32602`. #### Providers All internal (`-32603`, hidden in production), thrown by FrontMCP while building or looking up [providers](https://frontmcp.dev/reference/sdk/provider): `ProviderNotAvailableError` (`PROVIDER_NOT_AVAILABLE`: what [`this.get()`](https://frontmcp.dev/reference/sdk/get#provider-directory-is-not-available-not-found-in-local-or-parent-registries) throws for a provider it can't find), `ProviderNotRegisteredError`, `ProviderNotInstantiatedError`, `ProviderScopeMismatchError`, `ProviderScopedAccessError`, `ProviderConstructionError` (`Failed to construct provider "…"`), `ProviderDependencyError`, `DependencyCycleError` (`Circular dependency detected: A -> B -> A`), `InvalidDependencyScopeError`, `PluginDependencyError`, `InvalidPluginScopeError` and `DependencyNotFoundError`. #### Registries, decorators and normalization All internal, thrown by FrontMCP, most of them while the server starts: - **Registries**: `DuplicateAppIdError` (`DUPLICATE_APP_ID`, `Two apps share the id "status": …`, for two [`App.remote()`](https://frontmcp.dev/reference/server/remote#two-apps-share-the-id-status) or `App.esm()` apps with one id; new in 1.9.3), `RegistryDefinitionNotFoundError`, `RegistryGraphEntryNotFoundError`, `RegistryDependencyNotRegisteredError` (`Tool "X" depends on "Db", which is not registered`), `InvalidRegistryKindError`, `NameDisambiguationError`, `EntryValidationError`, `FlowNotRegisteredError`, `UnsupportedHookOwnerKindError`, `InvalidHookFlowError` and `RegistryNotInitializedError`. - **Decorators**: `InvalidDecoratorMetadataError` (`@FrontMcp invalid metadata for "apps": …`, see [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp#frontmcp-invalid-metadata-for-apps)) and `HookTargetNotDefinedError`. - **Normalization**, for an entry in a `providers`, `plugins`, `tools` or other list that isn't what FrontMCP expects: `MissingProvideError`, `InvalidUseClassError`, `InvalidUseFactoryError`, `InvalidUseValueError` and `InvalidEntityError` (`Invalid Tool 'X'. Expected a class or function.`). `FlowNotRegisteredError` uses `FLOW_NOT_REGISTERED` and says `Flow "…" is not registered`. Changed in 1.10.0: before, `create()`, `createDirect()` and `connect()` registered `http:*` flows and the default public auth's flows. They now omit `http:*` flows and, without explicit `auth`, OAuth, OAuth discovery and `session:verify` flows (`connect()`'s CLI mode keeps its existing initialization). Calling an omitted flow throws this error: see [what an in-process server initializes](https://frontmcp.dev/reference/sdk/create#what-the-server-initializes). #### Transport Internal, thrown by FrontMCP's transports: `MethodNotImplementedError`, `UnsupportedTransportTypeError`, `InvalidTransportSessionError`, `TransportNotConnectedError`, `TransportAlreadyStartedError`, `TransportServiceNotAvailableError` and `SessionClaimConflictError`. `TransportBusRequiredError` is never thrown. Two are public: `UnsupportedContentTypeError` (`UNSUPPORTED_CONTENT_TYPE`, for an older client's SSE message with the wrong content type) and `PayloadTooLargeError` (`PAYLOAD_TOO_LARGE`, HTTP `413`). #### ESM packages [Loading apps from npm](https://frontmcp.dev/reference/server/esm) covers how a package fails to load. For an [`App.esm()`](https://frontmcp.dev/reference/sdk/app#apps-from-outside-your-code) package, FrontMCP throws these while the server starts, so each one stops it: | Class | `code` | When | | --- | --- | --- | | `EsmVersionResolutionError` | `ESM_VERSION_RESOLUTION_ERROR` | No version matches the range, or the registry doesn't have the package: `Failed to resolve version for "@acme/nope@1.0.0": Package "@acme/nope" not found in registry at …`. | | `EsmRegistryAuthError` | `ESM_REGISTRY_AUTH_ERROR` | The registry answered `401` or `403`. | | `EsmPackageLoadError` | `ESM_PACKAGE_LOAD_ERROR` | The package's bundle couldn't be fetched or run: `Failed to load ESM package "@acme/status-tools"@1.0.0: …`. | | `EsmManifestInvalidError` | `ESM_MANIFEST_INVALID` | The package loaded, but doesn't export a FrontMCP package: `Invalid manifest in ESM package "…": ESM module does not export a valid FrontMcpPackageManifest. …` | | `EsmCacheError` | `ESM_CACHE_ERROR` | The bundle couldn't be cached. | | `EsmInvalidSpecifierError` | `ESM_INVALID_SPECIFIER` | Thrown by `App.esm()` itself, as soon as it is called, for a specifier that isn't a package name with an optional range. | Changed in 1.9.2: before, these were exported but never thrown, and a package that failed to load failed with a plain `Error`. Since 1.9.1, for a single tool, resource or prompt loaded with `Tool.esm()`, `Tool.remote()` and the like: `ExternalEntryLoadError`, when its package doesn't load or its server can't be reached, `ExternalEntryNotFoundError`, when the package or server has no entry by that name, and `ExternalEntryNotSupportedError`, for an agent, skill or job loaded that way, which FrontMCP refuses. All three stop the server from starting. #### Everything else Internal, thrown by FrontMCP: `FlowExitedWithoutOutputError` (`Flow exited without producing output`: a tool's `execute()` returned `undefined`), `RequestContextNotAvailableError` (`this.context` used outside a request), `ContextExtensionNotAvailableError`, `ServerNotFoundError`, `ConfigNotFoundError`, `RequiredConfigUndefinedError`, `SessionVerificationFailedError`, `ScopeConfigurationError`, `HaConfigurationError`, `InvokeStateMissingKeyError`, `FlowInputMissingError`, `DynamicAdapterNameError`, `ServerlessHandlerNotInitializedError`, `VercelKvNotSupportedError`, the skill errors `InvalidSkillError`, `SkillInstructionFetchError`, `InvalidInstructionSourceError`, `SkillSessionError` and `SkillContextNotImplementedError`, the agent errors `AgentConfigKeyNotFoundError`, `AgentToolExecutionError` and `AgentMethodNotAvailableError`, and `ToolNameConflictError`, for a tool added with [`registerTool()`](https://frontmcp.dev/reference/sdk/register-tool) under a name the server already has (new in 1.9). `SkillValidationError` is public: it stops the server from starting when a skill with `toolValidation: "strict"` names a tool the server doesn't have (`Skill '…' failed tool validation: missing tools […]`, since 1.9; see [`@Skill`](https://frontmcp.dev/reference/sdk/skill#skill--failed-tool-validation-missing-tools-)). `GlobalConfigNotFoundError` (`Plugin "cache" requires global "redis" configuration`) is public, and so is `UnenforcedMetadataError` (`UNENFORCED_METADATA`), which stops the server from starting when an entry declares a plugin's option, like `approval`, and no plugin that enforces it covers the entry: see [`@Plugin`](https://frontmcp.dev/reference/sdk/plugin#unenforced-metadata-tool--declares-). `VercelKvAsyncInitRequiredError` and `EnclaveExecutionError` are never thrown. `FlowControl` isn't an error to report: it's how `this.fail()` and `this.respond()` end a call, and its `message` is empty. See [A tool call succeeds after `this.fail()`](https://frontmcp.dev/reference/sdk/fail#a-tool-call-succeeds-after-thisfail). #### Caveats - **Fail with a public class for anything the model should read.** `PublicMcpError`, or one of `InvalidInputError`, `RateLimitError`, `QuotaExceededError`, `UnauthorizedError` and `ResourceNotFoundError`, or a subclass of your own. - **Throwing and failing differ only for errors that aren't public**: `TOOL_EXECUTION_ERROR` when thrown, the error's own code through `this.fail()`. Production hides both. - **In a resource or prompt, `statusCode` picks the JSON-RPC code.** A `PublicMcpError` with status `404` is `-32602`, like any other client error; `401` and `403` are the ones that change it. - **A development client reads more than a production one**: the stack, an internal error's message, and for a few classes, a longer developer message. Test what production sends with `createErrorHandler({ isDevelopment: false })`. - **Many exported classes are never thrown in 1.9.3**, as the tables say. Don't wait for them in a client. --- ## Usage ### Failing with a built-in class Each sample below is a class you might fail a tool with. `fail_with` fails with the one you name; the test checks what a client reads for each, in development. Try other samples in the Call tab. Initial call configuration: ```js { tool: "fail_with", arguments: { sample: "invalidInput" } } ``` ```ts samples.ts active import { ConcurrencyLimitError, ElicitationDisabledError, InternalMcpError, InvalidInputError, PublicMcpError, QuotaExceededError, RateLimitError, SessionSecretRequiredError, UnauthorizedError, } from "@frontmcp/sdk"; export const samples: Record = { public: new PublicMcpError("There's no ticket T-9."), publicWithCode: new PublicMcpError("Ticket T-2 is archived. Ask a lead to restore it.", "TICKET_ARCHIVED", 409), invalidInput: new InvalidInputError("`due` must be in the future.", { field: "due" }), rateLimit: new RateLimitError(3600), quota: new QuotaExceededError("export"), unauthorized: new UnauthorizedError("Sign in again to export tickets."), internal: new InternalMcpError("replica lag 12s on tickets-db-2", "DB_LAG"), plain: new Error("connect ECONNREFUSED 10.0.4.12:5432"), guard: new ConcurrencyLimitError("export_tickets", 1), elicitationOff: new ElicitationDisabledError(), authInternal: new SessionSecretRequiredError("sessions"), }; ``` ```ts fail-with.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { samples } from "./samples"; @Tool({ name: "fail_with", description: "Fail with one of the sample errors", inputSchema: { sample: z.string().describe("A key of `samples`, like invalidInput") }, }) export class FailWith extends ToolContext { async execute({ sample }: { sample: string }) { this.fail(samples[sample]); } } ``` ```ts development.test.ts import { test, expect } from "@frontmcp/testing"; const expected: Record = { public: ["PUBLIC_ERROR", "There's no ticket T-9."], publicWithCode: ["TICKET_ARCHIVED", "Ticket T-2 is archived. Ask a lead to restore it."], invalidInput: ["INVALID_INPUT", '`due` must be in the future.\nDetails: {\n "field": "due"\n}'], rateLimit: ["RATE_LIMIT_EXCEEDED", "Rate limit exceeded. Retry after 3600 seconds"], quota: ["QUOTA_EXCEEDED", "export quota exceeded"], unauthorized: ["UNAUTHORIZED", "Sign in again to export tickets."], internal: ["DB_LAG", "replica lag 12s on tickets-db-2"], plain: ["SERVER_ERROR", /^connect ECONNREFUSED 10\.0\.4\.12:5432\n\nOriginal error:\nError: connect ECONNREFUSED/], guard: ["CONCURRENCY_LIMIT", 'Concurrency limit reached for "export_tickets" (max: 1)'], elicitationOff: ["ELICITATION_DISABLED", "Elicitation is disabled in server configuration. Enable it via @FrontMcp({ elicitation: { enabled: true } })"], authInternal: ["SERVER_ERROR", /^Session secret is required for sessions/], }; for (const [sample, [code, text]] of Object.entries(expected)) { test(`${sample}: ${code}`, async ({ mcp }) => { const result = await mcp.tools.call("fail_with", { sample }); expect(result).toBeError(code); if (typeof text === "string") expect(result.text()).toBe(text); else expect(result.text()).toMatch(text); expect(result.raw._meta).toHaveProperty("stack"); }); } ``` ### What production sends The same samples, formatted by an `ErrorHandler` with `isDevelopment: false`, which is what `tools/call` uses when `NODE_ENV=production`. Public errors keep their message; internal ones, plain errors and `@frontmcp/auth`'s errors become an error ID; `ElicitationDisabledError` switches to its shorter message: Initial call configuration: ```js { tool: "fail_with", arguments: { sample: "internal" } } ``` ```ts production.test.ts active import { test, expect } from "@frontmcp/testing"; import { createErrorHandler, isPublicError, toMcpError } from "@frontmcp/sdk"; import { samples } from "./samples"; const production = createErrorHandler({ isDevelopment: false }); const hidden = /^Internal FrontMCP error\. Please contact support with error ID: err_[0-9a-f]{16}$/; const expected: Record = { public: ["PUBLIC_ERROR", "There's no ticket T-9."], invalidInput: ["INVALID_INPUT", '`due` must be in the future.\nDetails: {\n "field": "due"\n}'], rateLimit: ["RATE_LIMIT_EXCEEDED", "Rate limit exceeded. Retry after 3600 seconds"], guard: ["CONCURRENCY_LIMIT", 'Concurrency limit reached for "export_tickets" (max: 1)'], elicitationOff: ["ELICITATION_DISABLED", "Elicitation is disabled on this server."], internal: ["DB_LAG", hidden], plain: ["SERVER_ERROR", hidden], authInternal: ["SERVER_ERROR", hidden], }; for (const [sample, [code, text]] of Object.entries(expected)) { test(`${sample} in production`, () => { const result = production.handle(samples[sample]); expect(result._meta?.code).toBe(code); if (typeof text === "string") expect(result.content[0].text).toBe(text); else expect(result.content[0].text).toMatch(text); expect(result._meta).not.toHaveProperty("stack"); }); } test("the error ID is the one in the server's log", () => { const result = production.handle(samples.internal); expect(result._meta?.errorId).toBe((samples.internal as { errorId?: string }).errorId); }); test("guard errors aren't McpErrors, but are sent as public ones", () => { expect(isPublicError(samples.guard)).toBe(false); expect(toMcpError(samples.guard)).toMatchObject({ code: "CONCURRENCY_LIMIT", isPublic: true }); }); ``` ```ts samples.ts import { ConcurrencyLimitError, ElicitationDisabledError, InternalMcpError, InvalidInputError, PublicMcpError, QuotaExceededError, RateLimitError, SessionSecretRequiredError, UnauthorizedError, } from "@frontmcp/sdk"; export const samples: Record = { public: new PublicMcpError("There's no ticket T-9."), publicWithCode: new PublicMcpError("Ticket T-2 is archived. Ask a lead to restore it.", "TICKET_ARCHIVED", 409), invalidInput: new InvalidInputError("`due` must be in the future.", { field: "due" }), rateLimit: new RateLimitError(3600), quota: new QuotaExceededError("export"), unauthorized: new UnauthorizedError("Sign in again to export tickets."), internal: new InternalMcpError("replica lag 12s on tickets-db-2", "DB_LAG"), plain: new Error("connect ECONNREFUSED 10.0.4.12:5432"), guard: new ConcurrencyLimitError("export_tickets", 1), elicitationOff: new ElicitationDisabledError(), authInternal: new SessionSecretRequiredError("sessions"), }; ``` ```ts fail-with.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { samples } from "./samples"; @Tool({ name: "fail_with", description: "Fail with one of the sample errors", inputSchema: { sample: z.string().describe("A key of `samples`, like invalidInput") }, }) export class FailWith extends ToolContext { async execute({ sample }: { sample: string }) { this.fail(samples[sample]); } } ``` ### Failing vs throwing A public error reads the same thrown or failed. The others don't: thrown from a tool's `execute()`, an `InternalMcpError` or a guard error other than a timeout is wrapped as `TOOL_EXECUTION_ERROR`, and a `ToolCredentialsRequiredError` becomes a JSON-RPC error rather than a tool result. Thrown from a resource, every guard error is wrapped, the timeout too: Initial call configuration: ```js { tool: "throw_it", arguments: { sample: "internal" } } ``` ```ts tools.ts active import { ConcurrencyLimitError, ExecutionTimeoutError, GuardStorageUnsupportedError, InternalMcpError, PublicMcpError, ResourceContext, ResourceTemplate, Tool, ToolContext, ToolCredentialsRequiredError, z, } from "@frontmcp/sdk"; const make: Record Error> = { public: () => new PublicMcpError("There's no ticket T-9.", "TICKET_NOT_FOUND", 404), internal: () => new InternalMcpError("replica lag 12s", "DB_LAG"), concurrency: () => new ConcurrencyLimitError("export", 1), timeout: () => new ExecutionTimeoutError("export", 500), unsupported: () => new GuardStorageUnsupportedError(), credentials: () => new ToolCredentialsRequiredError({ toolId: "sync_crm", providers: ["crm"], authUrl: "https://desk.example.com/connect/crm" }), }; const input = { sample: z.enum(["public", "internal", "concurrency", "timeout", "unsupported", "credentials"]) }; type Input = { sample: "public" | "internal" | "concurrency" | "timeout" | "unsupported" | "credentials" }; @Tool({ name: "throw_it", description: "Throw a sample error", inputSchema: input }) export class ThrowIt extends ToolContext { async execute({ sample }: Input): Promise<{ ok: boolean }> { throw make[sample](); } } @Tool({ name: "fail_it", description: "Fail with a sample error", inputSchema: input }) export class FailIt extends ToolContext { async execute({ sample }: Input): Promise<{ ok: boolean }> { this.fail(make[sample]()); } } @ResourceTemplate({ name: "thrown", uriTemplate: "thrown://{sample}", mimeType: "text/plain" }) export class Thrown extends ResourceContext<{ sample: string }> { async execute(uri: string, { sample }: { sample: string }): Promise<{ contents: { uri: string; text: string }[] }> { throw make[sample](); } } ``` ```ts throw-vs-fail.test.ts import { test, expect } from "@frontmcp/testing"; const codes = async (mcp: any, sample: string) => { const thrown = await mcp.tools.call("throw_it", { sample }); const failed = await mcp.tools.call("fail_it", { sample }); const code = (r: any) => (r.error ? r.error.code : r.raw._meta?.code); return [code(thrown), code(failed)]; }; test("a public error: the same either way", async ({ mcp }) => { expect(await codes(mcp, "public")).toEqual(["TICKET_NOT_FOUND", "TICKET_NOT_FOUND"]); }); test("an internal error: wrapped when thrown", async ({ mcp }) => { expect(await codes(mcp, "internal")).toEqual(["TOOL_EXECUTION_ERROR", "DB_LAG"]); expect((await mcp.tools.call("throw_it", { sample: "internal" })).text()).toMatch(/^Tool "throw_it" execution failed: replica lag 12s/); }); test("guard errors: only a timeout keeps its code when thrown", async ({ mcp }) => { expect(await codes(mcp, "concurrency")).toEqual(["TOOL_EXECUTION_ERROR", "CONCURRENCY_LIMIT"]); expect(await codes(mcp, "timeout")).toEqual(["EXECUTION_TIMEOUT", "EXECUTION_TIMEOUT"]); expect(await codes(mcp, "unsupported")).toEqual(["TOOL_EXECUTION_ERROR", "GUARD_STORAGE_UNSUPPORTED"]); expect((await mcp.tools.call("fail_it", { sample: "unsupported" })).text()).toContain("Concurrency limits need storage with an atomic compare-and-delete and compare-and-expire"); }); test("in a resource, a thrown guard error is wrapped too, even a timeout", async ({ mcp }) => { expect((await mcp.resources.read("thrown://public")).error).toMatchObject({ code: -32602, data: { code: "TICKET_NOT_FOUND" } }); expect((await mcp.resources.read("thrown://timeout")).error).toMatchObject({ code: -32603, message: 'Resource "thrown://timeout" read failed: Execution of "export" timed out after 500ms', data: { code: "RESOURCE_READ_ERROR" }, }); }); test("missing credentials: a JSON-RPC error when thrown", async ({ mcp }) => { const thrown = await mcp.tools.call("throw_it", { sample: "credentials" }); expect(thrown.error).toMatchObject({ code: -32001, data: { tool: "sync_crm", providers: ["crm"], authUrl: "https://desk.example.com/connect/crm" } }); expect(await mcp.tools.call("fail_it", { sample: "credentials" })).toBeError("TOOL_CREDENTIALS_REQUIRED"); }); ``` ### Failing in a resource or a prompt In a resource or a prompt, the class's `statusCode` picks the JSON-RPC code. `TicketLockedError` is a public error of this server's own, with status `403`: Initial call configuration: ```js { resource: "tickets://T-3" } ``` ```ts ticket.resource.ts active import { InternalMcpError, PublicMcpError, ResourceContext, ResourceNotFoundError, ResourceTemplate, UnauthorizedError } from "@frontmcp/sdk"; export class TicketLockedError extends PublicMcpError { constructor(id: string) { super(`Ticket ${id} is under legal hold.`, "TICKET_LOCKED", 403); } } @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json" }) export class Ticket extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { if (id === "T-3") this.fail(new TicketLockedError(id)); if (id === "T-4") this.fail(new UnauthorizedError("Sign in to read archived tickets.")); if (id === "T-5") this.fail(new PublicMcpError("The ticket store is down for maintenance.", "STORE_DOWN", 503)); if (id === "T-6") this.fail(new InternalMcpError("replica lag 12s", "DB_LAG")); if (id !== "T-1") this.fail(new ResourceNotFoundError(uri)); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ id, title: "Cannot log in" }) }] }; } } ``` ```ts resource-errors.test.ts import { test, expect } from "@frontmcp/testing"; test("status 403 is -32003", async ({ mcp }) => { const result = await mcp.resources.read("tickets://T-3"); expect(result.error).toMatchObject({ code: -32003, message: "Ticket T-3 is under legal hold.", data: { code: "TICKET_LOCKED" } }); }); test("status 401 is -32001", async ({ mcp }) => { expect((await mcp.resources.read("tickets://T-4")).error).toMatchObject({ code: -32001, data: { code: "UNAUTHORIZED" } }); }); test("a public error with status 503 is -32603, message kept", async ({ mcp }) => { const result = await mcp.resources.read("tickets://T-5"); expect(result.error).toMatchObject({ code: -32603, message: "The ticket store is down for maintenance.", data: { code: "STORE_DOWN" } }); }); test("an internal error is -32603", async ({ mcp }) => { expect((await mcp.resources.read("tickets://T-6")).error).toMatchObject({ code: -32603, data: { code: "DB_LAG" } }); }); test("ResourceNotFoundError reads like an unknown URI", async ({ mcp }) => { const missing = await mcp.resources.read("tickets://T-9"); const unknown = await mcp.resources.read("invoices://1"); expect(missing.error).toMatchObject({ code: -32602, message: "Resource not found: tickets://T-9", data: { uri: "tickets://T-9" } }); expect(unknown.error).toMatchObject({ code: -32602, message: "Resource not found: invoices://1", data: { uri: "invoices://1" } }); }); ``` ### Errors FrontMCP raises for you Most errors a client sees come from FrontMCP, before or around your code. Here each test makes FrontMCP raise one: Initial call configuration: ```js { tool: "close_ticket", arguments: { id: "T-1" } } ``` ```ts main.ts active import { App, FrontMcp, Job, JobContext, Prompt, PromptContext, Resource, ResourceContext, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string().min(1) } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, tickets: [] }; } } @Tool({ name: "close_ticket", description: "Close a ticket", inputSchema: { id: z.string() }, authorities: "admin" }) class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, closed: true }; } } @Tool({ name: "run_on_deno", description: "Only on Deno", inputSchema: {}, availableWhen: { runtime: ["deno"] } }) class RunOnDeno extends ToolContext { async execute() { return { ok: true }; } } @Tool({ name: "export_archive", description: "Export the ticket archive", inputSchema: {}, execution: { taskSupport: "required" } }) class ExportArchive extends ToolContext { async execute() { return { rows: 18_000 }; } } @Prompt({ name: "summarize_ticket", description: "Summarize a ticket", arguments: [{ name: "id", required: true }] }) class SummarizeTicket extends PromptContext { async execute({ id }: Record) { return { messages: [{ role: "user" as const, content: { type: "text" as const, text: `Summarize ticket ${id}.` } }] }; } } @Resource({ name: "audit-log", uri: "desk://audit-log", mimeType: "text/plain", authorities: "admin" }) class AuditLog extends ResourceContext { async execute(uri: string) { return { contents: [{ uri, text: "T-1 closed by nour" }] }; } } @Resource({ name: "deploy-notes", uri: "desk://deploy-notes", mimeType: "text/plain", availableWhen: { runtime: ["deno"] } }) class DeployNotes extends ResourceContext { async execute(uri: string) { return { contents: [{ uri, text: "Deploy with deployctl." }] }; } } @Job({ name: "nightly_cleanup", description: "Close stale tickets", inputSchema: {}, outputSchema: {} }) class NightlyCleanup extends JobContext { async execute() { return {}; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, CloseTicket, RunOnDeno, ExportArchive], resources: [AuditLog, DeployNotes], prompts: [SummarizeTicket], jobs: [NightlyCleanup], }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], authorities: { profiles: { admin: { roles: { any: ["admin"] } } } }, }) export default class Server {} ``` ```ts frontmcp-errors.test.ts import { test, expect } from "@frontmcp/testing"; test("an unknown tool: TOOL_NOT_FOUND", async ({ mcp }) => { const result = await mcp.tools.call("delete_everything", {}); expect(result).toBeError("TOOL_NOT_FOUND"); expect(result.text()).toBe('Tool "delete_everything" not found'); }); test("arguments that don't match the schema: INVALID_INPUT", async ({ mcp }) => { const result = await mcp.tools.call("search_tickets", { query: "" }); expect(result).toBeError("INVALID_INPUT"); expect(result.text()).toMatch(/^Invalid tool input\nDetails: \[/); }); test("a caller an authorities rule refuses: AUTHORITY_DENIED", async ({ mcp }) => { const result = await mcp.tools.call("close_ticket", { id: "T-1" }); expect(result).toBeError("AUTHORITY_DENIED"); expect(result.text()).toBe(`Access denied to Tool "help-desk:close_ticket": profile:admin: roles.any: user has none of 'admin'`); }); test("a resource an authorities rule refuses: -32003", async ({ mcp }) => { const result = await mcp.resources.read("desk://audit-log"); expect(result.error).toMatchObject({ code: -32003, message: `Access denied to Resource "help-desk:audit-log": profile:admin: roles.any: user has none of 'admin'`, data: { entryType: "Resource", deniedBy: "profile:admin: roles.any: user has none of 'admin'" }, }); }); test("a tool whose availableWhen doesn't match: ENTRY_UNAVAILABLE", async ({ mcp }) => { const result = await mcp.tools.call("run_on_deno", {}); expect(result).toBeError("ENTRY_UNAVAILABLE"); expect(result.text()).toMatch(/^Tool "run_on_deno" is not available in the current environment \(requires: \{"runtime":\["deno"\]\}\) \(current: \{"os":/); }); test("a resource whose availableWhen doesn't match: -32003", async ({ mcp }) => { const result = await mcp.resources.read("desk://deploy-notes"); expect(result.error).toMatchObject({ code: -32003, data: { entryType: "Resource", missingAxes: ["runtime"] } }); expect(result.error?.message).toMatch(/^Resource "desk:\/\/deploy-notes" is not available in the current environment/); }); test("a job that doesn't exist: JOB_NOT_AUTHORIZED", async ({ mcp }) => { const result = await mcp.tools.call("execute_job", { name: "delete_everything", input: {} }); expect(result).toBeError("JOB_NOT_AUTHORIZED"); expect(result.text()).toBe('Job or workflow "delete_everything" not found or not permitted'); }); test("a task-only tool, called without the tasks extension: -32602", async ({ mcp }) => { const result = await mcp.tools.call("export_archive", {}); expect(result.error).toEqual({ code: -32602, message: 'Tool "export_archive" runs as a task; declare the io.modelcontextprotocol/tasks extension in clientCapabilities', }); }); test("an unknown prompt, and a missing argument: -32602", async ({ mcp }) => { expect((await mcp.prompts.get("escalate", {})).error).toMatchObject({ code: -32602, message: "Prompt not found: escalate", data: { code: "PROMPT_NOT_FOUND" } }); expect((await mcp.prompts.get("summarize_ticket", {})).error).toMatchObject({ code: -32602, message: "Missing required argument: id", data: { code: "MISSING_PROMPT_ARGUMENT" }, }); }); ``` `ENTRY_UNAVAILABLE`'s message lists the server's OS, runtime and deployment, in production too, for tools, resources and prompts alike. If that's more than callers should know, leave the entry out of the server instead of relying on `availableWhen`. --- ## Troubleshooting ### `Internal FrontMCP error. Please contact support with error ID: err_…` The server runs with `NODE_ENV=production`, and the call failed with an internal error: an `InternalMcpError` or one of FrontMCP's, a plain `Error`, or one of `@frontmcp/auth`'s. Search the server's log for the error ID: FrontMCP logs the original message and stack with it. If the model should read the message, fail with a [public class](https://frontmcp.dev/reference/sdk/error-classes#failing-with-a-built-in-class). See [`this.fail`](https://frontmcp.dev/reference/sdk/fail#internal-frontmcp-error-please-contact-support-with-error-id-err_). ### My error's code arrives as `TOOL_EXECUTION_ERROR` It was thrown from `execute()` and isn't public: an `InternalMcpError`, a guard error, or an `Error` with a `code` property of its own. Throw a `PublicMcpError` subclass, or pass the error to `this.fail()`, which keeps an `McpError`'s code. See [Failing vs throwing](https://frontmcp.dev/reference/sdk/error-classes#failing-vs-throwing). ### My error's code arrives as `SERVER_ERROR` It isn't an `McpError`: a plain `Error`, or one of `@frontmcp/auth`'s internal errors, passed to `this.fail()`. Only `McpError`s and guard errors keep their code. Wrap it: `this.fail(new InternalMcpError(error.message, "CRM_SYNC_FAILED"))` keeps the message out of production, with a code clients can tell apart. ### A resource error arrives as `-32602` when I meant "not found" Under MCP 2026-07-28, every client error from a resource is `-32602`, including `ResourceNotFoundError`; `-32002` is only for older clients. Tell errors apart by `data.code`, which is your class's `code`. `ResourceNotFoundError` is the exception: its `data` is `{ uri }`, so check the message. ### The client sees a long developer message, like `Elicitation is disabled in server configuration. Enable it via …` In development, a tool's error text is the internal message, written for you. Production sends the public one: here, `Elicitation is disabled on this server.` Resources and prompts send the public one in both. See [What production sends](https://frontmcp.dev/reference/sdk/error-classes#what-production-sends). ### I'm waiting for `AgentLoopExceededError` (or `QuotaExceededError`, `SessionMissingError`, an ESM error…) and it never comes FrontMCP exports these classes but never throws them. The [tables](https://frontmcp.dev/reference/sdk/error-classes#the-classes) say which, and what's thrown instead, like `TOOL_EXECUTION_ERROR` for an agent that runs out of turns. --- # Error codes > What FrontMCP's JSON-RPC error codes and tool error results mean, and how to fix them. Source: https://frontmcp.dev/reference/errors [Covers FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) as of October 26, 2026 FrontMCP reports problems in two ways. **Protocol errors** are JSON-RPC `error` responses with a numeric code: the request itself couldn't be handled. **Tool errors** are normal results with `isError: true`: the request was fine, but the tool couldn't do what was asked. Models can read tool errors and try again. [Error classes](https://frontmcp.dev/reference/sdk/error-classes) lists the class behind each code, and which ones keep their message in production. ## Tool errors A tool error arrives as a result like this: ```json { "content": [{ "type": "text", "text": "There's no ticket T-9." }], "isError": true, "_meta": { "errorId": "err_4418a34f3368d9dc", "code": "PUBLIC_ERROR" } } ``` | `_meta.code` | Cause | Fix | | --- | --- | --- | | `INVALID_INPUT` | The arguments didn't match `inputSchema`. `execute()` didn't run. The text lists each problem. | Check the field paths in the message. If a model keeps getting it wrong, describe the field better. | | `PUBLIC_ERROR` | The tool failed with a `PublicMcpError`, through `this.fail()` or `throw`. A `PublicMcpError` with a code of its own sends that code instead. | Intended: the message is for the model. | | `SERVER_ERROR` | The tool called `this.fail(new Error(...))` with a plain `Error`. In production the text is "Internal FrontMCP error" and an error ID. | Use `PublicMcpError` if the message is meant for the model. | | `INTERNAL_ERROR` | The tool failed with an `InternalMcpError` through `this.fail()`. An `InternalMcpError` with a code of its own sends that code. In production the text is "Internal FrontMCP error" and an error ID. | Intended, if the message is only for your logs. | | `TOOL_EXECUTION_ERROR` | `execute()` threw an error that isn't public, like a plain `Error` or an `InternalMcpError`. In development the text is prefixed `Tool "…" execution failed:` and ends with the stack; in production it's "Internal FrontMCP error" and an error ID. | Find the error ID in the server's log, next to the original message and stack. Use `PublicMcpError` for errors the model should read. | | `INVALID_OUTPUT` | The result didn't match the tool's `outputSchema`, or held `NaN` or `Infinity`. An [agent's](https://frontmcp.dev/reference/sdk/agent#returning-structured-output) answer that doesn't match its `outputSchema` fails the same way (since 1.8.7; 1.8.5 and 1.8.6 reported `TOOL_EXECUTION_ERROR`). In development the text names the first field that didn't match; in production it's "Output validation failed. Please contact support." | Fix the result, or the schema if the result is right. See [Shaping Tool Results](https://frontmcp.dev/learn/shaping-tool-results#declaring-the-shape-with-outputschema). | | `FLOW_EXITED_WITHOUT_OUTPUT` | `execute()` returned `undefined`. | Return a value, even `{ ok: true }`. | | `INVALID_PATH_PARAMETER` | A tool of the [OpenAPI adapter](https://frontmcp.dev/reference/adapters/openapi) got a path parameter that is `.` or `..`, or one that would take the URL outside the operation's path. Nothing is sent. | Send a valid value. | | `TOOL_NOT_FOUND` | Skills-only mode rejects the external call, no tool has that name, the tool has `visibility: "internal"`, or [CodeCall](https://frontmcp.dev/reference/plugins/codecall) takes it out of `tools/list` (since 1.9). The text is `Tool "…" not found`. | Check the name in `tools/list`. | | `AUTHORITY_DENIED` | The caller doesn't pass the tool's [`authorities`](https://frontmcp.dev/reference/auth/authorities#what-a-refused-caller-gets) rule. `execute()` didn't run. | Sign in as a caller the rule allows, or change the rule. | | `ENTRY_UNAVAILABLE` | The tool's [`availableWhen`](https://frontmcp.dev/reference/server/environment) doesn't match the environment the server runs in. | Run the server where the tool is meant to be available, or change `availableWhen`. | | `RATE_LIMIT_EXCEEDED`, `CONCURRENCY_LIMIT`, `QUEUE_TIMEOUT`, `EXECUTION_TIMEOUT` | A tool's `rateLimit`, `concurrency` or `timeout`, a `throttle` default, or the task concurrency cap stopped the call. `TaskConcurrencyLimitError` uses `CONCURRENCY_LIMIT` in an `isError` result. The message says when to retry, or which limit. | Intended. See [Guard options](https://frontmcp.dev/reference/sdk/guard#what-a-caller-gets-back). | | `GUARD_STORAGE_UNSUPPORTED` | A concurrency limit's storage lacks atomic compare-and-delete or compare-and-expire. `GuardStorageUnsupportedError` stops startup for global/default concurrency, or fails a tool's first concurrency check. | Use memory, Redis, Upstash or Vercel KV, or implement both atomic methods. See [the message](https://frontmcp.dev/reference/sdk/guard#concurrency-limits-need-storage-with-an-atomic-compare-and-delete-and-compare-and-expire). | | `APPROVAL_REQUIRED`, `APPROVAL_OPERATION_FAILED`, `APPROVAL_SCOPE_NOT_ALLOWED`, `CHALLENGE_VALIDATION_FAILED` | The [Approval plugin](https://frontmcp.dev/reference/plugins/approval#errors) refused a call that needs the user's approval (`APPROVAL_REQUIRED`, whose text is the tool's `approvalMessage`), or a grant it can't make. They are public errors, so the client reads the message in production too. | Grant the approval, or change the grant the tool makes. | | `REMEMBER_SCOPE_NOT_ALLOWED` | A [memory tool](https://frontmcp.dev/reference/plugins/remember#rememberscopenotallowederror) was called with a scope outside the plugin's `tools.allowedScopes`, the default scope included. The message names the scope and the allowed ones. | Pass an allowed scope, or add it to `allowedScopes`. | | `FEATURE_FLAG_DISABLED` | The tool is [disabled by a feature flag](https://frontmcp.dev/reference/plugins/feature-flags#the-featureflag-option). The text names the tool and the flag. | Turn the flag on for the caller, or call a different tool. | | `REMEMBER_IDENTITY_REQUIRED` | A [Remember](https://frontmcp.dev/reference/plugins/remember#rememberidentityerror) scope needs a caller to keep values apart, and the caller is anonymous (`session` and `user` scope). | Sign the caller in, or use `global` scope. | | `AGENT_CALL_DEPTH_EXCEEDED` | An agent called another, which called another, past [`swarm.maxCallDepth`](https://frontmcp.dev/reference/sdk/agent#swarm) (3 by default). The text lists the agents running. New in 1.9. | Give a chain of agents a way to end before the limit: see [Agents That Call Agents](https://frontmcp.dev/learn/agents-that-call-agents#handoffs-that-never-stop). | | `JOB_NOT_AUTHORIZED` | `execute_job` or `execute_workflow` was called with a name that doesn't exist, or that the caller may not run. | Check the name with `list_jobs`. See [`@Job`](https://frontmcp.dev/reference/sdk/job). | ## Protocol errors | Code | Meaning | Fix | | --- | --- | --- | | `-32001` | **Unauthorized**. A tool needs a credential from one of its `authProviders` that the user hasn't connected, and `data.authUrl` says where to connect it ([Progressive auth](https://frontmcp.dev/reference/auth/progressive#asking-for-a-credential-when-a-tool-needs-it)). Also: a resource or prompt that failed with `UnauthorizedError`, or a request refused by `throttle.ipFilter` (with HTTP `403`). | Send the user to `authUrl`, or mark the provider `required: false`. For `ipFilter`, see [Guard options](https://frontmcp.dev/reference/sdk/guard#ipfilter). | | `-32003` | **Forbidden**. A resource or prompt refused by an [`authorities`](https://frontmcp.dev/reference/auth/authorities#what-a-refused-caller-gets) rule, or that failed with a public error whose `statusCode` is `403`. | Sign in as a caller who's allowed. | | `-32020` | **Header mismatch** (MCP 2026-07-28) (HTTP only). A mirrored header (`Mcp-Method`, `Mcp-Name`, `MCP-Protocol-Version`) disagrees with the request body. | Send headers that match the body. | | `-32021` | **Missing client capability** (MCP 2026-07-28). The request needs a capability the client didn't declare in `_meta`. | Declare the capability in `io.modelcontextprotocol/clientCapabilities`. | | `-32022` | **Unsupported protocol version**. The error lists the `supported` and `requested` versions. | Use a version the server supports. FrontMCP serves every revision from `2024-11-05` to `2026-07-28`. | | `-32029` | **Rate limited**. The server's `throttle.global` limit refused the request, with HTTP `429` and a `Retry-After` header. The message says how many seconds to wait. | Wait that long. See [Guard options](https://frontmcp.dev/reference/sdk/guard#global). | | `-32601` | **Method not found**. Under 2026-07-28 this includes removed methods such as `initialize`, `ping` and `logging/setLevel`. | Use the 2026-07-28 equivalents, such as `server/discover`. | | `-32602` | **Invalid params**, including a resource or prompt that doesn't exist (a missing resource was `-32002` before 2026-07-28), a missing prompt argument, a call to a task-only tool from a client without the tasks extension, and a public error from a resource or prompt, such as a `PublicMcpError`, whose code is in `data.code`. | Check the method's parameters and the resource URI, or read the message. | | `-32603` | **Internal error**: a resource or prompt failed with an error that isn't public, or with a public error whose `statusCode` is 500 or more. For errors that aren't public, production's message is "Internal FrontMCP error" and an error ID. | Find the error ID in the server's log. Fail with a `PublicMcpError` if the message is for the client. See [`this.fail`](https://frontmcp.dev/reference/sdk/fail#in-resources-and-prompts). | [Error classes](https://frontmcp.dev/reference/sdk/error-classes#from-a-resource-or-a-prompt) shows how an error's `statusCode` picks its code. The 2026-07-28 errors apply over stdio too, including `-32021` and `-32022`; stdio has no mirrored HTTP headers to check. Changed in 1.10.5: before, `server/discover` over stdio returned `-32601`; it now returns discovery. See [Protocol versions](https://frontmcp.dev/reference/server/protocol-versions#revisions-over-stdio). ## HTTP status codes Under 2026-07-28, `GET` and `DELETE` on the MCP endpoint return `405`: every request is a `POST`, and there's no session to close. Tool errors, including every tool limit, arrive with `200`. `throttle.global` answers with `429` and `throttle.ipFilter` with `403`, before the request reaches a tool (see [Guard options](https://frontmcp.dev/reference/sdk/guard#what-a-caller-gets-back)). The Node HTTP server and the fetch handler answer `413` for a body over `http.bodyLimit` (`"4mb"` by default). Limits above 4 MiB apply on Workers and edge runtimes too: see [body size limits](https://frontmcp.dev/reference/deployment/security#body-size-limits). ### `Cannot POST /mcp/message` Changed in 1.10.11: before, `initialize` posts to the legacy HTTP+SSE message endpoint with `Accept: */*`, `application/*` or another value listing neither JSON nor an event stream got this HTTP `404`. The message endpoint now accepts any `Accept` value, or none: accepted messages get `202`, with their results on the SSE stream. Upgrade the server, use the message URL and session id named by the stream, and keep legacy SSE enabled. See [client troubleshooting](https://frontmcp.dev/reference/deployment/mcp-clients#cannot-post-mcpmessage). ## Remote redirects `Redirect to not followed` reports a refused redirect from a remote app's default Streamable HTTP transport, for example one that changes host or the request's method. A `POST` follows only `307` or `308` within the allowed origin; point `App.remote()` at the final MCP endpoint. A redirect from HTTPS to HTTP reports `Redirect to plain http not followed; try instead`. A browser that hides the target reports `Redirect not followed: this runtime does not expose the redirect target`. These are transport errors, not new FrontMCP error codes. See [remote redirect troubleshooting](https://frontmcp.dev/reference/server/remote#redirect-to-url-not-followed). ## CLI build errors These stop a build before it writes a usable deployment: | Message | What to do | | --- | --- | | `An SEA binary can only load Node built-ins, so it cannot load the native addon(s) …` | Disable SEA; for CLI, use `--js`. | | `Native addon "…" (build.dependencies.nativeAddons) is not installed under …/node_modules` | Install the addon, or remove it from `nativeAddons`. | | `userConfig "…" maps to "…", which is not an environment variable name` | Set the entry's `env` to a valid name. | | `userConfig "…" and "…" both map to the env var …` | Set a distinct `env` for one entry. | See [the CLI troubleshooting entries](https://frontmcp.dev/reference/cli#troubleshooting). ## Browser bundle errors `Could not resolve "vm"` or `"worker_threads"` from `@enclave-vm/core` means esbuild reached the optional Node-only engine for dynamic jobs. Keep it external when bundling an ordinary tool server for the browser. See [the browser build error](https://frontmcp.dev/reference/deployment/browser#could-not-resolve-vm-or-worker_threads). ## Package loading errors Changed in 1.10.10: before, loading the 1.10.9 cache or dashboard plugin or `@frontmcp/edge` alongside an older top-level `@enclave-vm/ast` could fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`: `Package subpath './redos' is not defined by "exports"`. All three now declare `@enclave-vm/ast: ^2.17.0` so the package manager supplies a compatible copy. Upgrade them with the SDK and reinstall dependencies. See [package-loading troubleshooting](https://frontmcp.dev/reference/plugins#err_package_path_not_exported-package-subpath-redos-is-not-defined-by-exports). ## Registry and route errors These report startup or publishing failures, rather than tool results: | Message or class | What to do | | --- | --- | | `ReservedRouteCollisionError`: `Custom http.route … collides with the reserved FrontMCP path` | Move the custom route. Changed in 1.10.0: before, a custom route could use `/server-card`. It is now reserved, even with no `registry` option. See [reserved paths](https://frontmcp.dev/reference/sdk/frontmcp#custom-httproute--collides-with-the-reserved-frontmcp-path). | | `FlowNotRegisteredError`, `FLOW_NOT_REGISTERED`: `Flow "…" is not registered` | Changed in 1.10.0: before, `create()`, `createDirect()` and `connect()` registered `http:*` flows and the default public auth's flows. They now omit `http:*` flows and, without explicit `auth`, OAuth, OAuth discovery and `session:verify` flows (`connect()`'s CLI mode keeps its existing initialization). Use an HTTP entry point for HTTP work. See [initialization](https://frontmcp.dev/reference/sdk/create#what-the-server-initializes). | | `registry.name must be "/" in reverse-DNS form, e.g. "com.acme/weather"` | Use the [registry name format](https://frontmcp.dev/reference/deployment/mcp-registry#registryname-must-be-namespacename-in-reverse-dns-form-eg-comacmeweather). | | `registry.description is required when registry.name is set` | Supply a 1–100 character description. | | `registry.publicKey must be a base64 Ed25519 public key or a "v=MCPv1; k=...; p=..." record` | Use the public key printed by keygen, not its private hex seed. | | `registry.publicKeyEnv must be an environment variable name` | Use letters, digits and underscores, starting with a letter or underscore. | | `MCP Registry domain proof not served: …` | Warning: the environment key is invalid and the proof returns `404`. Correct the public key. | | `RegistryError`: `The HTTP proof is not ready: …` | Correct the deployed proof's status, redirect, size or key mismatch named in the message. | | `No private key: pass --key-file , set MCP_REGISTRY_PRIVATE_KEY, or create …` | Run keygen or supply the private key to the CLI. | | `The private key must be a 64-character hex Ed25519 seed (what keygen writes) or a PEM.` | Supply keygen's file or an Ed25519 PEM. Unreadable PEMs and other algorithms are rejected. | | `An HTTP proof on … grants …` / `A DNS proof on … grants …` | Match the namespace to the domain proved, or choose DNS for subdomains. | | `--auth github-oidc works only in GitHub Actions, in a job with "permissions: id-token: write".` | Run in GitHub Actions with that permission and an `io.github./` name. | | `server.json is not valid: …` / `/v0.1/validate rejected server.json: …` | Fix each field path listed in the validation report. | | `cannot publish duplicate version` | Publish a new version; deletion doesn't free a version for reuse. | | `missing required 'mcpName' field` | Add `mcpName` to the npm package and publish a new npm version. | | `remote URL … is already used by server …` | Use that server name or a different remote URL. | | `Pass --server-version to change one version, or --all-versions to change every version.` | Supply exactly one selector to `registry status`. | Registry schema errors are `ZodError` validation failures. CLI `RegistryError` failures print the message and exit with code `1`; they have no MCP error code. See [MCP Registry troubleshooting](https://frontmcp.dev/reference/deployment/mcp-registry#troubleshooting) and [CLI troubleshooting](https://frontmcp.dev/reference/cli#troubleshooting). The channel warning `Channel "…" has a webhook source but no HTTP server to serve …` means the server runs in process. Use an HTTP entry point to [receive webhooks](https://frontmcp.dev/reference/sdk/channel#channel--has-a-webhook-source-but-no-http-server-to-serve-). ## Testing integration errors ### `@frontmcp/testing/playwright is not implemented` The Playwright subpath is a placeholder. Calling `test` or `expect`, or reading an integration member such as `test.describe`, throws the message with alternatives. Use `@playwright/test` against a server started with `TestServer.start()` from `@frontmcp/testing`, or [MockOAuthServer and TestTokenFactory](https://frontmcp.dev/reference/testing/auth) for OAuth tests without a browser. Changed in 1.9.7: before, member access or `expect()` could fail with an unhelpful `TypeError`. ## Decorator type errors ### `execute() requires a parameter matching the input schema` An agent's `execute()` override omits its parameter while `inputSchema` has fields. Add a matching parameter. With `inputSchema: {}`, the override can omit it. Changed in 1.9.7: that empty-input override compiles; the missing parameter for a schema with fields reports this message. See [Running an agent without input](https://frontmcp.dev/reference/sdk/agent#running-an-agent-without-input). --- # Scope and registries > What this.scope gives a FrontMCP tool, resource or prompt, which registries you can list and search, what you can add at runtime, and which parts are FrontMCP's own machinery. Source: https://frontmcp.dev/reference/sdk/scope [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `this.scope` is the server a call runs in. It holds a registry for each kind of thing the server offers (tools, resources, prompts, agents, skills, jobs, providers and more) with every app's entries in it, and the services FrontMCP runs on. A tool can use it to list what the server offers, find the resource behind a URI, read the server's configuration, or add a resource or a skill at runtime. Much of the rest is FrontMCP's own machinery, and this page says which parts those are. ```ts this.scope.tools.getTools() this.scope.resources.findResourceForUri(uri) this.scope.metadata.info ``` --- ## Reference ### `this.scope` Tools, resources, prompts, agents, jobs and channels all have `this.scope`. ```ts list-capabilities.tool.ts import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "list_capabilities", description: "List every tool and resource this server offers", inputSchema: {} }) export class ListCapabilities extends ToolContext { async execute() { return { tools: this.scope.tools.getTools().map((tool) => tool.name), resources: this.scope.resources.getResources().map((resource) => resource.uri), }; } } ``` [See more examples below.](https://frontmcp.dev/reference/sdk/scope#usage) A server has one scope, whose `id` is `"root"` (with [`splitByApp`](https://frontmcp.dev/reference/sdk/frontmcp#options), one per app), and its registries hold the entries of every app, plus the ones FrontMCP adds itself, like `execute_job` on a server with jobs. What you change in a registry changes the server for every caller at once. The type of `this.scope` is `ScopeEntry`, which declares every registry below. (Changed in 1.9.1: before, `jobs`, `workflows`, `channels`, `plugins` and a few services existed at run time but not on the type, so code had to cast `this.scope`: see [Troubleshooting](https://frontmcp.dev/reference/sdk/scope#property-jobs-does-not-exist-on-type-scopeentry).) #### Registries | Member | Type | What you can do with it | | --- | --- | --- | | `tools` | `ToolRegistry` | List and look up tools. See [`tools`](https://frontmcp.dev/reference/sdk/scope#tools). | | `resources` | `ResourceRegistry` | List resources and templates, find the one for a URI, and add resources at runtime. See [`resources`](https://frontmcp.dev/reference/sdk/scope#resources). | | `prompts` | `PromptRegistry` | `getPrompts()`, `findByName(name)`, `findAllByName(name)`, `getExported(name)`, `subscribe()`. | | `agents` | `AgentRegistry` | `getAgents()`, `findByName(name)`, `findById(id)`, `subscribe()`. | | `skills` | `SkillRegistry` | `getSkills()`, `findByName(name)`, `listSkills()`, `search(query)`, `loadSkill(id)`, and `registerSkillContent(content)` and `unregisterSkill(id)` to add and remove skills at runtime. See [`skills`](https://frontmcp.dev/reference/sdk/scope#skills) and [`@Skill`](https://frontmcp.dev/reference/sdk/skill). | | `apps` | `AppRegistry` | `getApps()`: each app's `id`, `metadata`, and its own `tools`, `resources`, `prompts`, `skills`, `providers` and `plugins` registries. | | `providers` | `ProviderRegistry` | The server's own providers, from `@FrontMcp({ providers })` and those [plugins](https://frontmcp.dev/reference/sdk/plugin) add to the server: `get(token)`, and `addDynamicProviders([...])` to add one at runtime. **Not** an app's providers: use [`this.get()`](https://frontmcp.dev/reference/sdk/get) for those. | | `hooks` | `HookRegistry` | `getHooks()`, `getFlowHooks(flow)`: every registered hook, FrontMCP's own included, for inspection. | | `jobs`, `workflows` | `JobRegistry`, `WorkflowRegistry`, or `undefined` | `getJobs()` / `getWorkflows()`, `findByName(name)`, `findById(id)`, `search(query?)`. `undefined` until an app has jobs or workflows; then both exist. | | `channels` | `ChannelRegistry`, or `undefined` | `getChannels()`, `findByName(name)`. `undefined` unless `channels.enabled`, as are `channelEventBus` and `channelNotifications`, which send events. See [`@Channel`](https://frontmcp.dev/reference/sdk/channel#emitting-events-from-your-code). | | `plugins` | `PluginRegistryInterface`, or `undefined` | `getPlugins()`, `getPluginNames()`: the plugins in `@FrontMcp({ plugins })`, and `undefined` without any. An app's plugins are in its own registry, under `apps`. `getPlugins()` returns each plugin's instance, typed `PluginInstance` (`{ get(token) }`) since 1.9.1. | | `authProviders` | `AuthRegistry` | `getPrimary()`, `getAuthProviders()`: how the server authenticates callers. For the current caller, use [`this.auth`](https://frontmcp.dev/reference/sdk/auth). | #### Other members | Member | What it is | | --- | --- | | `id` | `"root"`. | | `metadata` | The server's `@FrontMcp` options after parsing, defaults filled in: `info`, `apps`, `providers`, `transport` and the rest. Read it; don't change it. | | `entryPath`, `routeBase`, `fullPath` | The MCP endpoint's path: `http.entryPath`, `""` by default. | | `logger` | The server's logger. | | `rateLimitManager` | The [guard's](https://frontmcp.dev/reference/sdk/guard) `GuardManager`, or `undefined` when no limits are set. | | `getAllSupportedScopes()` | The OAuth scopes the server supports, like `["email", "openid", "profile"]`. | | `onServerStarted(callback)` | Runs `callback` once the HTTP server listens. It never runs for a server that doesn't listen, like one from `create()`. | | `onDispose(callback)` | Runs `callback` once, when the scope is disposed, as `dispose()` on a `create()` server does. Returns a function that removes it. New in 1.9: see [`registerTool()`](https://frontmcp.dev/reference/sdk/register-tool). | The rest is FrontMCP's own machinery, and changing it can break the server: `auth`, `notifications`, `elicitationStore`, `tasks`, `taskStore`, `eventStore`, `toolUI`, `authUi`, `transportService`, `healthService`, `skillSession`, `authorities*`, `runFlow()`, `runFlowForOutput()`, `registryFlows()`, `shutdown()` and `dispose()`. To call another tool, use [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool), not `runFlow()`. Changed in 1.10.0: before, scopes from `create()`, `createDirect()` and `connect()` initialized HTTP services. `transportService` and `healthService` are now `undefined` in those scopes (`connect()`'s CLI mode keeps its existing initialization). Without explicit `auth`, OAuth, OAuth discovery and `session:verify` flows aren't registered either. Configured Registry flows are still registered. The [`create()` example](https://frontmcp.dev/reference/sdk/create#checking-what-an-embedded-server-builds) compares those scopes with a fetch handler's. ### `tools` | Method | Returns | | --- | --- | | `getTools(includeHidden?)` | Every tool clients can see. With `true`, also the ones with `visibility: "hidden"` or `"internal"`. | | `getExported(name)` | The tool clients call by `name`, or `undefined`. | | `exportResolvedNames()` | `{ name, instance }` for every tool, hidden ones included, under the name clients use for it. | | `listByOwner(ownerPath)`, `lineageOf(entry)` | Tools by where they come from, like an app or a plugin. | | `subscribe(opts, callback)` | See [change events](https://frontmcp.dev/reference/sdk/scope#change-events). | | `hasAny()` | Whether there's at least one tool. | A `ToolEntry` has `name`, `fullName` (the owner's id and the name, like `"help-desk:search_tickets"`), `owner` (`{ kind, id }`, where `kind` is `"app"`, `"scope"`, `"plugin"`, `"adapter"` or `"agent"`), `metadata` (the tool's options, parsed), and `getInputJsonSchema()` / `getOutputJsonSchema()` for the JSON Schemas clients see. **`this.scope.tools` has no way to add a tool.** `registerToolInstance()` takes a `ToolInstance` built from an internal record, and `replaceAll(list, owner)` replaces every tool the registry itself owns. On `this.scope.tools` that's FrontMCP's own tools: `replaceAll()` does add yours, but `execute_job` and the other job and workflow tools disappear with it. To add tools while the server runs, use `registerTool()` on the server `create()` returns (new in 1.9): see [`registerTool()`](https://frontmcp.dev/reference/sdk/register-tool). Otherwise, register every tool at startup, and hide what isn't ready with [`visibility`](https://frontmcp.dev/reference/sdk/tool), [`availableWhen`](https://frontmcp.dev/reference/sdk/tool) or [authorities](https://frontmcp.dev/learn/authorizing-calls). ### `resources` | Method | Returns | | --- | --- | | `getResources(includeHidden?)` | Every fixed-URI resource. | | `getResourceTemplates()` | Every resource template. | | `findByUri(uri)` | The fixed-URI resource with exactly that URI, or `undefined`. | | `findResourceForUri(uri)` | `{ instance, params }`: the resource or template that serves `uri`, as `resources/read` would pick it, with the template's variables. `undefined` if none does. | | `getExported(name)` | A resource by name. | | `registerDynamicResource(resource)` | Adds a `@Resource` or `@ResourceTemplate` class, or a `resource()`, at runtime. Clients see it in their next `resources/list`, and can read it. Registering the same class twice does nothing. | | `subscribe(opts, callback)` | See [change events](https://frontmcp.dev/reference/sdk/scope#change-events). | A `ResourceEntry` has `name`, `fullName`, `owner`, `metadata`, `isTemplate`, and `uri` or `uriTemplate`. `replaceAll()`, `registerResourceInstance()` and `notifyContentChanged()` are FrontMCP's own. ### `skills` | Method | Returns | | --- | --- | | `registerSkillContent(content, options?)` | Adds a skill while the server runs, or replaces the one added earlier with the same `id`. Returns a promise of `{ id, unregister, removed }`: `unregister()` removes the skill again. See below. | | `unregisterSkill(id)` | A promise of `true` when it removed a skill added with `registerSkillContent()`, and `false` for any other id, including a skill an app declares, which stays. | | `setExternalProvider(provider)`, `syncToExternal()` | Keep skills in storage outside the server. See [External skill storage](https://frontmcp.dev/reference/sdk/skill#external-skill-storage). | | `subscribe(opts, callback)` | See [change events](https://frontmcp.dev/reference/sdk/scope#change-events). | This is what a plugin with [`dynamicSkills: true`](https://frontmcp.dev/reference/sdk/plugin#options) calls, like [Skilled OpenAPI](https://frontmcp.dev/reference/plugins/skilled-openapi) when its bundle loads. `content` is a skill written out in full, with the instructions as text: | Field | Type | Description | | --- | --- | --- | | `id` | `string` | **Required.** The id `skills/load` takes, and the path of its `SKILL.md`: `skill:///SKILL.md`. Without one, the call throws `registerSkillContent: SkillContent.id is required`. | | `name` | `string` | **Required.** Unlike `@Skill`, FrontMCP doesn't check it: `"Billing Escalation"` is accepted. Use kebab-case, like an app's skills. | | `description` | `string` | What the procedure is for. | | `instructions` | `string` | The procedure. | | `tools` | `{ name, purpose?, required? }[]` | **Required**, `[]` for none: without it the call throws `Cannot read properties of undefined (reading 'map')`. Marked available or missing like the tools of an app's skill. | | `parameters`, `examples`, `license`, `compatibility`, `specMetadata`, `allowedTools`, `resources`, `category`, `rating` | | As in [`@Skill`'s options](https://frontmcp.dev/reference/sdk/skill#options). | `options.source` names where the skill came from, in FrontMCP's debug log. `options.supersedes`, for swapping one set of runtime skills for another, isn't covered here. What a skill added this way does: - **It's served like an app's skill.** `skills/list`, `skills/search` and `skills/load` have it, `skill://index.json` lists it, and its `SKILL.md` can be read. It is also in `resources/list`. Changed in 1.9.5: before, only declared skills were listed there. - **Clients hear about it.** Each add, replace and removal sends `notifications/resources/list_changed` and `notifications/skills/list_changed` to every client with a session. A `false` from `unregisterSkill()` sends nothing. - **The server needs to know skills may come.** On a server with no skills of its own, `skills/list`, `skills/search` and `skills/load` answer `Method not found` unless a plugin sets `dynamicSkills: true`, and in-process clients, like `connect()`'s, keep getting it after a skill is added. See [Troubleshooting](https://frontmcp.dev/reference/sdk/scope#skillslist-answers-method-not-found-after-a-skill-was-added). - **The same `id` as an app's skill takes that skill's place** in `skills/list`, `skills/search`, `skills/load` and its `SKILL.md`, until it's removed. - **It lasts until the server restarts**, like a [resource added at runtime](https://frontmcp.dev/reference/sdk/scope#adding-a-resource-at-runtime). ### Change events `subscribe({ immediate?, filter? }, callback)`, on `tools`, `resources`, `prompts`, `agents`, `skills`, `jobs`, `workflows` and `channels`, calls `callback` whenever the registry changes, and returns a function that unsubscribes. With `immediate: true`, it's also called once right away. The event: | Field | Type | Description | | --- | --- | --- | | `kind` | `"added" \| "updated" \| "removed" \| "reset"` | What happened. Registering a resource at runtime, and `immediate`, give `"reset"`. | | `changeScope` | `"global" \| "session"` | Whether the change is for everyone. | | `version` | `number` | Goes up with every change. | | `snapshot` | entries | Everything in the registry after the change. | #### Caveats - **`this.scope.providers` holds only the server's providers.** `this.scope.providers.get(X)` for a provider registered in an app throws `Provider "X" is not available`. `this.get(X)` finds it. - **`getTools()` leaves out hidden tools.** Pass `true` to include them. Tools that FrontMCP adds hidden, like `list_jobs`, only show up that way. - **The registries hold every app's entries**, and FrontMCP's own. Filter by `owner` if you only want one app's. - **Changes apply to every caller.** A resource added at runtime is there for everyone, until the server restarts. Nothing in a registry is per user or per request. --- ## Usage ### Listing what the server offers A tool that describes the server, for a model that wants an overview before it starts. The server has two apps, and `this.scope` sees the tools of both. `admin_reset` is hidden, so it's only listed with `getTools(true)`: Initial call configuration: ```js { tool: "describe_server", arguments: {} } ``` ```ts describe-server.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "describe_server", description: "List this server's tools, resources and prompts, with what each is for", inputSchema: {} }) export class DescribeServer extends ToolContext { async execute() { const { tools, resources, prompts, metadata } = this.scope; return { server: metadata.info.name, tools: tools.getTools().map((tool) => ({ name: tool.name, app: tool.owner.id, description: tool.metadata.description })), resources: resources.getResources().map((resource) => resource.uri), templates: resources.getResourceTemplates().map((template) => template.uriTemplate), prompts: prompts.getPrompts().map((prompt) => prompt.name), hiddenTools: tools.getTools(true).filter((tool) => tool.metadata.visibility === "hidden").map((tool) => tool.name), }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { DescribeServer } from "./describe-server.tool"; import { AdminReset, SearchTickets, SummarizeTicket, Ticket, TicketStats } from "./help-desk"; import { GetInvoice } from "./billing"; @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, AdminReset, DescribeServer], resources: [TicketStats, Ticket], prompts: [SummarizeTicket] }) class HelpDeskApp {} @App({ id: "billing", name: "Billing", tools: [GetInvoice] }) class BillingApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp] }) export default class Server {} ``` ```ts help-desk.ts import { Prompt, PromptContext, Resource, ResourceContext, ResourceTemplate, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, tickets: [] }; } } @Tool({ name: "admin_reset", description: "Reset the ticket store", inputSchema: {}, visibility: "hidden" }) export class AdminReset extends ToolContext { async execute() { return { reset: true }; } } @Resource({ name: "ticket-stats", uri: "tickets://stats", mimeType: "application/json" }) export class TicketStats extends ResourceContext { async execute(uri: string) { return { contents: [{ uri, text: JSON.stringify({ open: 12 }) }] }; } } @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json" }) export class Ticket extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { return { contents: [{ uri, text: JSON.stringify({ id }) }] }; } } @Prompt({ name: "summarize_ticket", description: "Summarize a ticket", arguments: [{ name: "id", required: true }] }) export class SummarizeTicket extends PromptContext { async execute({ id }: Record) { return { messages: [{ role: "user" as const, content: { type: "text" as const, text: `Summarize ticket ${id}.` } }] }; } } ``` ```ts billing.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_invoice", description: "Get an invoice by number", inputSchema: { number: z.string() } }) export class GetInvoice extends ToolContext { async execute({ number }: { number: string }) { return { number, total: 120 }; } } ``` ```ts describe.test.ts import { test, expect } from "@frontmcp/testing"; test("the scope sees every app's tools, resources and prompts", async ({ mcp }) => { const result = (await mcp.tools.call("describe_server", {})).json(); expect(result.server).toBe("help-desk"); expect(result.tools).toEqual( expect.arrayContaining([ { name: "search_tickets", app: "help-desk", description: "Search tickets by title" }, { name: "get_invoice", app: "billing", description: "Get an invoice by number" }, ]), ); expect(result.resources).toEqual(["tickets://stats"]); expect(result.templates).toEqual(["tickets://{id}"]); expect(result.prompts).toEqual(["summarize_ticket"]); }); test("hidden tools are only listed with getTools(true)", async ({ mcp }) => { const result = (await mcp.tools.call("describe_server", {})).json(); expect(result.tools.map((t: { name: string }) => t.name)).not.toContain("admin_reset"); expect(result.hiddenTools).toEqual(["admin_reset"]); }); ``` ### Finding what serves a URI `findResourceForUri()` picks the resource or template the way `resources/read` would, and returns the template's variables. Here a tool checks a link before handing it to the model: Initial call configuration: ```js { tool: "check_link", arguments: { uri: "tickets://T-4" } } ``` ```ts check-link.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "check_link", description: "Check that this server can read a resource URI", inputSchema: { uri: z.string() } }) export class CheckLink extends ToolContext { async execute({ uri }: { uri: string }) { const match = this.scope.resources.findResourceForUri(uri); if (!match) return { readable: false }; return { readable: true, resource: match.instance.name, template: match.instance.isTemplate, params: match.params }; } } ``` ```ts tickets.resources.ts import { Resource, ResourceContext, ResourceTemplate } from "@frontmcp/sdk"; @Resource({ name: "ticket-stats", uri: "tickets://stats", mimeType: "application/json" }) export class TicketStats extends ResourceContext { async execute(uri: string) { return { contents: [{ uri, text: JSON.stringify({ open: 12 }) }] }; } } @ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json" }) export class Ticket extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { return { contents: [{ uri, text: JSON.stringify({ id }) }] }; } } ``` ```ts check-link.test.ts import { test, expect } from "@frontmcp/testing"; test("a template match comes with its variables", async ({ mcp }) => { const result = await mcp.tools.call("check_link", { uri: "tickets://T-4" }); expect(result.json()).toEqual({ readable: true, resource: "ticket", template: true, params: { id: "T-4" } }); }); test("a fixed URI wins over a template that also matches", async ({ mcp }) => { const result = await mcp.tools.call("check_link", { uri: "tickets://stats" }); expect(result.json()).toEqual({ readable: true, resource: "ticket-stats", template: false, params: {} }); }); test("a URI nothing serves", async ({ mcp }) => { expect((await mcp.tools.call("check_link", { uri: "invoices://1" })).json()).toEqual({ readable: false }); }); ``` ### Adding a resource at runtime `registerDynamicResource()` adds a resource class to the server while it runs. Clients see it in their next `resources/list`. Here the weekly report only exists once someone has generated it: Initial call configuration: ```js { tool: "publish_weekly_report", arguments: {} } ``` ```ts publish-weekly-report.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; import { WeeklyReport } from "./weekly-report.resource"; export const changes: string[] = []; @Tool({ name: "publish_weekly_report", description: "Generate this week's report and publish it as reports://weekly", inputSchema: {} }) export class PublishWeeklyReport extends ToolContext { async execute() { const stop = this.scope.resources.subscribe({}, (event) => changes.push(`${event.kind}: ${event.snapshot.length} resources`)); this.scope.resources.registerDynamicResource(WeeklyReport); stop(); return { published: "reports://weekly" }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { PublishWeeklyReport } from "./publish-weekly-report.tool"; @App({ id: "reports", name: "Reports", tools: [PublishWeeklyReport] }) class ReportsApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [ReportsApp] }) export default class Server {} ``` ```ts weekly-report.resource.ts import { Resource, ResourceContext } from "@frontmcp/sdk"; // Not in the app's `resources`: publish_weekly_report adds it. @Resource({ name: "weekly-report", uri: "reports://weekly", mimeType: "text/plain" }) export class WeeklyReport extends ResourceContext { async execute(uri: string) { return { contents: [{ uri, text: "41 tickets opened, 38 closed." }] }; } } ``` ```ts publish.test.ts import { test, expect } from "@frontmcp/testing"; import { changes } from "./publish-weekly-report.tool"; test("the report isn't there before it's published", async ({ mcp }) => { expect(await mcp.resources.list()).toEqual([]); expect(await mcp.resources.read("reports://weekly")).toBeError(); }); test("once published, clients can list and read it", async ({ mcp }) => { await mcp.tools.call("publish_weekly_report", {}); await mcp.tools.call("publish_weekly_report", {}); // the same class again: no change expect((await mcp.resources.list()).map((r: { uri: string }) => r.uri)).toEqual(["reports://weekly"]); expect((await mcp.resources.read("reports://weekly")).text()).toBe("41 tickets opened, 38 closed."); }); test("subscribers hear about it", () => { expect(changes).toEqual(["reset: 1 resources"]); }); ``` A resource added this way belongs to the scope, not to an app, and lasts until the server restarts. On a server with several instances, each instance has only what was added to it. ### Adding a skill at runtime `registerSkillContent()` adds a [skill](https://frontmcp.dev/reference/sdk/skill) while the server runs. Here each team writes its escalation runbook in the help desk, and `publish_runbook` turns it into a skill the client's model can load. The plugin's `dynamicSkills: true` tells the server skills may arrive later, so it answers the skills requests from the start: Initial call configuration: ```js { tool: "publish_runbook", arguments: { team: "billing" } } ``` ```ts runbooks.ts active import { Plugin, Tool, ToolContext, z } from "@frontmcp/sdk"; // The server has no skills of its own: without this, it wouldn't answer skills/list @Plugin({ name: "runbooks", dynamicSkills: true }) export class RunbooksPlugin {} @Tool({ name: "publish_runbook", description: "Publish a team's escalation runbook as a skill", inputSchema: { team: z.string() } }) export class PublishRunbook extends ToolContext { async execute({ team }: { team: string }) { const { id } = await this.scope.skills.registerSkillContent( { id: `escalate-to-${team}`, name: `escalate-to-${team}`, description: `Escalate a ticket to the ${team} team.`, instructions: `1. Read the ticket with get_ticket.\n2. Page the ${team} on-call engineer with page_on_call.`, tools: [{ name: "get_ticket" }, { name: "page_on_call", purpose: "Page the on-call engineer" }], }, { source: "publish_runbook" }, ); return { published: id }; } } @Tool({ name: "retire_runbook", description: "Remove a team's escalation runbook", inputSchema: { team: z.string() } }) export class RetireRunbook extends ToolContext { async execute({ team }: { team: string }) { return { retired: await this.scope.skills.unregisterSkill(`escalate-to-${team}`) }; } } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { PublishRunbook, RetireRunbook, RunbooksPlugin } from "./runbooks"; @Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } }) export class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Charged twice for invoice 1042" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, PublishRunbook, RetireRunbook], plugins: [RunbooksPlugin] }) export class HelpDeskApp {} ``` ```ts runbooks.test.ts import { test, expect } from "@frontmcp/testing"; import { create } from "@frontmcp/sdk"; import { GetTicket } from "./help-desk.app"; import { PublishRunbook, RetireRunbook, RunbooksPlugin } from "./runbooks"; const request = (method: string, params: object) => ({ jsonrpc: "2.0" as const, id: 1, method, params }); const listed = async (mcp: any) => (await mcp.raw.request(request("skills/list", {}))).result.skills.map((s: { id: string }) => s.id); test("the skill is there once published", async ({ mcp }) => { expect(await listed(mcp)).toEqual([]); expect((await mcp.tools.call("publish_runbook", { team: "billing" })).json()).toEqual({ published: "escalate-to-billing" }); expect(await listed(mcp)).toEqual(["escalate-to-billing"]); const { result } = await mcp.raw.request(request("skills/load", { skillIds: ["escalate-to-billing"] })); expect(result.skills[0]).toMatchObject({ instructions: "1. Read the ticket with get_ticket.\n2. Page the billing on-call engineer with page_on_call.", availableTools: ["get_ticket"], missingTools: ["page_on_call"], }); }); test("clients read its SKILL.md and find it in the index and resources/list", async ({ mcp }) => { const text = (await mcp.resources.read("skill://escalate-to-billing/SKILL.md")).text(); expect(text).toMatch(/^---\nname: escalate-to-billing\ndescription: Escalate a ticket to the billing team\.\n/); const index = (await mcp.resources.read("skill://index.json")).json(); expect(index.skills.map((s: { url: string }) => s.url)).toContain("skill://escalate-to-billing/SKILL.md"); expect((await mcp.resources.list()).map((r: { uri: string }) => r.uri)).toEqual(["skill://escalate-to-billing/SKILL.md", "skill://index.json"]); }); test("retiring removes it; a second time there's nothing to remove", async ({ mcp }) => { expect((await mcp.tools.call("retire_runbook", { team: "billing" })).json()).toEqual({ retired: true }); expect((await mcp.tools.call("retire_runbook", { team: "billing" })).json()).toEqual({ retired: false }); expect(await listed(mcp)).toEqual([]); }); test("connected clients get notifications/skills/list_changed", async () => { const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [GetTicket, PublishRunbook, RetireRunbook], plugins: [RunbooksPlugin], }); const client = await server.connect(); const heard: string[] = []; client.onNotification((notification: { method: string }) => { heard.push(notification.method); }); try { await client.callTool("publish_runbook", { team: "billing" }); await client.callTool("retire_runbook", { team: "billing" }); await new Promise((resolve) => setTimeout(resolve, 20)); expect(heard).toEqual(["notifications/resources/list_changed", "notifications/skills/list_changed", "notifications/resources/list_changed", "notifications/skills/list_changed"]); } finally { await client.close(); await server.dispose(); } }); ``` `page_on_call` isn't a tool of this server, so `skills/load` lists it in `missingTools`, as for an app's skill. Publishing the same team again replaces its skill, and clients hear about that too. A skill added this way is for every caller, and a server with several instances has it only on the instance that added it. ### Reading the server's configuration `this.scope.metadata` is the server's `@FrontMcp` options, with defaults filled in, and `this.scope.apps` has each app's. Here a tool reports the server's version, and a server-level provider is read through `this.scope.providers`: Initial call configuration: ```js { tool: "server_version", arguments: {} } ``` ```ts main.ts active import { App, FrontMcp, Provider, ProviderScope, Tool, ToolContext } from "@frontmcp/sdk"; @Provider({ name: "Deployment", scope: ProviderScope.GLOBAL }) export class Deployment { region = "eu-west-1"; } @Tool({ name: "server_version", description: "Which version of the server is running, and where", inputSchema: {} }) export class ServerVersion extends ToolContext { async execute() { return { scope: this.scope.id, name: this.scope.metadata.info.name, version: this.scope.metadata.info.version, apps: this.scope.apps.getApps().map((app) => app.id), region: this.scope.providers.get(Deployment).region, }; } } @App({ id: "help-desk", name: "Help Desk", tools: [ServerVersion] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "2.3.0" }, apps: [HelpDeskApp], providers: [Deployment] }) export default class Server {} ``` ```ts version.test.ts import { test, expect } from "@frontmcp/testing"; test("the tool reads the server's info, apps and providers", async ({ mcp }) => { const result = await mcp.tools.call("server_version", {}); expect(result.json()).toEqual({ scope: "root", name: "help-desk", version: "2.3.0", apps: ["help-desk"], region: "eu-west-1" }); }); ``` `this.scope.providers` has the server's providers, not an app's. For a provider registered on an app, `this.get()` is the way ([see Troubleshooting](https://frontmcp.dev/reference/sdk/scope#provider-x-is-not-available-from-thisscopeprovidersget)). ### Using the scope outside a call `FrontMcpInstance.createForGraph()` builds a server without serving it, and `getScopes()` returns its scope, for scripts and tests that inspect a server. See [`FrontMcpInstance`](https://frontmcp.dev/reference/sdk/frontmcp-instance). Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts help-desk.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, tickets: [] }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] }) export class HelpDeskApp {} ``` ```ts inventory.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; test("a script can list a server's tools without serving it", async () => { const server = await FrontMcpInstance.createForGraph({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); const [scope] = server.getScopes(); const tools = scope.tools.getTools().map((tool) => ({ name: tool.name, input: tool.getInputJsonSchema() })); expect(tools).toEqual([ { name: "search_tickets", input: expect.objectContaining({ type: "object", properties: { query: { type: "string" } } }) }, ]); }); ``` --- ## Troubleshooting ### `Provider "X" is not available` from `this.scope.providers.get()` `this.scope.providers` holds the server's providers, the ones registered on `@FrontMcp`. A provider registered on an app lives in that app's registry: Initial call configuration: ```js { tool: "count_tickets", arguments: {} } ``` ```ts count-tickets.tool.ts active import { App, Provider, Tool, ToolContext } from "@frontmcp/sdk"; @Provider({ name: "TicketStore" }) export class TicketStore { count() { return 12; } } @Tool({ name: "count_tickets", description: "How many tickets there are", inputSchema: {} }) export class CountTickets extends ToolContext { async execute() { return { count: this.scope.providers.get(TicketStore).count() }; // 🚩 an app's provider } } @Tool({ name: "count_tickets_fixed", description: "How many tickets there are", inputSchema: {} }) export class CountTicketsFixed extends ToolContext { async execute() { return { count: this.get(TicketStore).count() }; // ✅ } } @App({ id: "help-desk", name: "Help Desk", tools: [CountTickets, CountTicketsFixed], providers: [TicketStore] }) export class HelpDeskApp {} ``` ```ts providers.test.ts import { test, expect } from "@frontmcp/testing"; test("the scope doesn't have the app's provider", async ({ mcp }) => { const result = await mcp.tools.call("count_tickets", {}); expect(result.text()).toContain('Provider "TicketStore" is not available: not found in local or parent registries'); }); test("this.get() finds it", async ({ mcp }) => { expect((await mcp.tools.call("count_tickets_fixed", {})).json()).toEqual({ count: 12 }); }); ``` Use `this.get(X)` from the tool, which looks in the tool's app first, then the server. See [where `this.get()` looks](https://frontmcp.dev/reference/sdk/get#where-frontmcp-looks). ### `Property 'jobs' does not exist on type 'ScopeEntry'` The project has a FrontMCP older than 1.9.1. There, `jobs`, `workflows`, `channels` and `plugins` existed on the scope at run time but not on its type, `ScopeEntry`, and code cast `this.scope` to reach them. Since 1.9.1, `ScopeEntry` declares them, so upgrade and read them directly: Initial call configuration: ```js { tool: "list_job_names", arguments: {} } ``` ```ts list-job-names.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "list_job_names", description: "List the jobs this server can run", inputSchema: {} }) export class ListJobNames extends ToolContext { async execute() { return { jobs: this.scope.jobs?.getJobs().map((job) => job.name) ?? [] }; } } ``` ```ts nightly-cleanup.job.ts import { Job, JobContext } from "@frontmcp/sdk"; @Job({ name: "nightly_cleanup", description: "Close tickets nobody has touched in 30 days", inputSchema: {}, outputSchema: {} }) export class NightlyCleanup extends JobContext { async execute() { return {}; } } ``` ```ts jobs.test.ts import { test, expect } from "@frontmcp/testing"; test("the tool lists the server's jobs", async ({ mcp }) => { expect((await mcp.tools.call("list_job_names", {})).json()).toEqual({ jobs: ["nightly_cleanup"] }); }); ``` `jobs` is `undefined` on a server without jobs, so keep the `?.`. ### `Tool "execute_job" not found` after adding a tool with `replaceAll()` `this.scope.tools.replaceAll()` replaced every tool the scope owns, and those are FrontMCP's own, like the job and workflow tools: Initial call configuration: ```js { tool: "enable_exports", arguments: {} } ``` ```ts enable-exports.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "export_tickets", description: "Export every ticket as CSV", inputSchema: {} }) export class ExportTickets extends ToolContext { async execute() { return { csv: "id,title\nT-1,Cannot log in" }; } } @Tool({ name: "enable_exports", description: "Turn on the export tool", inputSchema: {} }) export class EnableExports extends ToolContext { async execute() { this.scope.tools.replaceAll([ExportTickets], this.scope.tools.owner); // 🚩 replaces the scope's own tools return { enabled: true }; } } ``` ```ts main.ts import { App, FrontMcp, Job, JobContext } from "@frontmcp/sdk"; import { EnableExports } from "./enable-exports.tool"; @Job({ name: "nightly_cleanup", description: "Close stale tickets", inputSchema: {}, outputSchema: {} }) class NightlyCleanup extends JobContext { async execute() { return {}; } } @App({ id: "help-desk", name: "Help Desk", tools: [EnableExports], jobs: [NightlyCleanup] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts replace.test.ts import { test, expect } from "@frontmcp/testing"; test("the new tool arrives, and the job tools are gone", async ({ mcp }) => { const before = (await mcp.tools.list()).map((t: { name: string }) => t.name); expect(before).toContain("execute_job"); await mcp.tools.call("enable_exports", {}); const after = (await mcp.tools.list()).map((t: { name: string }) => t.name); expect(after).toContain("export_tickets"); expect(after).not.toContain("execute_job"); expect((await mcp.tools.call("execute_job", { name: "nightly_cleanup", input: {} })).text()).toBe('Tool "execute_job" not found'); }); ``` From inside a call, there's no supported way to add a tool: register every tool at startup, and hide the ones that aren't ready with `visibility`, `availableWhen` or authorities. A `create()` server can add tools with [`registerTool()`](https://frontmcp.dev/reference/sdk/register-tool), since 1.9. See [`tools`](https://frontmcp.dev/reference/sdk/scope#tools). ### `Invalid resource '…'. Expected a class or a resource function.` `registerDynamicResource()` was given a plain object, like `{ name, uri, read }`. It takes what an app's `resources` takes: a `@Resource` or `@ResourceTemplate` class, or a `resource()`: Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts help-desk.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, tickets: [] }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] }) export class HelpDeskApp {} ``` ```ts register.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance, resource } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; const scopeOf = async () => (await FrontMcpInstance.createForGraph({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] })).getScopes()[0]; test("a plain object is refused", async () => { const scope = await scopeOf(); const config = { name: "runtime-config", uri: "config://runtime", read: async () => ({ text: "{}" }) }; expect(() => scope.resources.registerDynamicResource(config as never)).toThrow( "Invalid resource 'runtime-config'. Expected a class or a resource function.", ); }); test("a resource() works", async () => { const scope = await scopeOf(); const runtimeConfig = resource({ name: "runtime-config", uri: "config://runtime", mimeType: "application/json" })( async (uri: string) => ({ contents: [{ uri, text: "{}" }] }), ); scope.resources.registerDynamicResource(runtimeConfig); expect(scope.resources.getResources().map((r) => r.uri)).toEqual(["config://runtime"]); }); ``` ### `skills/list` answers `Method not found` after a skill was added The server had no skills when it started, and nothing told it skills would be added, so `skills/list`, `skills/search` and `skills/load` answer `-32601` `Method not found`. Adding a skill with `registerSkillContent()` doesn't fix it for in-process clients, from `connect()` or `server.connect()`: the skill is in the registry, but they can't reach it. Register a plugin with `dynamicSkills: true` on the app or the server, as in [Adding a skill at runtime](https://frontmcp.dev/reference/sdk/scope#adding-a-skill-at-runtime): ```ts refund-policy.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "publish_refund_policy", description: "Publish the refund policy as a skill", inputSchema: {} }) export class PublishRefundPolicy extends ToolContext { async execute() { await this.scope.skills.registerSkillContent({ id: "refund-policy", name: "refund-policy", description: "How to answer a customer who asks for a refund.", instructions: "Refunds within 30 days of the charge are automatic. After 30 days, assign the ticket to billing.", tools: [], }); return { published: true }; } } ``` ```ts method.test.ts import { test, expect } from "@frontmcp/testing"; import { Plugin, create } from "@frontmcp/sdk"; import { PublishRefundPolicy } from "./refund-policy"; const info = { name: "help-desk", version: "1.0.0" }; test("🚩 without dynamicSkills, skills/list isn't found, even after the skill is added", async () => { const server = await create({ info, tools: [PublishRefundPolicy] }); const client = await server.connect(); try { await expect(client.listSkills()).rejects.toThrow("Method not found"); await client.callTool("publish_refund_policy", {}); await expect(client.listSkills()).rejects.toThrow("Method not found"); } finally { await client.close(); await server.dispose(); } }); test("✅ with a plugin that sets dynamicSkills, it lists the skill", async () => { @Plugin({ name: "policies", dynamicSkills: true }) class PoliciesPlugin {} const server = await create({ info, tools: [PublishRefundPolicy], plugins: [PoliciesPlugin] }); const client = await server.connect(); try { expect((await client.listSkills()).skills).toEqual([]); await client.callTool("publish_refund_policy", {}); expect((await client.listSkills()).skills.map((s: { id: string }) => s.id)).toEqual(["refund-policy"]); } finally { await client.close(); await server.dispose(); } }); ``` A server whose apps declare at least one skill has the skills requests anyway. ### `Cannot read properties of undefined (reading 'registerSkillContent')` A provider's factory called `scope.skills.registerSkillContent()` while the server was starting. FrontMCP builds the apps' providers before it creates the skill registry, so `scope.skills` is still `undefined`, and the server doesn't start: ```ts help-desk.app.ts active import { App, ScopeEntry } from "@frontmcp/sdk"; export class RefundPolicy {} export const refundPolicy = { name: "RefundPolicy", provide: RefundPolicy, inject: () => [ScopeEntry] as const, useFactory: async (scope: ScopeEntry) => { // 🚩 Runs before the skill registry exists await scope.skills.registerSkillContent({ id: "refund-policy", name: "refund-policy", description: "How to answer a customer who asks for a refund.", instructions: "Refunds within 30 days of the charge are automatic.", tools: [], }); return new RefundPolicy(); }, }; @App({ id: "help-desk", name: "Help Desk" }) export class HelpDeskApp {} ``` ```ts factory.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { refundPolicy } from "./help-desk.app"; test("the server doesn't start", async () => { @App({ id: "help-desk", name: "Help Desk", providers: [refundPolicy] }) class Desk {} await expect(FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] })).rejects.toThrow( `Failed to construct provider "RefundPolicy": Cannot read properties of undefined (reading 'registerSkillContent')`, ); }); ``` Add skills from code that runs once the server is up, like a tool, or declare them in the app's `skills`. ### `registerSkillContent: id "…" of skill "…" is the skill:// path of skill "…"` The new skill's `id` is the `skill://` path of another skill, so `skill:///SKILL.md` would serve one for the other: for example, `id: "triage-ticket"` with `name: "escalate"`, on a server that has a `triage-ticket` skill. Nothing is added. Give the new skill another `id`, or the same `id` as its `name`. ### A tool I registered doesn't show up in `getTools()` It has `visibility: "hidden"` or `"internal"`. `getTools(true)` includes them. --- # Server configuration > How a FrontMCP server is configured: apps, config files, environment variables, MCP revisions, background tasks, logging, tracing and request flows. Source: https://frontmcp.dev/reference/server [Covers FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) as of October 26, 2026 This section covers a FrontMCP server as a whole: how apps combine into one server, including apps loaded from npm or another MCP server, where settings come from, the MCP revisions it speaks, background tasks, logging and tracing, and the request flows hooks attach to. Read it once a server has more than one app or is about to run somewhere other than your machine; [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp) and [`@App`](https://frontmcp.dev/reference/sdk/app) list each option, and these pages show how the options work together. Start with [Apps, discovery and splitting](https://frontmcp.dev/reference/server/apps). ```ts @FrontMcp({ info, apps: [HelpDeskApp, App.esm(specifier, options?), App.remote(url, options?)], // your apps, a package's, another server's splitByApp?, logging?, observability?, metrics?, health?, tasks?, transport?: { defaultProtocolVersion? }, // background tasks; requests that name no MCP revision plugins: [ConfigPlugin.init({ schema, loadYaml: true })], // your own settings, as this.config }) ``` --- ## Composing a server | Page | Covers | | --- | --- | | [Apps, discovery and splitting](https://frontmcp.dev/reference/server/apps) | How a server is built from apps, what clients see and discover, what happens when two apps use the same name, and how `standalone` and `splitByApp` give apps endpoints and auth of their own | | [Loading apps from npm (ESM)](https://frontmcp.dev/reference/server/esm) | `App.esm()`: a package's tools, resources and prompts, loaded from npm when the server starts and run in your server's process | | [Remote servers](https://frontmcp.dev/reference/server/remote) | `App.remote()`: another MCP server mounted as an app, so its tools, resources and prompts appear on your server and calls are forwarded to it | ## Settings and environment | Page | Covers | | --- | --- | | [Configuration files](https://frontmcp.dev/reference/server/config-files) | Every place a server takes settings from: the `@FrontMcp` options, the environment variables FrontMCP reads, `.env` and YAML files through `ConfigPlugin`, and the `frontmcp.config` file the CLI reads, and which one wins | | [Environment awareness](https://frontmcp.dev/reference/server/environment) | `availableWhen`, which offers a tool, resource or prompt only on some runtimes, platforms or deployments, and `this.runtimeContext`, which branches on where the server runs | ## Talking to clients | Page | Covers | | --- | --- | | [Protocol versions](https://frontmcp.dev/reference/server/protocol-versions) | The MCP revisions the server speaks on one endpoint, from 2024-11-05 to 2026-07-28, how it picks one for each request, and what 2026-07-28 changed | | [Background tasks](https://frontmcp.dev/reference/server/tasks) | Slow tools that run as MCP tasks: the client gets a task id at once, polls it for the result, answers the tool's questions, and can cancel it | ## Watching it run | Page | Covers | | --- | --- | | [Logging](https://frontmcp.dev/reference/server/logging) | The server's own log: levels, where lines go, custom transports, request tags, and the line that matches an error ID | | [Observability and telemetry](https://frontmcp.dev/reference/server/observability) | OpenTelemetry traces for every request, your own spans and counters, `/metrics`, and health checks | ## Inside a request | Page | Covers | | --- | --- | | [Flows and stages](https://frontmcp.dev/reference/server/flows) | The named flows every request runs through, each a fixed plan of stages, the state each stage sets, how flows nest in one request, and how to write your own | ## Which page for which task | You want to | Read | | --- | --- | | Split one product into areas, like tickets and billing | [Composition patterns](https://frontmcp.dev/reference/server/apps#composition-patterns) | | Fix two apps that use the same tool name | [Handling name clashes](https://frontmcp.dev/reference/server/apps#handling-name-clashes) | | Give one app its own URL and its own auth | [Giving an app its own endpoint](https://frontmcp.dev/reference/server/apps#giving-an-app-its-own-endpoint) | | Serve another MCP server's tools next to your own | [Mounting a remote server](https://frontmcp.dev/reference/server/remote#mounting-a-remote-server) | | Add tools that a team published to npm | [Loading a package](https://frontmcp.dev/reference/server/esm#loading-a-package) | | Give the server settings of your own, read with `this.config` | [Reading your own settings](https://frontmcp.dev/reference/server/config-files#reading-your-own-settings) | | Keep those settings' defaults in a YAML file | [Keeping defaults in a YAML file](https://frontmcp.dev/reference/server/config-files#keeping-defaults-in-a-yaml-file) | | Look up an environment variable FrontMCP reads | [Environment variables](https://frontmcp.dev/reference/server/config-files#environment-variables) | | See which MCP revisions the server speaks | [Asking which revisions a server speaks](https://frontmcp.dev/reference/server/protocol-versions#asking-which-revisions-a-server-speaks) | | Serve clients that send plain JSON-RPC, with no protocol version | [Serving requests that name no revision](https://frontmcp.dev/reference/server/protocol-versions#serving-requests-that-name-no-revision) | | Let a slow tool answer at once and finish in the background | [Letting a tool run as a task](https://frontmcp.dev/reference/server/tasks#letting-a-tool-run-as-a-task) | | Offer a tool only on Node, or only in production | [Offering a tool only where it works](https://frontmcp.dev/reference/server/environment#offering-a-tool-only-where-it-works) | | Find the log line for an error ID a user reported | [Finding the line for an error ID](https://frontmcp.dev/reference/server/logging#finding-the-line-for-an-error-id) | | Send the log somewhere other than the console | [Writing a transport](https://frontmcp.dev/reference/server/logging#writing-a-transport) | | Follow a request across services with OpenTelemetry | [Following a request across services](https://frontmcp.dev/reference/server/observability#following-a-request-across-services) | | Count things and serve them to Prometheus | [Counting things](https://frontmcp.dev/reference/server/observability#counting-things) | | Pick the stage a hook should attach to | [Stages of the request flows](https://frontmcp.dev/reference/server/flows#stages-of-the-request-flows) | ## Related pages - [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp) and [`@App`](https://frontmcp.dev/reference/sdk/app): every option of the server and of an app. - [Hook decorators](https://frontmcp.dev/reference/sdk/hooks): running code before, after or around a stage of a flow. - [Health checks and metrics](https://frontmcp.dev/reference/deployment/health-and-metrics): wiring `/healthz`, `/readyz` and `/metrics` into a deployment. - [frontmcp CLI](https://frontmcp.dev/reference/cli): the commands that read `frontmcp.config`. - Learn: [Grouping Capabilities into Apps](https://frontmcp.dev/learn/grouping-capabilities-into-apps) and [Structuring a Server](https://frontmcp.dev/learn/structuring-a-server). --- # Apps, discovery and splitting > How a FrontMCP server is built from apps, what clients see and discover, what happens when two apps use the same name, and how standalone apps and splitByApp give apps endpoints and auth of their own. Source: https://frontmcp.dev/reference/server/apps [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A FrontMCP server is a list of apps. By default every app is served on one MCP endpoint, and clients see one server: every app's tools in one `tools/list`, every app's resources in one `resources/list`. This page covers how apps combine, what happens when two of them use the same name, what a client learns from `server/discover`, and how `standalone` and `splitByApp` give apps endpoints of their own, each with its own auth. [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp) and [`@App`](https://frontmcp.dev/reference/sdk/app) document each option. ```ts @FrontMcp({ info, apps: [HelpDeskApp, BillingApp], instructions?, splitByApp? }) @App({ id, name, standalone?, auth?, tools, resources, prompts }) ``` --- ## Reference ### What goes in `apps` | Entry | What it is | See | | --- | --- | --- | | An `@App` class | Your own tools, resources, prompts and providers. | [`@App`](https://frontmcp.dev/reference/sdk/app) | | `app({ ... })` | The same, built from a plain object. | [Building an app without a decorator](https://frontmcp.dev/reference/sdk/app#building-an-app-without-a-decorator) | | `App.esm(package, options?)` | An app loaded from an npm package when the server starts, and run in your process. | [Loading apps from npm](https://frontmcp.dev/reference/server/esm) | | `App.remote(url, options?)` | Another MCP server, whose tools, resources and prompts are served as an app. | [Remote servers](https://frontmcp.dev/reference/server/remote) | Anything else fails at startup with [`@FrontMcp invalid metadata for "apps"`](https://frontmcp.dev/reference/sdk/frontmcp#frontmcp-invalid-metadata-for-apps). The order of `apps` matters in two places: a clashing tool or prompt name without its prefix, and a resource URI or URI template that two apps both declare, reach the **first** app ([name clashes](https://frontmcp.dev/reference/server/apps#name-clashes)); and with `splitByApp`, the first app is the one `runStdio()` serves, and `createDirect()` and `connect()` without an `app` ([which entry points serve every endpoint](https://frontmcp.dev/reference/server/apps#which-entry-points-serve-every-endpoint)). Besides your apps' entries, clients see entries FrontMCP adds for features you turn on, like the job tools (`execute_job`, …) when an app has [jobs](https://frontmcp.dev/reference/sdk/job), and the `skill://` resources when it has [skills](https://frontmcp.dev/reference/sdk/skill). They belong to the server, not to an app. ### Composition patterns | Goal | How | | --- | --- | | Split one product into areas, like tickets and billing | One app per area, all on the main endpoint, with names that are unique across the server. | | Share a service, like a database client, between apps | Register it on `@FrontMcp({ providers })`. An app's own providers are [private to it](https://frontmcp.dev/reference/sdk/app#keeping-providers-private-to-an-app). | | Run the same behavior for every app, like auditing | A plugin on `@FrontMcp({ plugins })`. A plugin on one app hooks that app's calls, but [every app's lists](https://frontmcp.dev/reference/sdk/hooks#caveats). | | Use one app's tool from another | [`this.callTool(":")`](https://frontmcp.dev/reference/server/apps#calling-another-apps-tool). | | Different defaults for one app | App-level settings like [`output`](https://frontmcp.dev/reference/sdk/app#overriding-server-defaults-for-one-app) override the server's. | | Add another MCP server's tools | [`App.remote()`](https://frontmcp.dev/reference/server/remote). | | Reuse an app published to npm | [`App.esm()`](https://frontmcp.dev/reference/server/esm). | | Give one app its own URL and its own auth | `standalone: true` on that app. See [Endpoints](https://frontmcp.dev/reference/server/apps#endpoints-standalone-and-splitbyapp). | | One URL per product or tenant | `splitByApp: true`. | ### What clients see Clients see one list per kind for each endpoint, merged from every app served there. Nothing in a list says which app an entry comes from, unless its name was prefixed because of a clash. - Under protocol 2026-07-28, each list is **sorted by name**, whatever the order of `apps` or of each app's `tools`. - `server/discover` describes the endpoint as a whole: see [what `server/discover` returns](https://frontmcp.dev/reference/server/apps#what-serverdiscover-returns). - [`pagination`](https://frontmcp.dev/reference/sdk/frontmcp#paging-long-tool-lists) splits a long `tools/list` into pages across all the apps. #### Name clashes Clients find tools, prompts, resources and templates by name, so names on one endpoint have to differ. When two apps use the same `name`, FrontMCP keeps both entries and prefixes each name with its app's id, as `:`. The id is the app's `id`, or, when it has none, its `name` with spaces replaced by `-` (`"Billing Team"` becomes `Billing-Team:search`). Entries whose names don't clash keep them. | Clash | Listed as | What a request reaches | | --- | --- | --- | | Two apps' tools named `search` | `desk:search`, `billing:search` | Each prefixed name reaches its app. Plain `search` still works, and reaches the first app in `apps`. | | Two apps' prompts named `summarize` | `desk:summarize`, `billing:summarize` | Each prefixed name reaches its app. Plain `summarize` still works, and reaches the first app. | | Two apps' resources named `sla`, at different URIs | `desk:sla`, `billing:sla` | Each URI reaches its app. Only the names change. | | Two apps' resources at the same URI | Only the **first** app's, under its own name | The first app's. The other app's resource is neither listed nor read. | | Two apps' templates with the same `uriTemplate` | Only the **first** app's, under its own name | The first app's. | A URI can't be renamed, because it's the address clients read, so for a shared URI or URI template FrontMCP keeps the first app's entry and logs a warning that names both: ```text Resource URI "support://status" is registered by both "scope:root/app:desk:status" and "scope:root/app:billing:status"; "scope:root/app:desk:status" serves it and the other is not listed or read. Give each a distinct URI. ``` A clash is decided when the server starts, so adding an app can rename or hide entries that clients already use. Names and URIs that are unique across the server, like `search_tickets`, `search_invoices` and `desk://status`, avoid all of it. [Handling name clashes](https://frontmcp.dev/reference/server/apps#handling-name-clashes) shows each row. ### Endpoints: `standalone` and `splitByApp` An **endpoint** (FrontMCP calls it a [scope](https://frontmcp.dev/reference/sdk/scope)) is one MCP URL with its own list of entries, its own auth and its own copy of the server's providers. A server has one by default. Two options add more: | Setting | Endpoints | Where each app is served | | --- | --- | --- | | Neither (the default) | One, `root` | Every app, at the entry path: `/`, or [`http.entryPath`](https://frontmcp.dev/reference/sdk/frontmcp#http). | | `@App({ standalone: true })` | `root`, plus one per standalone app | The standalone app only at `/`. The other apps at the entry path. | | `@App({ standalone: "includeInParent" })` | `root`, plus one per such app | The app at both. | | `@FrontMcp({ splitByApp: true })` | One per app, in the order of `apps` | Each app at `/`. Nothing at the entry path itself: it answers 404. | With `splitByApp`, `standalone: true` changes nothing, and `standalone: "includeInParent"` stops the server from starting ([`standalone: includeInParent is not supported for splitByApp scope`](https://frontmcp.dev/reference/server/apps#standalone-includeinparent-is-not-supported-for-splitbyapp-scope)). ```ts main.ts @App({ id: "desk", name: "Help Desk", tools: [SearchTickets] }) class HelpDeskApp {} @App({ id: "billing", name: "Billing", tools: [GetInvoice], standalone: true, auth: { mode: "static", tokens: [process.env.BILLING_KEY!] } }) class BillingApp {} @App({ id: "status", name: "Status", tools: [GetStatus], standalone: "includeInParent" }) class StatusApp {} @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp, StatusApp], http: { entryPath: "/mcp" } }) export default class Server {} // POST /mcp → search_tickets, get_status (public) // POST /mcp/billing → get_invoice (needs the billing key) // POST /mcp/status → get_status (public) // POST /mcp/desk → 404: only standalone apps get a path ``` The Playground's client only talks to the entry path, so the other paths are checked in tests: [Giving an app its own endpoint](https://frontmcp.dev/reference/server/apps#giving-an-app-its-own-endpoint) checks the same layout with `createForGraph()`, and [the troubleshooting example](https://frontmcp.dev/reference/server/apps#app-level-auth-is-not-enforced-on-the-shared-endpoint) calls a standalone app's path through `createFetchHandler()`. #### What each endpoint gets | Setting | On the main endpoint | On an app's own endpoint | | --- | --- | --- | | `auth` | `@FrontMcp({ auth })`, for every app on it. A public, static or transparent server with an app here that has its own `auth` refuses to start; a local or remote one checks the app's grant per call only with `incrementalAuth`. See [Auth for one app](https://frontmcp.dev/reference/auth/modes#auth-for-one-app). | The app's `auth`, or, if it has none, the server's. `splitByApp` with a server-level `auth` is allowed, and gives it to every app without one. | | `@FrontMcp({ providers })` | One instance, shared by the apps on it. | A **separate** instance per endpoint: state in a server provider isn't shared between endpoints. | | `instructions` and the rest of `@FrontMcp` | As set. | The server's. | A client that connects to one endpoint sees nothing of the others: not their tools, not their instructions. #### Which entry points serve every endpoint The Node HTTP server ([`FrontMcpInstance.bootstrap()`](https://frontmcp.dev/reference/sdk/frontmcp-instance), which is what importing a `@FrontMcp` class starts, and `createHandler()`) and [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) serve every endpoint. The in-process entry points serve **one**: | Entry point | Serves | | --- | --- | | `bootstrap()`, `createHandler()`, `createFetchHandler()` | Every endpoint, at its path. | | `runStdio()` | The main endpoint: every app that isn't `standalone: true`. With `splitByApp`, or when every app is standalone, the first endpoint. | | [`createDirect()`](https://frontmcp.dev/reference/sdk/create), [`connect()`](https://frontmcp.dev/reference/sdk/connect) | The main endpoint, as `runStdio()` does. Given `{ app }`, the endpoint that app has of its own. | [`FrontMcpInstance.getPrimaryScope()`](https://frontmcp.dev/reference/sdk/frontmcp-instance) returns the main endpoint, and `getAppScope(app)` an app's own. A standalone app's endpoint, and with `splitByApp` every app's but the first, can be reached in-process only with `{ app }`. See [Some apps' tools aren't listed](https://frontmcp.dev/reference/server/apps#some-apps-tools-arent-listed). > **Note** > > Changed in 1.9.3: `createDirect()` and `connect()` take `{ app }`, and `createFetchHandler()` serves nothing at the entry path with `splitByApp`, as the Node server. Before, the in-process entry points reached the main endpoint only, and `createFetchHandler()` also served the first endpoint at the entry path when nothing else was there. > > Changed in 1.9.2: `createFetchHandler()` serves every endpoint. Before, it served only the main one, like the in-process entry points. > > Changed in 1.8.3: the one-endpoint entry points used to serve the first endpoint, which is a standalone app's whenever there is one, because endpoints of standalone apps are created before the main one. A server with a standalone app served that app alone, and none of the others. ### What `server/discover` returns A 2026-07-28 client asks the endpoint what it is with `server/discover`: | Field | What it holds | | --- | --- | | `supportedVersions` | The protocol versions FrontMCP speaks ([Protocol versions](https://frontmcp.dev/reference/server/protocol-versions)): `2026-07-28`, `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`. | | `capabilities` | What the endpoint offers. See the next table. | | `instructions` | `@FrontMcp({ instructions })`, when set, with the skill catalog appended (as set by [`skillsConfig.injectInstructions`](https://frontmcp.dev/reference/sdk/skill)) and, with [channels](https://frontmcp.dev/reference/sdk/channel), a line about them. See [giving the model instructions](https://frontmcp.dev/reference/sdk/frontmcp#giving-the-model-instructions). (Changed in 1.9.3: before, it was `instructions` alone.) | | `_meta["io.modelcontextprotocol/serverInfo"]` | `info`: `name`, `version`, and `title`, `websiteUrl` and `icons` when set. Every response carries it. | | `ttlMs`, `cacheScope` | How long, and how widely, a client may cache the answer: 300000 ms (five minutes), and `"public"` for an anonymous caller. `cacheScope` is `"private"` when the answer could differ per caller: for a caller that sent a token, a static key included, and when the server has [`authorities`](https://frontmcp.dev/learn/authorizing-calls) or a hook on `skills:filter`. Changed in 1.8.4: a caller signed in with a static key used to get `"public"`. | | Capability | When it's there | | --- | --- | | `tools: { listChanged: true }` | Always. | | `resources: { subscribe, listChanged }` | Always. Both are `true` when the endpoint has any resource (skills count), `false` otherwise. | | `prompts: { listChanged }` | Always. `true` when the endpoint has any prompt. | | `completions: {}` | When the endpoint has any prompt or resource. | | `logging: {}` | Always. | | `extensions` | Always `io.modelcontextprotocol/tasks`; also `io.modelcontextprotocol/skills` when there are skills. | | `experimental` | `io.modelcontextprotocol/skills` when there are skills, and `claude/channel` when there are [channels](https://frontmcp.dev/reference/sdk/channel) (since 1.9.3). | The capabilities describe the whole endpoint, so they can't tell a client which app has what. #### `initialize`, for older clients Clients on protocol versions before 2026-07-28 open a session with `initialize` instead. Its result has the same `capabilities` (plus `tasks` when a tool sets `execution.taskSupport`), the same `instructions`, and `info` as its `serverInfo`: `name`, `version`, and `title`, `websiteUrl` and `icons` when set. Without `info.title`, `serverInfo.title` is the name. This was checked on the Node server and through `createFetchHandler()`; the Playground speaks 2026-07-28 only. Changed in 1.9.3: before, `serverInfo.title` was always the name, and `websiteUrl` and `icons` weren't sent; `initialize` through `createFetchHandler()` carried no `instructions`. #### Caveats - The Playground and every example on this site send their requests to the entry path, `/`, of a `createFetchHandler()`, which is the main endpoint. An example with a standalone app shows the other apps' tools, not the standalone app's; its tests can reach the app's own path with a handler of their own, as in [the troubleshooting example below](https://frontmcp.dev/reference/server/apps#app-level-auth-is-not-enforced-on-the-shared-endpoint). - `@FrontMcp({ tools, resources })` are served on every endpoint, next to the apps' entries, since FrontMCP 1.9.1; before, they weren't served anywhere. A server-level tool keeps its plain name unless an app's tool has it too: then they're listed as `server:` and `:`. See [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp). --- ## Usage ### Seeing what a client discovers Two apps on one endpoint: the client gets one sorted `tools/list`, and `server/discover` describes both apps together. The help desk app has a resource, so the endpoint offers `resources` and `completions` for both. Initial call configuration: ```js { tool: "get_invoice", arguments: { id: "INV-7" } } ``` ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { BillingApp } from "./billing.app"; import { HelpDeskApp } from "./help-desk.app"; @FrontMcp({ info: { name: "support", title: "Support", version: "2.1.0" }, apps: [HelpDeskApp, BillingApp], instructions: "Help desk and billing tools. Find the customer's ticket before you look at their invoices.", }) export default class Server {} ``` ```ts help-desk.app.ts import { App, Resource, ResourceContext, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [{ id: "T-1", title: `Cannot log in (matched "${query}")` }] }; } } @Resource({ name: "open-tickets", uri: "desk://open-tickets", mimeType: "application/json" }) class OpenTickets extends ResourceContext { async execute() { return { count: 12 }; } } @App({ id: "desk", name: "Help Desk", tools: [SearchTickets], resources: [OpenTickets] }) export class HelpDeskApp {} ``` ```ts billing.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_invoice", description: "Get an invoice by id", inputSchema: { id: z.string() } }) class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, total: 120, currency: "EUR" }; } } @Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { id: z.string() } }) class RefundInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, refunded: true }; } } @App({ id: "billing", name: "Billing", tools: [GetInvoice, RefundInvoice] }) export class BillingApp {} ``` ```ts discover.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { BillingApp } from "./billing.app"; import { HelpDeskApp } from "./help-desk.app"; test("one tools/list, sorted by name", async ({ mcp }) => { const names = (await mcp.tools.list()).map((t) => t.name); expect(names).toEqual(["get_invoice", "refund_invoice", "search_tickets"]); }); test("server/discover describes the endpoint as a whole", async ({ mcp }) => { const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" })) as any; expect(result.supportedVersions[0]).toBe("2026-07-28"); expect(result.instructions).toContain("Find the customer's ticket"); expect(result.capabilities).toEqual({ tools: { listChanged: true }, resources: { subscribe: true, listChanged: true }, prompts: { listChanged: false }, completions: {}, logging: {}, extensions: { "io.modelcontextprotocol/tasks": {} }, }); expect(result._meta["io.modelcontextprotocol/serverInfo"]).toEqual({ name: "support", title: "Support", version: "2.1.0" }); expect([result.ttlMs, result.cacheScope]).toEqual([300000, "public"]); }); async function cacheScope(config: Parameters[0], headers: Record = {}) { const handler = await FrontMcpInstance.createFetchHandler(config); const response = await handler( new Request("https://support.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "server/discover", ...headers }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "server/discover", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }), }), ); return (await response.json()).result.cacheScope; } test("with authorities, the answer is cached per caller", async () => { const authorities = { profiles: { admin: { roles: { any: ["admin"] } } } }; expect(await cacheScope({ info: { name: "support", version: "2.1.0" }, apps: [HelpDeskApp, BillingApp], authorities })).toBe("private"); }); test("a caller signed in with a static key gets a private answer too", async () => { const auth = { mode: "static" as const, tokens: ["support-key"] }; const config = { info: { name: "support", version: "2.1.0" }, apps: [HelpDeskApp, BillingApp], auth }; expect(await cacheScope(config, { authorization: "Bearer support-key" })).toBe("private"); }); ``` Remove `OpenTickets` from the help desk app and run the tests again: `resources` turns to `{ subscribe: false, listChanged: false }`, and `completions` disappears. ### Handling name clashes Both apps have a `search` tool, a `summarize` prompt, an `sla` resource at a URI of their own, a `status` resource at the same URI and a `ticket` template with the same URI template. The tests show what a client sees and what each name reaches, and the **Logs** tab shows the warnings: Initial call configuration: ```js { tool: "billing:search", arguments: { query: "INV" } } ``` ```ts main.ts active import { FrontMcp } from "@frontmcp/sdk"; import { BillingApp, DeskApp } from "./apps"; @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [DeskApp, BillingApp] }) export default class Server {} ``` ```ts apps.ts import { App, Prompt, PromptContext, Resource, ResourceContext, ResourceTemplate, Tool, ToolContext, z, type GetPromptResult } from "@frontmcp/sdk"; function appEntries(app: string) { @Tool({ name: "search", description: `Search ${app}`, inputSchema: { query: z.string() } }) class Search extends ToolContext { async execute({ query }: { query: string }) { return { app, query }; } } @Prompt({ name: "summarize", description: `Summarize for ${app}` }) class Summarize extends PromptContext { async execute(): Promise { return { messages: [{ role: "user", content: { type: "text", text: `Summarize the ${app} queue.` } }] }; } } @Resource({ name: "sla", uri: `${app}://sla`, mimeType: "text/plain" }) class Sla extends ResourceContext { async execute() { return `${app} replies within a day`; } } @Resource({ name: "status", uri: "support://status", mimeType: "application/json" }) class Status extends ResourceContext { async execute() { return { app, healthy: true }; } } @ResourceTemplate({ name: "ticket", uriTemplate: "support://tickets/{id}", mimeType: "application/json" }) class Ticket extends ResourceContext<{ id: string }> { async execute(uri: string, { id }: { id: string }) { return { app, id }; } } return { tools: [Search], prompts: [Summarize], resources: [Sla, Status, Ticket] }; } @App({ id: "desk", name: "Help Desk", ...appEntries("desk") }) export class DeskApp {} @App({ id: "billing", name: "Billing", ...appEntries("billing") }) export class BillingApp {} ``` ```ts clashes.test.ts import { test, expect } from "@frontmcp/testing"; test("clashing tools are renamed; the plain name reaches the first app", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["billing:search", "desk:search"]); expect((await mcp.tools.call("billing:search", { query: "x" })).json()).toEqual({ app: "billing", query: "x" }); expect((await mcp.tools.call("search", { query: "x" })).json()).toEqual({ app: "desk", query: "x" }); }); test("clashing prompts are renamed too; each name reaches its app", async ({ mcp }) => { expect((await mcp.prompts.list()).map((p) => p.name)).toEqual(["billing:summarize", "desk:summarize"]); const text = async (name: string) => (await mcp.prompts.get(name, {})).messages[0].content; expect(await text("billing:summarize")).toEqual({ type: "text", text: "Summarize the billing queue." }); expect(await text("summarize")).toEqual({ type: "text", text: "Summarize the desk queue." }); }); test("resources with one name at different URIs: renamed, each URI reaches its app", async ({ mcp }) => { const listed = (await mcp.resources.list()).map((r) => [r.name, r.uri]); expect(listed).toContainEqual(["billing:sla", "billing://sla"]); expect(listed).toContainEqual(["desk:sla", "desk://sla"]); expect((await mcp.resources.read("billing://sla")).text()).toBe("billing replies within a day"); }); test("two resources at one URI: only the first app's is listed and read", async ({ mcp }) => { const atStatus = (await mcp.resources.list()).filter((r) => r.uri === "support://status"); expect(atStatus.map((r) => r.name)).toEqual(["status"]); expect((await mcp.resources.read("support://status")).json()).toEqual({ app: "desk", healthy: true }); }); test("two templates with one URI template: only the first app's is listed and read", async ({ mcp }) => { const listed = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "resources/templates/list", params: {} }); expect((listed.result as any).resourceTemplates.map((t: { name: string }) => t.name)).toEqual(["ticket"]); expect((await mcp.resources.read("support://tickets/T-1")).json()).toEqual({ app: "desk", id: "T-1" }); }); ``` Give entries names and URIs that are unique across the server, like `search_tickets`, `summarize_queue` and `desk://status`, and none of this happens. ### Calling another app's tool Apps don't share providers, but a tool can call any tool on its endpoint with [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool), by its plain name or as `:`. The call goes through the other tool's validation and hooks, as the same caller. Here the help desk app looks up an invoice without knowing how billing stores them: Initial call configuration: ```js { tool: "ticket_with_invoice", arguments: { id: "T-1" } } ``` ```ts help-desk.app.ts active import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; const tickets: Record = { "T-1": { title: "Invoice total is wrong", invoice: "INV-7" }, }; @Tool({ name: "ticket_with_invoice", description: "Get a ticket and the invoice it's about", inputSchema: { id: z.string() } }) class TicketWithInvoice extends ToolContext { async execute({ id }: { id: string }) { const ticket = tickets[id]; const invoice = await this.callTool("billing:get_invoice", { id: ticket.invoice }); return { id, title: ticket.title, invoice: invoice.structuredContent }; } } @App({ id: "desk", name: "Help Desk", tools: [TicketWithInvoice] }) export class HelpDeskApp {} ``` ```ts billing.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "get_invoice", description: "Get an invoice by id", inputSchema: { id: z.string().regex(/^INV-\d+$/) } }) class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, total: 120, currency: "EUR" }; } } @App({ id: "billing", name: "Billing", tools: [GetInvoice] }) export class BillingApp {} ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { BillingApp } from "./billing.app"; import { HelpDeskApp } from "./help-desk.app"; @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp] }) export default class Server {} ``` ```ts call.test.ts import { test, expect } from "@frontmcp/testing"; test("the help desk reads the invoice through billing's tool", async ({ mcp }) => { expect((await mcp.tools.call("ticket_with_invoice", { id: "T-1" })).json()).toEqual({ id: "T-1", title: "Invoice total is wrong", invoice: { id: "INV-7", total: 120, currency: "EUR" }, }); }); ``` The prefixed name keeps working if another app adds its own `get_invoice` later; the plain name would then reach whichever app comes first in `apps`. The other ways to share across apps: a provider on `@FrontMcp({ providers })` is [shared by every app](https://frontmcp.dev/reference/sdk/app#keeping-providers-private-to-an-app) on an endpoint, and a plugin on `@FrontMcp({ plugins })` [hooks every app's requests](https://frontmcp.dev/reference/sdk/plugin). ### Giving an app its own endpoint `FrontMcpInstance.createForGraph()` builds a server without serving it, `getScopes()` lists its endpoints, and `getPrimaryScope()` returns the main one. The tests check a standalone billing app with its own auth, a status app served in both places, and the same apps with `splitByApp`, all under `http.entryPath: "/mcp"`: ```ts endpoints.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { BillingApp, HelpDeskApp, StatusApp, Usage } from "./apps"; const base = { info: { name: "support", version: "1.0.0" }, http: { entryPath: "/mcp" }, instructions: "Support tools." }; async function endpoints(config: Parameters[0]) { const instance = await FrontMcpInstance.createForGraph(config); return instance.getScopes().map((scope) => ({ path: scope.fullPath, auth: scope.metadata.auth?.mode ?? "public", tools: scope.tools.getTools().map((tool) => tool.metadata.name), })); } test("standalone apps get their own endpoint, listed before the main one", async () => { expect(await endpoints({ ...base, apps: [HelpDeskApp, BillingApp, StatusApp] })).toEqual([ { path: "/mcp/billing", auth: "static", tools: ["get_invoice"] }, { path: "/mcp/status", auth: "public", tools: ["get_status"] }, { path: "/mcp", auth: "public", tools: ["search_tickets", "get_status"] }, ]); }); test("getPrimaryScope() is the main endpoint, which the one-endpoint entry points serve", async () => { const instance = await FrontMcpInstance.createForGraph({ ...base, apps: [HelpDeskApp, BillingApp] }); expect(instance.getScopes()[0].fullPath).toBe("/mcp/billing"); expect(instance.getPrimaryScope()?.fullPath).toBe("/mcp"); // No main endpoint to serve: the first one const split = await FrontMcpInstance.createForGraph({ ...base, apps: [HelpDeskApp, BillingApp], splitByApp: true }); expect(split.getPrimaryScope()?.fullPath).toBe("/mcp/desk"); const allStandalone = await FrontMcpInstance.createForGraph({ ...base, apps: [BillingApp] }); expect(allStandalone.getPrimaryScope()?.fullPath).toBe("/mcp/billing"); }); test("splitByApp: one endpoint per app; the server's auth for apps without their own", async () => { const server = { ...base, auth: { mode: "static" as const, tokens: ["support-key"] }, splitByApp: true }; expect(await endpoints({ ...server, apps: [HelpDeskApp, BillingApp] })).toEqual([ { path: "/mcp/desk", auth: "static", tools: ["search_tickets"] }, // the server's auth { path: "/mcp/billing", auth: "static", tools: ["get_invoice"] }, // its own ]); }); test("every endpoint gets the server's instructions, and its own copy of the server's providers", async () => { const instance = await FrontMcpInstance.createForGraph({ ...base, providers: [Usage], apps: [HelpDeskApp, BillingApp] }); const [billing, main] = instance.getScopes(); expect([billing.metadata.instructions, main.metadata.instructions]).toEqual(["Support tools.", "Support tools."]); expect(billing.providers.get(Usage)).not.toBe(main.providers.get(Usage)); }); test("includeInParent can't be combined with splitByApp", async () => { await expect(FrontMcpInstance.createForGraph({ ...base, apps: [StatusApp], splitByApp: true })).rejects.toThrow( "standalone: includeInParent is not supported for splitByApp scope", ); }); ``` ```ts apps.ts import { App, Provider, Tool, ToolContext, z } from "@frontmcp/sdk"; @Provider({ name: "Usage" }) export class Usage { calls = 0; } @Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [], query }; } } @Tool({ name: "get_invoice", description: "Get an invoice by id", inputSchema: { id: z.string() } }) class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, total: 120 }; } } @Tool({ name: "get_status", description: "Is the support system up?", inputSchema: {} }) class GetStatus extends ToolContext { async execute() { return { up: true }; } } @App({ id: "desk", name: "Help Desk", tools: [SearchTickets] }) export class HelpDeskApp {} @App({ id: "billing", name: "Billing", tools: [GetInvoice], standalone: true, auth: { mode: "static", tokens: ["billing-key"] } }) export class BillingApp {} @App({ id: "status", name: "Status", tools: [GetStatus], standalone: "includeInParent" }) export class StatusApp {} ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./apps"; // The Playground serves one endpoint, so it runs the help desk alone. The tests build the layouts. @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` With `bootstrap()`, each of these paths answers MCP requests; the table in [Endpoints](https://frontmcp.dev/reference/server/apps#endpoints-standalone-and-splitbyapp) shows what a client at each one gets. --- ## Troubleshooting ### Some apps' tools aren't listed The server has a standalone app, or uses `splitByApp`, and it's served by an entry point that serves only one endpoint: `runStdio()`, `createDirect()` or `connect()`. These serve the main endpoint, so a standalone app's tools aren't there; with `splitByApp`, only the first app's are. Give `createDirect()` or `connect()` the `app` whose endpoint you want. `createFetchHandler()` serves every endpoint, each at its path: ```ts one-endpoint.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { BillingApp, HelpDeskApp } from "./apps"; const info = { name: "support", version: "1.0.0" }; async function directToolNames(config: Parameters[0], endpoint?: { app: string }) { const server = await FrontMcpInstance.createDirect(config, endpoint); const { tools } = await server.listTools(); await server.dispose(); return tools.map((tool: { name: string }) => tool.name); } async function toolNames(handler: (request: Request) => Promise, path: string) { const response = await handler( new Request(`https://support.example.com${path}`, { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }), }), ); if (!response.ok) return response.status; return (await response.json()).result.tools.map((tool: { name: string }) => tool.name); } test("createDirect() with a standalone app: the other apps, or the standalone one with `app`", async () => { expect(await directToolNames({ info, apps: [HelpDeskApp, BillingApp] })).toEqual(["search_tickets"]); expect(await directToolNames({ info, apps: [HelpDeskApp, BillingApp] }, { app: "billing" })).toEqual(["get_invoice"]); }); test("createDirect() with splitByApp: the first app, or the one `app` names", async () => { expect(await directToolNames({ info, apps: [HelpDeskApp, BillingApp], splitByApp: true })).toEqual(["search_tickets"]); expect(await directToolNames({ info, apps: [HelpDeskApp, BillingApp], splitByApp: true }, { app: "billing" })).toEqual(["get_invoice"]); }); test("createFetchHandler() serves the standalone app at its own path", async () => { const handler = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDeskApp, BillingApp] }); expect(await toolNames(handler, "/")).toEqual(["search_tickets"]); expect(await toolNames(handler, "/billing")).toEqual(["get_invoice"]); expect(await toolNames(handler, "/desk")).toBe(404); }); test("createFetchHandler() with splitByApp: each app at its path, and nothing at the root", async () => { const handler = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDeskApp, BillingApp], splitByApp: true }); expect(await toolNames(handler, "/desk")).toEqual(["search_tickets"]); expect(await toolNames(handler, "/billing")).toEqual(["get_invoice"]); expect(await toolNames(handler, "/")).toBe(404); }); ``` ```ts apps.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [], query }; } } @Tool({ name: "get_invoice", description: "Get an invoice by id", inputSchema: { id: z.string() } }) class GetInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, total: 120 }; } } @App({ id: "desk", name: "Help Desk", tools: [SearchTickets] }) export class HelpDeskApp {} @App({ id: "billing", name: "Billing", tools: [GetInvoice], standalone: true }) export class BillingApp {} ``` `runStdio()` has no such option: for it, don't mark apps `standalone` and don't use `splitByApp`, or build one configuration per app that needs an endpoint of its own. `createFetchHandler()` (Cloudflare Workers, Deno, Bun) and the Node server started by `bootstrap()` serve every endpoint at its path. (Changed in 1.9.3: before, `createDirect()` and `connect()` had no `app` option either.) ### `App-level auth is not enforced on the shared endpoint` An app sets its own `auth`, but it's served on the main endpoint, where the server's `auth` applies to every app. A public or static server can't check the app's `auth` there, so instead of serving its tools to anyone, it refuses to start: ```text Invalid auth configuration: App-level auth is not enforced on the shared endpoint of a server in public mode, so the tools of billing would be served without it. Serve the app on its own endpoint with standalone: true or splitByApp: true, or run the server in local or remote mode with incrementalAuth enabled, which checks each tool call against the apps the caller has authorized (without incrementalAuth, local and remote mode do not check app grants per tool call). ``` A static server says `in static mode`, unless the app is `static` too, with the same header and scheme, and lists every one of the server's `tokens`. A transparent server refuses to start too, with `Parent uses transparent mode but apps have their own auth providers`. A local or remote server starts, and checks a call against the apps the caller authorized only with [`incrementalAuth`](https://frontmcp.dev/reference/auth/progressive); [Auth for one app](https://frontmcp.dev/reference/auth/modes#auth-for-one-app) has the whole table. The usual fix is to give the app its own endpoint, where its `auth` applies: Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "login" } } ``` ```ts main.ts active import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [], query }; } } @Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { id: z.string() } }) export class RefundInvoice extends ToolContext { async execute({ id }: { id: string }) { return { id, refunded: true }; } } @App({ id: "desk", name: "Help Desk", tools: [SearchTickets] }) export class HelpDeskApp {} // ✅ Its own endpoint, at /billing, where its auth applies @App({ id: "billing", name: "Billing", tools: [RefundInvoice], standalone: true, auth: { mode: "static", tokens: ["billing-key"] } }) export class BillingApp {} @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp] }) export default class Server {} ``` ```ts auth.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { BillingApp, HelpDeskApp, RefundInvoice } from "./main"; const info = { name: "support", version: "1.0.0" }; test("on the shared endpoint of a public server, an app's own auth stops the server from starting", async () => { // 🚩 Not standalone @App({ id: "billing", name: "Billing", tools: [RefundInvoice], auth: { mode: "static", tokens: ["billing-key"] } }) class SharedBillingApp {} await expect(FrontMcpInstance.createFetchHandler({ info, apps: [HelpDeskApp, SharedBillingApp] })).rejects.toThrow( "App-level auth is not enforced on the shared endpoint of a server in public mode, so the tools of billing would be served without it.", ); }); async function toolNames(handler: (request: Request) => Promise, path: string, token?: string) { const response = await handler( new Request(`https://support.example.com${path}`, { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list", ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }), }), ); if (!response.ok) return response.status; return (await response.json()).result.tools.map((tool: { name: string }) => tool.name); } test("standalone, the app's auth guards its own endpoint", async () => { const handler = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDeskApp, BillingApp] }); expect(await toolNames(handler, "/")).toEqual(["search_tickets"]); expect(await toolNames(handler, "/billing")).toBe(401); expect(await toolNames(handler, "/billing", "billing-key")).toEqual(["refund_invoice"]); }); ``` To protect every app the same way, put the `auth` on `@FrontMcp` instead. To let some callers use some tools on one endpoint, keep the server's `auth` and check callers per tool with [`authorities`](https://frontmcp.dev/learn/authorizing-calls). ### `Resource URI "…" is registered by both …` Two apps declare a resource with the same URI, or a template with the same `uriTemplate`. Only the first one in `apps` is listed and read; the other app's resource can't be reached, and a read of the URI returns the first app's content. A URI can only mean one thing: give each app its own scheme or path, like `desk://status` and `billing://status`. See [Handling name clashes](https://frontmcp.dev/reference/server/apps#handling-name-clashes). ### `standalone: includeInParent is not supported for splitByApp scope` The server uses `splitByApp: true`, and an app sets `standalone: "includeInParent"`. With `splitByApp`, every app already has its own endpoint and there's no main one to include it in. Remove `standalone` from the app. ### `404` or `Cannot POST /` with `splitByApp` With `splitByApp: true`, the Node server and `createFetchHandler()` serve nothing at the entry path itself: each app is at `/`, like `/mcp/billing`, and the 404's `entryPaths` lists them. Point the client at the app's path. (Changed in 1.9.3: before, `createFetchHandler()` served the first app at the entry path as well.) The same goes for a standalone app, which isn't on the main endpoint; and a non-standalone app has no path of its own (`/mcp/desk` is a 404). --- # Flows and stages > Every request FrontMCP handles runs through named flows, each a fixed plan of stages. Every flow FrontMCP runs, with its stages in order and the state each one sets, how flows nest in one request, and how to write your own. Source: https://frontmcp.dev/reference/server/flows [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 Every request FrontMCP handles runs through a **flow**: a named plan of stages. A `tools/call` runs the `tools:call-tool` flow, whose stages find the tool, check auth and limits, validate the input, run `execute()` and send the result. Flows are what [hooks](https://frontmcp.dev/reference/sdk/hooks) attach to: a hook names a flow and one of its stages. This page lists every flow FrontMCP has, with its stages and the state each one sets, shows how several flows nest inside one request, and shows how to write a flow of your own. ```ts // Hook a stage of a built-in flow (see Hook decorators) const { Will, Did, Around, Stage } = FlowHooksOf("tools:call-tool"); // Or write a flow, register it, and run it @Flow({ name, access, inputSchema, outputSchema, plan: { pre, execute, post, finalize, error } }) class MyFlow extends FlowBase { @Stage("stageName") async stageName() { /* this.input, this.state, this.respond(), this.fail() */ } } await this.scope.registryFlows(MyFlow); const output = await this.scope.runFlowForOutput(name, input); ``` --- ## Reference ### How a flow runs A flow's **plan** puts its stage names into phases, which run in this order: | Phase | Runs | When a stage answers (`respond`) | When a stage fails | | --- | --- | --- | --- | | `pre` | First. Lookups and checks: finding the tool, auth, rate limits. | The rest of `pre` and all of `execute` are skipped. `post` and `finalize` still run. | `error` runs, then `finalize`, and the request fails with the error. | | `execute` | If nothing in `pre` answered. The work itself. | The rest of `execute` is skipped. `post` and `finalize` still run. | As for `pre`. | | `post` | After `execute`, and after an answer from `pre` or `execute`. | The rest of `post` still runs, and the **last** answer is the one sent. | As for `pre`. | | `finalize` | Always, last, whether the request worked or not. | | The request fails. If it was already failing, it keeps its first error. | | `error` | Only when a stage in `pre`, `execute` or `post` fails, before `finalize`. | Ignored: the request still fails. | | Each stage runs its hooks around it: `Will` hooks, then `Around` hooks, then the stage's own step (with any `Stage` hooks beside it), then `Did` hooks. A `Did` hook runs after a stage that worked or answered, not after one that failed. [Hook decorators](https://frontmcp.dev/reference/sdk/hooks#the-order-hooks-run-in) has the order within a stage. Every run starts fresh: a new flow object, with its own `input` and an empty `state`. A hook's `ctx` **is** that object, so `ctx.state` is the state the stages share, and `ctx.respond()` answers exactly as a stage would. Changed in 1.10.10: before, the `FlowControl` signals for `respond`, `next` and `handled` captured a stack on every call. On V8 runtimes with a writable `Error.stackTraceLimit`, they now have no stack frames. `fail` and `abort` keep their stacks. These signals control the flow; let them through a `catch` that isn't meant to change the outcome. No built-in flow has `error` stages. The list flows put their lookups in `execute` and their formatting in `post`; the flows that run one entry (`tools:call-tool`, `resources:read-resource`, `prompts:get-prompt`, `agents:call-agent`, `jobs:execute-job`) have no `post`, and close with `finalize` stages instead. That's why `ctx.respond()` in a `Will("findTool")` hook ends a tool call but `finalize` still runs. ### `httpInputSchema` and `httpRequestInputSchema` Import these schemas from `@frontmcp/sdk` for a flow that accepts HTTP objects: | Schema | Field | Type | Default | Description | | --- | --- | --- | --- | --- | | Both | `request` | `Record` | Required | An object, passed through by reference. | | `httpInputSchema` | `response` | `Record` | Required | An object, passed through by reference. | | `httpInputSchema` | `next` | Function | Optional | The next middleware callback. | Both reject `null`, arrays and non-objects with an `invalid_type` issue whose `expected` is `object`. They check that the value is an object, not that it implements an HTTP interface. Changed in 1.10.10: before, parsing copied the enumerable keys of `request` and `response` into plain objects. Parsing now preserves their identity, prototypes and methods. A flow's protected `this.input.request` and `this.input.response` refer to those original objects too. See [Checking HTTP input objects](https://frontmcp.dev/reference/server/flows#checking-http-input-objects) and [Answering HTTP requests with a flow](https://frontmcp.dev/reference/server/flows#answering-http-requests-with-a-flow). ### Flows in one request One request runs several flows, one inside another. For a `tools/call` from a 2026-07-28 client over HTTP: | Flow | What it does | Where it runs | | --- | --- | --- | | `http:request` | Every HTTP request at the MCP endpoint: tracing, IP filter, server-wide rate limits, auth, then routing to a transport. | The HTTP entry points. | | `session:verify` | Checks the caller's credentials for the server's `auth` mode. | Inside `http:request`'s `checkAuthorization` stage. | | `handle:mcp-202607280728` | Reads a 2026-07-28 request (the name is spelled that way) and answers it. | Inside `http:request`'s `handleMcp2026` stage. | | `tools:call-tool` | The tool call. | Inside `handle:mcp-202607280728`'s `handleMessage` stage. | Which flows a request runs depends on how it arrives: Changed in 1.10.0: before, scopes from `create()`, `createDirect()` and `connect()` registered `http:*` flows and the default public auth's flows. They now omit `http:*` flows and, without explicit `auth`, OAuth, OAuth discovery and `session:verify` flows; manually running an omitted flow throws `FlowNotRegisteredError`. `connect()`'s CLI mode keeps its existing initialization. Configured Registry flows are still registered. [The embedding reference](https://frontmcp.dev/reference/sdk/create#what-the-server-initializes) describes what they initialize. | Request | Flows | | --- | --- | | 2026-07-28 over HTTP (`createFetchHandler()`, `bootstrap()`, the Playground) | `http:request` → `session:verify` → `handle:mcp-202607280728` → the method's flow | | Older clients over Streamable HTTP (`bootstrap()`) | `http:request` → `session:verify` → `handle:streamable-http` → the method's flow | | 2026-07-28 over stdio | `handle:mcp-202607280728` → the method's flow. `http:request` hooks never run. | | Older revisions over stdio, [`createDirect()`](https://frontmcp.dev/reference/sdk/create), [`connect()`](https://frontmcp.dev/reference/sdk/connect) | The method's flow only. `http:request` hooks never run. | | [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool) from a tool | Another `tools:call-tool`, inside the caller's `execute` stage. | | `invoke_`, a call to an [agent](https://frontmcp.dev/reference/sdk/agent) | `tools:call-tool`, then `agents:call-agent` inside its `execute` stage. | | An agent's model reading the agent's resources or prompts | `resources:list-resources` and `resources:list-resource-templates`, `resources:read-resource`, `prompts:list-prompts` or `prompts:get-prompt`, in the agent's own scope, inside `agents:call-agent`'s `execute` stage. Plugins in `@Agent({ plugins })` hook them, and the app's and the server's only with `execution.inheritPlugins`. New in 1.9.4. | | `execute_job`, `execute_workflow` | `tools:call-tool`, and [`jobs:execute-job`](https://frontmcp.dev/reference/server/flows#jobsexecute-job) once per attempt of the job, or of each step: inside the call's `execute` stage while the client waits, on its own for a run in the background. New in 1.9.4. | | `server/discover`, `initialize` | No method flow. | | A 2026-07-28 call that asks the user with [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit) | One `tools:call-tool` per round: the first ends when `execute()` asks, and `finalize` still runs. | For 2026-07-28 requests whose response is streamed (the request asks for progress or log messages), the closing stages of `http:request` and `handle:mcp-202607280728` run as soon as the stream starts, **before** the tool call has run. Don't time requests or release per-request resources in them. ### `ServerRequestTokens.stdio` New in 1.10.5. A hook on `handle:mcp-202607280728` can read `ctx.rawInput.request[ServerRequestTokens.stdio]` to tell whether a request arrived over stdio or the `dev --stdio --serve` pipe. Import `ServerRequestTokens` and the `StdioRequestOrigin` type from `@frontmcp/sdk`. HTTP requests have no stdio origin. `ServerRequestTokens.stdio` holds `StdioRequestOrigin | undefined`: | Field | Type | Description | | --- | --- | --- | | `principal` | `string` | Identifies the stdio connection. It owns the connection's tasks; it isn't a signed-in user's identity. | | `signal` | `AbortSignal` | Aborts when the client cancels this request with `notifications/cancelled`, or the connection closes. | ```ts import { DynamicPlugin, FlowHooksOf, Plugin, ServerRequestTokens, type FlowCtxOf, type StdioRequestOrigin } from "@frontmcp/sdk"; const { Will } = FlowHooksOf("handle:mcp-202607280728"); @Plugin({ name: "stdio-trace" }) export class StdioTrace extends DynamicPlugin> { @Will("handleMessage") async traceStdio(ctx: FlowCtxOf<"handle:mcp-202607280728">) { const origin = ctx.rawInput.request[ServerRequestTokens.stdio] as StdioRequestOrigin | undefined; if (origin) ctx.scopeLogger.debug(`stdio connection: ${origin.principal}`); } } ``` > **Note** > > Add `StdioTrace` to the server's `plugins`. The Playground receives HTTP requests through a fetch handler. To see a stdio origin, run the plugin in a Node server with [`runStdio()`](https://frontmcp.dev/reference/sdk/frontmcp-instance#serving-over-stdio). ### Method flows The method's flow for each request: | Request | Flow | | --- | --- | | `tools/call`, including `invoke_` and FrontMCP's job tools | `tools:call-tool`; for `invoke_` also `agents:call-agent`, and for `execute_job` and `execute_workflow` also `jobs:execute-job` | | `tools/list` | `tools:list-tools` | | `resources/read` | `resources:read-resource` | | `resources/list` | `resources:list-resources` | | `resources/templates/list` | `resources:list-resource-templates` | | `prompts/get` | `prompts:get-prompt` | | `prompts/list` | `prompts:list-prompts` | | `completion/complete` | `completion:complete` | | `skills/search`, `skills/load` | `skills:search`, `skills:load`, each with `skills:filter` inside it, which drops skills the caller may not see | | `skills/list` | `skills:filter` | | `logging/setLevel`, `resources/subscribe`, `resources/unsubscribe` (clients before 2026-07-28) | `logging:set-level`, `resources:subscribe`, `resources:unsubscribe` | | `tasks/get`, `tasks/result`, `tasks/cancel`, `tasks/list` (clients before 2026-07-28) | `tasks:get`, `tasks:result`, `tasks:cancel`, `tasks:list` | The `http:request` stage `resolveSkillsOnlyMode` reads `?mode=skills_only`, and `tools:call-tool` checks it in `checkSkillsOnlyMode`. Both stages are hookable. ### Stages of the request flows Each table lists the stages in order, by phase, and the `ctx.state` fields each stage sets. A field stays set for the rest of the flow. [Reading what the stages set](https://frontmcp.dev/reference/server/flows#reading-what-the-stages-set) shows them. #### `tools:call-tool` | Phase | Stage | Sets | | --- | --- | --- | | `pre` | `parseInput` | `input` (the request's `name`, `arguments` and `_meta`), `authInfo`, `jsonRpcRequestId`, and `progressToken` when the request has one | | | `checkSkillsOnlyMode` | Before `ensureRemoteCapabilities`, refuses external calls in skills-only mode with `TOOL_NOT_FOUND`; in-process calls still run. | | | `ensureRemoteCapabilities` | Loads the tool lists of [remote apps](https://frontmcp.dev/reference/server/remote), if any. | | | `findTool` | `tool`: the tool's entry, with `metadata` and `owner` | | | `checkPublicAccess` | Refuses an anonymous caller a tool the server's [`publicAccess`](https://frontmcp.dev/reference/auth/modes#publicaccess) doesn't list, with `PUBLIC_ACCESS_DENIED`. New in 1.9.2. | | | `checkToolAuthorization`, `checkEntryAuthorities` | Refuses callers the tool's [`authorities`](https://frontmcp.dev/learn/authorizing-calls) don't allow. | | | `createTaskIfRequested` | Starts a background task, for tools with `execution.taskSupport`. | | | `createToolCallContext` | `toolContext` (the tool instance `execute()` runs on) and `executionAbort` | | | `checkToolCredentials` | Checks the tool's `authProviders`. | | | `acquireQuota`, `acquireSemaphore` | The tool's [`rateLimit` and `concurrency`](https://frontmcp.dev/reference/sdk/guard). | | `execute` | `validateInput` | Validates the arguments into `toolContext.input`. | | | `execute` | Runs `execute()`, and puts what it returned in `toolContext.output`. | | | `validateOutput` | `rawOutput`: a copy of `toolContext.output` | | `finalize` | `releaseSemaphore`, `releaseQuota`, `applyUI` | Frees the limits, and attaches the tool's [UI](https://frontmcp.dev/reference/sdk/tool). | | | `finalize` | Checks `rawOutput` against `outputSchema` (a mismatch is `INVALID_OUTPUT`), builds the result and sends it. What a hook put in `resultMeta` is merged into the result's `_meta`, not into its data (new in 1.8.6: the [Cache plugin](https://frontmcp.dev/reference/plugins/cache) records `cache: "hit"` there). | #### `tools:list-tools`, `resources:list-resources`, `resources:list-resource-templates`, `prompts:list-prompts` The four list flows share one plan: | Phase | Stage | Sets | | --- | --- | --- | | `pre` | `parseInput` | Tools only: `authInfo`, `platformType`, and `cursor` when the request has one | | | `ensureRemoteCapabilities` | | | `execute` | `findTools`, `findResources`, `findTemplates` or `findPrompts` | `tools`, `resources`, `templates` or `prompts`: every entry on the endpoint. Tools also set `foundTools`, the same list, which the filters below leave as it is (new in 1.9.2). | | | `filterByAuthorities` | Drops the entries the caller's `authorities` don't allow, from the same field. | | | `filterByPublicAccess` | Tools and prompts only: drops what `publicAccess` doesn't offer an anonymous caller. New in 1.9.2. | | | `resolveConflicts` | `resolvedTools`, `resolvedResources`, `resolvedTemplates` or `resolvedPrompts`: the entries with [clashing names](https://frontmcp.dev/reference/server/apps#name-clashes) prefixed | | `post` | `parseTools`, `parseResources`, `parseTemplates` or `parsePrompts` | Builds the list the client gets, one page of it for [paged](https://frontmcp.dev/reference/sdk/frontmcp#paging-long-tool-lists) `tools/list`. | A hook on the `find…` stage sees and changes what every app contributes: see [Changing what clients list](https://frontmcp.dev/reference/sdk/hooks#changing-what-clients-list). #### `resources:read-resource`, `prompts:get-prompt` | Phase | Stage | Sets | | --- | --- | --- | | `pre` | `parseInput` | `input` (the request's params), `authInfo` | | | `ensureRemoteCapabilities` | | | | `findResource` / `findPrompt` | `resource` with `params` (a template's variables) and `resourceOwnerId`; or `prompt` and `promptOwnerId` | | | `checkPublicAccess` | Prompts only, as in `tools:call-tool`. New in 1.9.2. | | | `checkEntryAuthorities` | Refuses callers the entry's `authorities` don't allow. | | | `createResourceContext` / `createPromptContext` | `resourceContext`; or `parsedArgs` and `promptContext` | | `execute` | `execute` | Runs `execute()`, into `resourceContext.output` or `promptContext.output`. | | | `validateOutput` | `rawOutput`: a copy of that output | | `finalize` | `finalize` | Builds the result from `rawOutput` and sends it. | #### `agents:call-agent` | Phase | Stage | Sets | | --- | --- | --- | | `pre` | `parseInput` | `input`, `authInfo` | | | `findAgent` | `agent`: the agent's entry | | | `checkCallDepth` | Refuses a call that would nest agents deeper than `swarm.maxCallDepth` allows, with `AGENT_CALL_DEPTH_EXCEEDED`. New in 1.9.0. | | | `checkEntryAuthorities`, `checkAgentAuthorization` | Refuses callers the agent's `authorities` don't allow. | | | `createAgentContext` | `agentContext` | | | `acquireQuota`, `acquireSemaphore` | The agent's `rateLimit` and `concurrency`. | | `execute` | `validateInput`, `execute`, `validateOutput` | `executionStartedAt`, `executionMeta` (`execute`); `rawOutput` (`validateOutput`) | | `finalize` | `releaseSemaphore`, `releaseQuota`, `parseOutput`, `emitCompletion`, `finalize` | `parsedOutput` (`parseOutput`). `emitCompletion` feeds [`agent-completion` channels](https://frontmcp.dev/reference/sdk/channel). | Under `invoke_`, `checkEntryAuthorities`, `acquireQuota` and `acquireSemaphore` pass straight through: the surrounding `tools:call-tool` has already applied them, so they count once. Changed in 1.8.5: before, this flow never ran, and hooks on it never fired. #### `jobs:execute-job` One run per attempt of a [job](https://frontmcp.dev/reference/sdk/job): `execute_job` while the client waits or in the background, each retry, and each step of a [workflow](https://frontmcp.dev/reference/sdk/workflow). Its hooks are [`JobHook`](https://frontmcp.dev/reference/sdk/hooks#hooking-a-jobs-attempts). | Phase | Stage | Sets | | --- | --- | --- | | `pre` | `parseInput` | `job` (the job's entry), `input` (as given), `attempt` (1 for the first, 2 for the first retry), `authInfo`; `runId` for a run of `execute_job`, or `workflow` (`{ name, stepId, runId }`) for a workflow step | | | `checkJobAuthorization` | Refuses a caller the job's [`permissions`](https://frontmcp.dev/reference/sdk/job#permissions) don't let `execute` it, with `JOB_NOT_AUTHORIZED`. A refusal isn't retried. | | | `validateInput` | `parsedInput`: `input` checked against the job's `inputSchema`. A hook before it can replace `input`. | | | `createJobContext` | `jobContext`: the job instance `execute()` runs on. A `@Job` class's instance hooks join the run here. | | `execute` | `execute` | `output`: what `execute()` returned, or passed to `this.respond()` | | | `validateOutput` | `answer`: `{ result, logs }`, `result` checked against `outputSchema` (a mismatch is `INVALID_OUTPUT`, and isn't retried) | | `finalize` | `updateRunState` | Records the attempt on the run as `completed`, `retrying` or `failed`, and notifies. A workflow step has no run of its own: its workflow run records it. `flowError` is set when the attempt failed. | | | `finalize` | Answers with `answer`. | A hook that calls `ctx.respond({ result, logs })` before `execute` answers for the job, which doesn't run: the answer is checked against `outputSchema` before `updateRunState` records it. A mismatch is `INVALID_OUTPUT` and is not retried (changed in 1.9.5). An app's plugins hook that app's jobs; the server's plugins hook every app's. New in 1.9.4: before, jobs ran through no flow. Initial call configuration: ```js { tool: "execute_workflow", arguments: { name: "nightly-report", input: {} } } ``` ```ts attempt-log.plugin.ts active import { DynamicPlugin, FlowCtxOf, JobHook, Plugin } from "@frontmcp/sdk"; export const attempts: object[] = []; @Plugin({ name: "attempt-log" }) export class AttemptLog extends DynamicPlugin { @JobHook.Did("updateRunState") async record(ctx: FlowCtxOf<"jobs:execute-job">) { const { job, attempt, runId, workflow, answer } = ctx.state; attempts.push({ job: job?.name, attempt, hasRunId: runId !== undefined, workflow, answer }); } } ``` ```ts help-desk.app.ts import { App, Job, JobContext, Workflow, z } from "@frontmcp/sdk"; import { AttemptLog } from "./attempt-log.plugin"; @Job({ name: "count-open-tickets", inputSchema: {}, outputSchema: { open: z.number() } }) class CountOpenTickets extends JobContext { async execute() { this.log("Counted the open tickets"); return { open: 12 }; } } @Workflow({ name: "nightly-report", steps: [{ id: "count", jobName: "count-open-tickets" }] }) class NightlyReport {} @App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets], workflows: [NightlyReport], plugins: [AttemptLog] }) export class HelpDeskApp {} ``` ```ts attempts.test.ts import { test, expect } from "@frontmcp/testing"; import { attempts } from "./attempt-log.plugin"; test("execute_job's attempt has a run id and an answer", async ({ mcp }) => { attempts.length = 0; await mcp.tools.call("execute_job", { name: "count-open-tickets", input: {} }); expect(attempts).toEqual([ { job: "count-open-tickets", attempt: 1, hasRunId: true, workflow: undefined, answer: { result: { open: 12 }, logs: [expect.stringMatching(/Counted the open tickets$/)] }, }, ]); }); test("a workflow step's attempt names the step instead", async ({ mcp }) => { attempts.length = 0; const run = (await mcp.tools.call("execute_workflow", { name: "nightly-report", input: {} })).json(); expect(attempts).toMatchObject([ { job: "count-open-tickets", attempt: 1, hasRunId: false, workflow: { name: "nightly-report", stepId: "count", runId: run.runId } }, ]); }); ``` #### `completion:complete`, `skills:filter` | Flow | Stages | Sets | | --- | --- | --- | | `completion:complete` | `pre`: `parseInput`, `findReference`, `checkPublicAccess`, `checkEntryAuthorities`, `findWidgetTools`; `execute`: `complete`; `finalize`: `finalize`. `checkPublicAccess` and `checkEntryAuthorities` are new in 1.9.2. | `ref`, `argument` (`parseInput`); `prompt` or the resource (`findReference`); `output` (`complete`) | | `skills:filter` | `pre`: `parseInput`; `execute`: `filterSkills`; `finalize`: `finalize` | `skills` | #### `skills:search`, `skills:load`, `logging:set-level`, `resources:subscribe`, `resources:unsubscribe` | Flow | Stages | Sets | | --- | --- | --- | | `skills:search` | `pre`: `parseInput`; `execute`: `search`; `finalize`: `finalize` | `query`, `options` (`parseInput`); `results` (`search`); `output` (`finalize`) | | `skills:load` | `pre`: `parseInput`; `execute`: `loadSkills`, `activateSessions`; `finalize`: `finalize` | `skillIds`, `format`, `activateSession`, `policyMode`, `warnings` (`parseInput`); `loadResults` (`loadSkills`) | | `logging:set-level` | `pre`: `parseInput`; `execute`: `setLevel`; `finalize`: `finalize` | `input` (`{ level }`), `sessionId` | | `resources:subscribe` | `pre`: `parseInput`, `validateResource`; `execute`: `subscribe`; `finalize`: `finalize` | `input` (`{ uri }`), `sessionId`. `validateResource` refuses a URI `resources/read` would refuse, with the same error. | | `resources:unsubscribe` | `pre`: `parseInput`; `execute`: `unsubscribe`; `finalize`: `finalize` | `input` (`{ uri }`), `sessionId` | Changed in 1.9.3: before, these five flows were registered but never ran, so hooks on them never fired: `skills/search` and `skills/load` read the skill registry directly, and the other three were answered without a flow. [Session requests and skills](https://frontmcp.dev/reference/server/flows#session-requests-and-skills) shows them running. ### Stages of the HTTP and transport flows | Flow | `pre` | `execute` | `finalize` | Sets | | --- | --- | --- | --- | --- | | `http:request` | `traceRequest`, `checkIpFilter`, `relayToSessionOwner`, `acquireQuota`, `acquireSemaphore`, `checkAuthorization`, `acquireIdentityQuota`, `resolveSkillsOnlyMode`, `router` | `checkPersistentSessionOwner`, which answers `404` `Session not found` to anyone but the caller who opened a Durable Object's session (new in 1.8.4); then `handleMcp2026`, `handleWebFetch`, `handleLegacySse`, `handleSse`, `handleStreamableHttp`, `handleStatefulHttp`, `handleStatelessHttp`, `applyDeleteNodeHeaders`, `handleDeleteSession`: the one that matches the request answers it. `applyDeleteNodeHeaders` (new in 1.9.2) sets the session owner's `X-FrontMCP-Machine-Id` on a `DELETE` relayed to another node | `audit`, `metrics`, `releaseSemaphore`, `releaseQuota`, `finalize` | `verifyResult` (`checkAuthorization`), `intent` (`router`) | | `handle:mcp-202607280728` | `parseInput`, `validate`, `router` | `handleNotification`, `handleSubscriptions`, `handleMessage` | `cleanup` | `isAnonymous`, `version` (`validate`), `requestType` (`router`) | | `handle:streamable-http` | `applyNodeHeaders`, `parseInput`, `router` | `onInitialize`, `onMessage`, `onElicitResult`, `onSseListener`, `onExtApps` | `cleanup` | `token`, `session`, `requestType` | | `handle:legacy-sse` | `parseInput`, `router` | `onInitialize`, `onMessage`, `onElicitResult` | `cleanup` | For the old SSE transport; not shown here. | | `handle:stateless-http` | `applyNodeHeaders`, `parseInput`, `router` | `handleRequest` | `cleanup` | For stateless HTTP; not shown here. | | `session:verify` | `parseInput`, `handleStaticToken`, `handlePublicMode`, `handleAnonymousFallback`, `requireAuthorizationHeader`, `verifyIfJwt` | `deriveUser`, `parseSessionHeader`, `buildAuthorizedOutput` | | Answers from the first stage that can decide: in public mode, `handlePublicMode`. | `relayToSessionOwner` is new in 1.9.0: on a `distributed` deployment, it passes a request for a session another node owns to that node, and does nothing elsewhere (read in FrontMCP's code; it needs Redis and two nodes, so it isn't run here). `applyNodeHeaders` (new in 1.8.7) sets the `X-FrontMCP-Machine-Id` response header on a `distributed` deployment. `ctx.rawInput.request` in an `http:request` hook is the request: `method`, `path`, `headers`, `query` and the parsed `body`. [Seeing every HTTP request](https://frontmcp.dev/reference/sdk/hooks#seeing-every-http-request) uses it. ### Other flows These run for HTTP endpoints outside the MCP endpoint, or for features you turn on. Through `createFetchHandler()`, they don't run inside `http:request`. The `acquireQuota` stage after `checkIpFilter` counts the request against `throttle.global` (new in 1.9.2). | Flow | Runs for | Stages | | --- | --- | --- | | `well-known.oauth-protected-resource` | `GET /.well-known/oauth-protected-resource` | `pre`: `checkIpFilter`, `acquireQuota`, `parseInput`; `execute`: `collectData`; `post`: `validateOutput` | | `well-known.oauth-authorization-server` | `GET /.well-known/oauth-authorization-server` | `pre`: `checkIpFilter`, `acquireQuota`, `parseInput`; `execute`: `collectData` | | `well-known.jwks` | `GET /.well-known/jwks.json` | `pre`: `checkIpFilter`, `acquireQuota`, `parseInput`, `validateInput`; `execute`: `collectData` | | `well-known.mcp-registry-auth` | `GET /.well-known/mcp-registry-auth`, with `registry` and a valid public key | `pre`: `checkIpFilter`, `acquireQuota`; `execute`: `serveRecord` | | `registry:server-card` | `GET` or `OPTIONS /server-card`, with `registry.name` and the card enabled | `pre`: `checkIpFilter`, `acquireQuota`, `answerPreflight`; `execute`: `buildCard`, `respondWithCard`. `buildCard` sets `card`. | | `well-known.ai-catalog` | `GET /.well-known/ai-catalog.json`, with the registry card and catalog enabled | `pre`: `checkIpFilter`, `acquireQuota`; `execute`: `serveCatalog` | | `oauth:authorize`, `oauth:token`, `oauth:callback`, `oauth:register`, `oauth:userinfo`, `oauth:connect`, `oauth:provider-callback`, `oauth:auth-ui-extra` | FrontMCP's own OAuth endpoints (`/oauth/authorize`, `/oauth/token`, …), for `local` and `remote` auth | Each starts with `checkIpFilter`, `acquireQuota`, `parseInput`. | | `skills-http:llm-txt`, `skills-http:llm-full-txt`, `skills-http:api` | `GET /llm.txt`, `/llm_full.txt` and `/skills`, with `skillsConfig` enabled; each runs `skills:filter` | `pre`: `checkIpFilter`, `acquireQuota`, `checkEnabled` (`skills-http:api` adds `parseRequest`); `execute`: `generateContent` or `handleRequest` | | `http:ip-filter` | Custom routes in `http.routes`, and a [channel](https://frontmcp.dev/reference/sdk/channel)'s webhook path, on the Node server and in `createFetchHandler()` | `pre`: `checkIpFilter` | | `elicitation:request`, `elicitation:result` | `this.elicit()` in a session of a client before 2026-07-28, [`connect()`](https://frontmcp.dev/reference/sdk/connect#answering-the-tools-questions)'s client included, and the answer | `request`: `parseInput`, `validateRequest`; `generateElicitId`, `storePendingRecord`, `buildRequestParams`; `finalize`. `result`: `parseInput`; `lookupPending`, `validateContent`, `buildResult`, `publishResult`; `finalize` | | `tasks:get`, `tasks:result`, `tasks:cancel`, `tasks:list` | `tasks/*` requests from clients before 2026-07-28, and `tasks:cancel` for a 2026-07-28 `tasks/cancel` too (changed in 1.9.5: before, a 2026-07-28 cancel ran no flow) | `pre`: `parseInput`; `execute`: one stage (`fetchAndRespond`, `awaitTerminal`, `cancelAndRespond`, `listAndRespond`) | | `channels:send-notification`, `channels:list` | Every [channel](https://frontmcp.dev/reference/sdk/channel#hooks-on-channel-flows) event, however it's sent, and the channels a session subscribes to. New in 1.9.0: before, they never ran. | `send-notification`: `pre`: `parseInput`, `resolveMeta`; `execute`: `send`; `finalize`. `list`: `execute`: `listChannels`; `finalize` | The three registry flows are new in 1.10.0 and public, while still enforcing the IP filter and global rate limit. [MCP Registry](https://frontmcp.dev/reference/deployment/mcp-registry#serving-the-proof-and-discovery-documents) tests their responses through a fetch handler. OAuth, other well-known, custom-route and task flows aren't demonstrated on this page. The elicitation flows run for `connect()`'s in-process client, which keeps a session as older clients do. A question from a 2026-07-28 client runs neither: ```ts main.ts active import { App, DynamicPlugin, FlowHooksOf, FrontMcp, Plugin, Tool, ToolContext, z } from "@frontmcp/sdk"; export const started: string[] = []; const ElicitRequest = FlowHooksOf("elicitation:request"); const ElicitResult = FlowHooksOf("elicitation:result"); @Plugin({ name: "elicit-log" }) export class ElicitLogPlugin extends DynamicPlugin { @ElicitRequest.Will("parseInput") async asked() { started.push("elicitation:request"); } @ElicitResult.Will("parseInput") async answered() { started.push("elicitation:result"); } } @Tool({ name: "close_ticket", description: "Close a ticket once the user confirms", inputSchema: { id: z.string() } }) class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() })); return { id, status: answer.status === "accept" && answer.content?.confirm ? "closed" : "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [ElicitLogPlugin], elicitation: { enabled: true } }; @FrontMcp(config) export default class Server {} ``` ```ts elicit-flows.test.ts import { test, expect } from "@frontmcp/testing"; import { connect } from "@frontmcp/sdk"; import { config, started } from "./main"; test("connect()'s client runs both flows for a question", async () => { const client = await connect({ ...config }, { onElicitation: () => ({ action: "accept", content: { confirm: true } }) }); try { started.length = 0; expect(await client.callTool("close_ticket", { id: "T-1" })).toMatchObject({ structuredContent: { status: "closed" } }); expect(started).toEqual(["elicitation:request", "elicitation:result"]); } finally { await client.close(); } }); test("a 2026-07-28 client's question runs neither", async ({ mcp }) => { mcp.onElicitation(() => ({ action: "accept", content: { confirm: true } })); started.length = 0; expect((await mcp.tools.call("close_ticket", { id: "T-2" })).json()).toEqual({ id: "T-2", status: "closed" }); expect(started).toEqual([]); }); ``` ### Session requests and skills `logging/setLevel`, `resources/subscribe` and `resources/unsubscribe` come from clients that keep a session: clients before 2026-07-28 and [`connect()`](https://frontmcp.dev/reference/sdk/connect)'s in-process client. 2026-07-28 removed them, so they get `-32601` `Method not found: resources/subscribe was removed in protocol 2026-07-28` and run no flow. `skills/search` and `skills/load` run their flows for every client: ```ts main.ts active import { App, DynamicPlugin, FlowHooksOf, FrontMcp, Plugin, Resource, ResourceContext, Skill, Tool, ToolContext, z } from "@frontmcp/sdk"; export const started: string[] = []; const SetLevel = FlowHooksOf("logging:set-level"); const Subscribe = FlowHooksOf("resources:subscribe"); const Unsubscribe = FlowHooksOf("resources:unsubscribe"); const Search = FlowHooksOf("skills:search"); const Load = FlowHooksOf("skills:load"); @Plugin({ name: "request-log" }) export class RequestLogPlugin extends DynamicPlugin { @SetLevel.Will("parseInput") async setLevel() { started.push("logging:set-level"); } @Subscribe.Will("parseInput") async subscribe() { started.push("resources:subscribe"); } @Unsubscribe.Will("parseInput") async unsubscribe() { started.push("resources:unsubscribe"); } @Search.Will("parseInput") async search() { started.push("skills:search"); } @Load.Will("parseInput") async load() { started.push("skills:load"); } } @Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { tickets: [], query }; } } @Resource({ uri: "tickets://open", name: "open-tickets", mimeType: "application/json" }) class OpenTickets extends ResourceContext { async execute() { return { tickets: ["T-1"] }; } } @Skill({ name: "triage", description: "Triage incoming tickets", instructions: "Search for duplicates, then set a priority.", tools: [SearchTickets] }) class Triage {} @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets], resources: [OpenTickets], skills: [Triage] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [RequestLogPlugin] }; @FrontMcp(config) export default class Server {} ``` ```ts request-flows.test.ts import { test, expect } from "@frontmcp/testing"; import { connect } from "@frontmcp/sdk"; import { config, started } from "./main"; test("a session client's setLevel, subscribe and unsubscribe run their flows", async () => { const client = await connect({ ...config }); try { started.length = 0; await client.setLogLevel("info"); await client.subscribeResource("tickets://open"); await client.unsubscribeResource("tickets://open"); expect(started).toEqual(["logging:set-level", "resources:subscribe", "resources:unsubscribe"]); } finally { await client.close(); } }); test("skills/search and skills/load run their flows", async ({ mcp }) => { started.length = 0; await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/search", params: { query: "triage" } }); const loaded = await mcp.raw.request({ jsonrpc: "2.0", id: 2, method: "skills/load", params: { skillIds: ["triage"], activateSession: true } }); expect(started).toEqual(["skills:search", "skills:load"]); expect(loaded.result.skills[0].session).toEqual({ activated: false }); }); test("a 2026-07-28 client's setLevel runs none", async ({ mcp }) => { started.length = 0; const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "logging/setLevel", params: { level: "info" } }); expect(response.error).toMatchObject({ code: -32601 }); expect(started).toEqual([]); }); ``` The answers are the same as before 1.9.3, when the five requests were answered without their flows, except that `skills/load` with `activateSession: true` now gives each skill a `session`: `{ activated: false }` here, where nothing starts a skill session. ### Writing a flow A flow is a class that extends `FlowBase`, decorated with `@Flow` (also exported as `FrontMcpFlow`). Each stage is a method decorated with `Stage()` from `FlowHooksOf(name)`. | `@Flow` option | Type | Description | | --- | --- | --- | | `name` | `string` | **Required.** The flow's name, like `"desk:triage"`. | | `inputSchema` | Zod object | **Required.** Parses the input into `this.input` when a run starts. Input that doesn't match throws a `ZodError` from `runFlow()` before any stage or hook runs. | | `plan` | `FlowPlan` | **Required.** Stage names by phase: `{ pre?, execute?, post?, finalize?, error? }`. A name without a matching `@Stage` method is skipped. | | `outputSchema` | Zod schema | What `this.respond()` accepts: the answer is parsed with it, so fields it doesn't declare are dropped. Without one, `this.respond()` throws `Cannot read properties of undefined (reading 'parse')`. | | `access` | `"public" \| "authorized"` | Required by the TypeScript type, defaults to `"public"` at run time, and never read by FrontMCP. | | `middleware` | `{ path?, method?, canActivate? }` | Answer HTTP requests to `path` with this flow. See [Answering HTTP requests with a flow](https://frontmcp.dev/reference/server/flows#answering-http-requests-with-a-flow). | | `dependsOn` | `Token[]` | Providers the flow uses. Not checked for a flow you register yourself, and not needed: `this.get()` finds the server's providers either way. | | `description` | `string` | For your own documentation. | Inside a stage: | Member | Description | | --- | --- | | `this.input` | The input, parsed with `inputSchema`. `this.rawInput` is the input as it was passed. | | `this.state` | The run's state: read a field as `this.state.title`, set it with `this.state.set("title", value)`. | | `this.respond(output)` | Answers with `output`, as described in [How a flow runs](https://frontmcp.dev/reference/server/flows#how-a-flow-runs). | | `this.fail(error)` | Fails the run with `error`, exactly like throwing it. | | `this.get(token)` | A provider of the server, from `@FrontMcp({ providers })`. | | `this.scope`, `this.scopeLogger` | The endpoint the flow runs on, and its logger. | For typed `this.input`, `this.state` and stage names, add the flow to the global `ExtendFlows` interface, as in [Writing and running a flow](https://frontmcp.dev/reference/server/flows#writing-and-running-a-flow). #### Registering and running No `@FrontMcp` or `@App` option registers a flow. Register it at run time on an endpoint with `scope.registryFlows(MyFlow)`, from code that has the scope: `this.scope` in a tool, resource or prompt, or `createForGraph().getScopes()` in a script. Registering the same class again replaces the first registration. | Method | Returns | | --- | --- | | `scope.runFlowForOutput(name, input)` | The answer. A run that ends without one throws `FlowExitedWithoutOutputError` (`FLOW_EXITED_WITHOUT_OUTPUT`). | | `scope.runFlow(name, input)` | The answer, or `undefined`. | Both throw what the run fails with, and `FlowNotRegisteredError` (`Flow "desk:nope" is not registered`) for a name that isn't registered on that endpoint. A run started from a tool is part of the tool's request: `this.context` in the flow is the tool's, with the same `requestId`. #### Caveats - TypeScript rejects a plan written `as const satisfies FlowPlan`: `as const` makes the stage lists read-only, and `FlowPlan` wants plain arrays. Type the plan as `FlowPlan` instead, as in [Writing and running a flow](https://frontmcp.dev/reference/server/flows#writing-and-running-a-flow). - A flow's name must be unique on the endpoint. Registering a class with the name of a built-in flow, like `tools:list-tools`, **replaces** the built-in one. - Plugin hooks on your flow run for every run of it, whichever app the plugin is on: a flow of your own has no owner to limit them. --- ## Usage ### Seeing which flows a request runs A server plugin with a `Will` hook on the first stage of several flows records each flow as it starts, and the `librarian` agent has a plugin of its own for the reads its model makes. The tests show the nesting for each kind of request: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts flows.plugin.ts active import { AgentCallHook, DynamicPlugin, FlowHooksOf, HttpHook, JobHook, ListToolsHook, Plugin, ResourceHook, ToolHook } from "@frontmcp/sdk"; export const started: string[] = []; const SessionHook = FlowHooksOf("session:verify"); const Mcp2026Hook = FlowHooksOf("handle:mcp-202607280728"); @Plugin({ name: "flow-log" }) export class FlowLogPlugin extends DynamicPlugin { @HttpHook.Will("traceRequest") async http() { started.push("http:request"); } @SessionHook.Will("parseInput") async session() { started.push("session:verify"); } @Mcp2026Hook.Will("parseInput") async transport() { started.push("handle:mcp-202607280728"); } @ToolHook.Will("parseInput") async call() { started.push("tools:call-tool"); } @ListToolsHook.Will("parseInput") async list() { started.push("tools:list-tools"); } @AgentCallHook.Will("parseInput") async agent() { started.push("agents:call-agent"); } @JobHook.Will("parseInput") async job() { started.push("jobs:execute-job"); } @ResourceHook.Will("parseInput") async read() { started.push("resources:read-resource"); } } // On an agent: hooks the agent's own flows @Plugin({ name: "agent-flow-log" }) export class AgentFlowLogPlugin extends DynamicPlugin { @ResourceHook.Will("parseInput") async read() { started.push("resources:read-resource, in the agent"); } } ``` ```ts help-desk.app.ts import { Agent, AgentContext, App, Job, JobContext, Resource, ResourceContext, Tool, ToolContext, z } from "@frontmcp/sdk"; import { AgentFlowLogPlugin } from "./flows.plugin"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in" }; } } let syncs = 0; @Job({ name: "sync-tickets", inputSchema: {}, outputSchema: { synced: z.boolean() }, retry: { maxAttempts: 2, backoffMs: 10 } }) class SyncTickets extends JobContext { async execute() { syncs += 1; if (syncs % 2 === 1) throw new Error("The ticket store is busy"); return { synced: true }; } } @Resource({ uri: "kb://priorities", name: "priorities", mimeType: "text/plain" }) class Priorities extends ResourceContext { async execute() { return "High when the customer can't work."; } } @Tool({ name: "get_ticket_summary", description: "Summarize a ticket in one line", inputSchema: { id: z.string() } }) class GetTicketSummary extends ToolContext { async execute({ id }: { id: string }) { const ticket = await this.callTool("get_ticket", { id }); return { summary: `${id}: ${(ticket.structuredContent as { title: string }).title}` }; } } @Agent({ name: "triage", description: "Suggest a priority for a support ticket", inputSchema: { id: z.string() }, // Stands in for a real model, which the Playground can't reach llm: { adapter: { async completion() { return { content: "Priority: high", finishReason: "stop" }; } } }, }) class Triage extends AgentContext {} @Agent({ name: "librarian", description: "Answer from the help desk's notes", inputSchema: { question: z.string() }, resources: [Priorities], plugins: [AgentFlowLogPlugin], // Reads the notes, then answers llm: { adapter: { async completion(prompt) { if (prompt.messages.length === 1) return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "1", name: "read_resource", arguments: { uri: "kb://priorities" } }] }; return { content: "High", finishReason: "stop" }; }, }, }, }) class Librarian extends AgentContext {} @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, GetTicketSummary], agents: [Triage, Librarian], jobs: [SyncTickets] }) export class HelpDeskApp {} ``` ```ts main.ts import { FrontMcp } from "@frontmcp/sdk"; import { FlowLogPlugin } from "./flows.plugin"; import { HelpDeskApp } from "./help-desk.app"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [FlowLogPlugin] }) export default class Server {} ``` ```ts flows.test.ts import { test, expect } from "@frontmcp/testing"; import { started } from "./flows.plugin"; const transport = ["http:request", "session:verify", "handle:mcp-202607280728"]; test("a tool call runs the HTTP and transport flows, then tools:call-tool", async ({ mcp }) => { started.length = 0; await mcp.tools.call("get_ticket", { id: "T-1" }); expect(started).toEqual([...transport, "tools:call-tool"]); }); test("tools/list runs tools:list-tools", async ({ mcp }) => { started.length = 0; await mcp.tools.list(); expect(started).toEqual([...transport, "tools:list-tools"]); }); test("server/discover runs no method flow", async ({ mcp }) => { started.length = 0; await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" }); expect(started).toEqual(transport); }); test("this.callTool() runs a second tools:call-tool inside the first", async ({ mcp }) => { started.length = 0; await mcp.tools.call("get_ticket_summary", { id: "T-1" }); expect(started).toEqual([...transport, "tools:call-tool", "tools:call-tool"]); }); test("invoke_ runs agents:call-agent inside tools:call-tool", async ({ mcp }) => { started.length = 0; expect((await mcp.tools.call("invoke_triage", { id: "T-1" })).json()).toEqual({ response: "Priority: high" }); expect(started).toEqual([...transport, "tools:call-tool", "agents:call-agent"]); }); test("an agent's read runs in the agent, where only its own plugin hooks it", async ({ mcp }) => { started.length = 0; await mcp.tools.call("invoke_librarian", { question: "When is a ticket high?" }); expect(started).toEqual([...transport, "tools:call-tool", "agents:call-agent", "resources:read-resource, in the agent"]); }); test("execute_job runs jobs:execute-job inside tools:call-tool, once per attempt", async ({ mcp }) => { started.length = 0; await mcp.tools.call("execute_job", { name: "sync-tickets", input: {} }); expect(started).toEqual([...transport, "tools:call-tool", "jobs:execute-job", "jobs:execute-job"]); }); ``` A hook on `http:request` sees every request, but it runs before FrontMCP knows which tool is called. To act on tool calls, hook `tools:call-tool`, which runs whichever way the call arrives: over HTTP, over stdio, or from `this.callTool()`. ### Reading what the stages set `ctx.state.snapshot()` returns a copy of the state. This plugin records which fields are set after five stages of `tools:call-tool`, and after the `find…` stage of `tools:list-tools`. It also sets `resultMeta` before `finalize`, which the second test reads from the result: Initial call configuration: ```js { tool: "get_ticket", arguments: { id: "T-1" } } ``` ```ts state.plugin.ts active import { DynamicPlugin, FlowCtxOf, ListToolsHook, Plugin, ToolHook } from "@frontmcp/sdk"; export const fieldsAfter: Record = {}; function record(stage: string, state: { snapshot(): object }) { const snapshot = state.snapshot() as Record; fieldsAfter[stage] = Object.keys(snapshot).filter((key) => snapshot[key] !== undefined); } @Plugin({ name: "state-log" }) export class StateLogPlugin extends DynamicPlugin { @ToolHook.Did("parseInput") async parsed(ctx: FlowCtxOf<"tools:call-tool">) { record("parseInput", ctx.state); } @ToolHook.Did("findTool") async found(ctx: FlowCtxOf<"tools:call-tool">) { record("findTool", ctx.state); } @ToolHook.Did("createToolCallContext") async created(ctx: FlowCtxOf<"tools:call-tool">) { record("createToolCallContext", ctx.state); } @ToolHook.Did("execute") async executed(ctx: FlowCtxOf<"tools:call-tool">) { record("execute", ctx.state); } @ToolHook.Did("validateOutput") async validated(ctx: FlowCtxOf<"tools:call-tool">) { record("validateOutput", ctx.state); } @ToolHook.Will("finalize") async marked(ctx: FlowCtxOf<"tools:call-tool">) { ctx.state.set("resultMeta", { checked: true }); } @ListToolsHook.Did("findTools") async listed(ctx: FlowCtxOf<"tools:list-tools">) { record("findTools", ctx.state); } } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext, z } from "@frontmcp/sdk"; import { StateLogPlugin } from "./state.plugin"; @Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } }) class GetTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, title: "Cannot log in" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [StateLogPlugin] }) export class HelpDeskApp {} ``` ```ts state.test.ts import { test, expect } from "@frontmcp/testing"; import { fieldsAfter } from "./state.plugin"; test("each stage of tools:call-tool adds its fields", async ({ mcp }) => { await mcp.tools.call("get_ticket", { id: "T-1" }); // progressToken is there because the test client asks for progress on every call expect(fieldsAfter.parseInput).toEqual(["input", "authInfo", "progressToken", "jsonRpcRequestId"]); expect(fieldsAfter.findTool).toEqual([...fieldsAfter.parseInput, "tool"]); expect(fieldsAfter.createToolCallContext).toEqual([...fieldsAfter.findTool, "executionAbort", "toolContext"]); expect(fieldsAfter.execute).toEqual(fieldsAfter.createToolCallContext); // the result is in toolContext.output expect(fieldsAfter.validateOutput).toEqual([...fieldsAfter.execute, "rawOutput"]); }); test("resultMeta goes into the result's _meta, and the data stays as the tool returned it", async ({ mcp }) => { const result = await mcp.tools.call("get_ticket", { id: "T-1" }); expect(result.raw._meta?.checked).toBe(true); expect(result.json()).toEqual({ id: "T-1", title: "Cannot log in" }); }); test("findTools sets the list of every app's tools", async ({ mcp }) => { await mcp.tools.list(); expect(fieldsAfter.findTools).toEqual(["authInfo", "platformType", "tools", "foundTools"]); }); ``` Read fields by name, like `ctx.state.tool` or `ctx.state.toolContext`, rather than from a snapshot: a snapshot is a copy, and changing it changes nothing. ### Writing and running a flow A triage flow for tickets: `loadTicket` answers at once for closed tickets and fails for unknown ones, `classify` picks a priority, and `answer` sends it. `audit`, in `finalize`, runs every time, and `onError` only when a stage failed. A tool registers the flow the first time it's called, then runs it, and a plugin hooks one of its stages like any other flow's: Initial call configuration: ```js { tool: "triage_ticket", arguments: { id: "T-1" } } ``` ```ts triage.flow.ts active import { Flow, FlowBase, FlowHooksOf, PublicMcpError, z, type FlowPlan, type FlowRunOptions } from "@frontmcp/sdk"; const name = "desk:triage" as const; const inputSchema = z.object({ id: z.string() }); const outputSchema = z.object({ id: z.string(), priority: z.enum(["low", "high", "none"]) }); const stateSchema = z.object({ title: z.string(), vip: z.boolean(), priority: z.enum(["low", "high"]) }); type Stages = "loadTicket" | "classify" | "answer" | "audit" | "onError"; const plan: FlowPlan = { pre: ["loadTicket"], execute: ["classify"], post: ["answer"], finalize: ["audit"], error: ["onError"], }; // Makes the name known to FlowHooksOf, FlowCtxOf and runFlowForOutput, with typed input and state declare global { interface ExtendFlows { "desk:triage": FlowRunOptions; } } const tickets: Record = { "T-1": { title: "Cannot log in", status: "open", customer: "globex" }, "T-2": { title: "Printer is slow", status: "open", customer: "globex" }, "T-3": { title: "Old invoice", status: "closed", customer: "globex" }, "T-4": { title: "Printer is slow", status: "open", customer: "acme" }, }; export const auditLog: string[] = []; const { Stage } = FlowHooksOf(name); @Flow({ name, access: "authorized", inputSchema, outputSchema, plan }) export class TriageFlow extends FlowBase { private steps: string[] = []; @Stage("loadTicket") async loadTicket() { this.steps.push("loadTicket"); const ticket = tickets[this.input.id]; if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${this.input.id}.`)); if (ticket.status === "closed") this.respond({ id: this.input.id, priority: "none" }); this.state.set({ title: ticket.title, vip: ticket.customer === "acme" }); } @Stage("classify") async classify() { this.steps.push("classify"); this.state.set("priority", /log in|password/i.test(this.state.title ?? "") ? "high" : "low"); } @Stage("answer") async answer() { this.steps.push("answer"); if (!this.state.priority) return; // post runs after an early answer too this.respond({ id: this.input.id, priority: this.state.priority }); } @Stage("onError") async onError() { this.steps.push("onError"); } @Stage("audit") async audit() { auditLog.push(`${this.input.id}: ${this.steps.join(" > ")}`); } } ``` ```ts triage.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { TriageFlow } from "./triage.flow"; let registered: Promise | undefined; @Tool({ name: "triage_ticket", description: "Suggest a priority for a support ticket", inputSchema: { id: z.string() } }) export class TriageTicket extends ToolContext { async execute({ id }: { id: string }) { registered ??= this.scope.registryFlows(TriageFlow); await registered; return this.scope.runFlowForOutput("desk:triage", { id }); } } ``` ```ts vip.plugin.ts import { DynamicPlugin, FlowCtxOf, FlowHooksOf, Plugin } from "@frontmcp/sdk"; const TriageHook = FlowHooksOf("desk:triage"); @Plugin({ name: "vip", description: "Raises every VIP customer's ticket to high priority" }) export class VipPlugin extends DynamicPlugin { @TriageHook.Did("classify") async raise(ctx: FlowCtxOf<"desk:triage">) { if (ctx.state.vip) ctx.state.set("priority", "high"); } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { TriageTicket } from "./triage.tool"; import { VipPlugin } from "./vip.plugin"; @App({ id: "help-desk", name: "Help Desk", tools: [TriageTicket], plugins: [VipPlugin] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts triage.test.ts import { test, expect } from "@frontmcp/testing"; import { auditLog } from "./triage.flow"; test("every phase runs, in order, and finalize last", async ({ mcp }) => { expect((await mcp.tools.call("triage_ticket", { id: "T-2" })).json()).toEqual({ id: "T-2", priority: "low" }); expect(auditLog.at(-1)).toBe("T-2: loadTicket > classify > answer"); }); test("a plugin's Did hook changes the state the next stage reads", async ({ mcp }) => { // Same title as T-2, but acme is a VIP customer expect((await mcp.tools.call("triage_ticket", { id: "T-4" })).json()).toEqual({ id: "T-4", priority: "high" }); }); test("an answer in pre skips execute; post and finalize still run", async ({ mcp }) => { expect((await mcp.tools.call("triage_ticket", { id: "T-3" })).json()).toEqual({ id: "T-3", priority: "none" }); expect(auditLog.at(-1)).toBe("T-3: loadTicket > answer"); }); test("a failure runs the error phase, then finalize, and fails the call", async ({ mcp }) => { const result = await mcp.tools.call("triage_ticket", { id: "T-9" }); expect(result).toBeError(); expect(result.text()).toBe("There's no ticket T-9."); expect(auditLog.at(-1)).toBe("T-9: loadTicket > onError"); }); ``` `answer` checks for a priority because `post` runs after the early answer for `T-3` too, and a second `this.respond()` there would replace it. The plugin is on the same app as the tool here, but it doesn't have to be: hooks on a flow of your own run whichever app's plugin declares them. ### Checking HTTP input objects The parsed request and response are the objects you supplied. The tests use objects with a prototype method to check that parsing keeps it, and check the values the schemas refuse: ```ts http-input.test.ts active import { test, expect } from "@frontmcp/testing"; import { httpInputSchema, httpRequestInputSchema } from "@frontmcp/sdk"; class HttpObject { header() { return "application/json"; } } test("parsing keeps the request and response objects and their methods", async () => { const request = new HttpObject(); const response = new HttpObject(); const parsed = httpInputSchema.parse({ request, response }); expect(parsed.request).toBe(request); expect(parsed.response).toBe(response); expect((parsed.request as HttpObject).header()).toBe("application/json"); expect(httpRequestInputSchema.parse({ request }).request).toBe(request); }); test("both schemas reject values that aren't objects", async () => { for (const value of [null, [], "request", 42, undefined]) { const inputs = [ httpInputSchema.safeParse({ request: value, response: {} }), httpInputSchema.safeParse({ request: {}, response: value }), httpRequestInputSchema.safeParse({ request: value }), ]; for (const parsed of inputs) { expect(parsed.success).toBe(false); if (!parsed.success) { expect(parsed.error.issues[0]).toMatchObject({ code: "invalid_type", expected: "object" }); } } } }); ``` **Hidden fixture/test file (runs with the example):** ```ts main.ts hidden import { App } from "@frontmcp/sdk"; @App({ name: "HTTP input checks" }) export class ChecksApp {} ``` ### Answering HTTP requests with a flow A flow with `middleware: { method, path }` answers HTTP requests to that path, once it's registered. Its input is the request, and its output an HTTP response: `httpRespond.json(body)`, `httpRespond.ok(text)` and others build one. A tool registers this one here, so the endpoint appears after the first call: Initial call configuration: ```js { tool: "enable_exports", arguments: {} } ``` ```ts export.flow.ts active import { Flow, FlowBase, FlowHooksOf, httpInputSchema, httpOutputSchema, httpRespond, z, type FlowPlan, type FlowRunOptions } from "@frontmcp/sdk"; const name = "desk:export" as const; const stateSchema = z.object({}); const plan: FlowPlan<"serve"> = { execute: ["serve"] }; declare global { interface ExtendFlows { "desk:export": FlowRunOptions; } } const { Stage } = FlowHooksOf(name); @Flow({ name, access: "public", inputSchema: httpInputSchema, outputSchema: httpOutputSchema, plan, middleware: { method: "GET", path: "/exports" }, }) export class ExportFlow extends FlowBase { @Stage("serve") async serve() { const { query } = this.input.request as { query: Record }; this.respond(httpRespond.json({ format: query.format ?? "csv", tickets: ["T-1", "T-2"] })); } } ``` ```ts help-desk.app.ts import { App, Tool, ToolContext } from "@frontmcp/sdk"; import { ExportFlow } from "./export.flow"; @Tool({ name: "enable_exports", description: "Turn on the /exports endpoint", inputSchema: {} }) class EnableExports extends ToolContext { async execute() { await this.scope.registryFlows(ExportFlow); return { enabled: "/exports" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [EnableExports] }) export class HelpDeskApp {} ``` ```ts export.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; const handler = FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); const send = (path: string, init?: RequestInit) => handler.then((h) => h(new Request(`https://desk.example.com${path}`, init))); const callTool = (name: string) => send("/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); test("GET /exports answers once the flow is registered", async () => { expect((await send("/exports?format=json")).status).toBe(404); expect((await callTool("enable_exports")).status).toBe(200); const response = await send("/exports?format=json"); expect(response.status).toBe(200); expect(await response.json()).toEqual({ format: "json", tickets: ["T-1", "T-2"] }); }); ``` With the Node server, the flow is mounted as an Express route on the same port. Because nothing registers a flow at startup, prefer [`http.routes`](https://frontmcp.dev/reference/sdk/frontmcp#http) for an endpoint that must exist from the first request; a flow is for an endpoint that should run hooks, or that code turns on later. --- ## Troubleshooting ### My hook on a flow never runs Check that the request reaches the flow: stdio, `createDirect()` and `connect()` skip `http:request`, and a 2026-07-28 `resources/subscribe` or `logging/setLevel` never gets past the transport ([Session requests and skills](https://frontmcp.dev/reference/server/flows#session-requests-and-skills)). Before 1.9.3, `logging:set-level`, `resources:subscribe`, `resources:unsubscribe`, `skills:search` and `skills:load` never ran at all. [Hook decorators](https://frontmcp.dev/reference/sdk/hooks#my-hook-never-runs) lists the other reasons. ### `Flow "desk:triage" is not registered` `runFlow()` or `runFlowForOutput()` was called with a name that isn't registered on that endpoint: the flow wasn't registered yet, its `name` is spelled differently, or it was registered on another endpoint (each [standalone app](https://frontmcp.dev/reference/server/apps#endpoints-standalone-and-splitbyapp) is one). Register it with `scope.registryFlows()` first, on the same scope. ### `Flow exited without producing output` `runFlowForOutput()` ran the flow and no stage called `this.respond()`. Check that the stage that answers is in `plan` and has a `@Stage()` decorator with the same name: a plan entry without one is skipped without a warning. Use `runFlow()` if the flow isn't meant to answer. ### `Invalid input: expected Object, received undefined` from `@Flow` The `path` in the error is `inputSchema`: every flow needs one, even a flow that takes no input (`z.object({})`). The error comes when the class is decorated, so importing the file is enough to throw it. ### `Cannot read properties of undefined (reading 'parse')` from `this.respond()` The flow has no `outputSchema`, and `respond()` parses the answer with it. Add one, like `z.object({ ... })`. ### TypeScript says `Argument of type '"desk:triage"' is not assignable to parameter of type 'keyof ExtendFlows'` `FlowHooksOf()`, `FlowCtxOf`, `FlowBase` and `@Flow` only accept names in the global `ExtendFlows` interface. Add yours with `declare global { interface ExtendFlows { ... } }`, as in [Writing and running a flow](https://frontmcp.dev/reference/server/flows#writing-and-running-a-flow). Every built-in flow is in it, all 49 of them, `jobs:execute-job` (new in 1.9.4) and the three Registry flows (new in 1.10.0) included. (Changed in 1.9.3: before, 19 were missing, `session:verify`, `handle:mcp-202607280728`, `handle:streamable-http`, `elicitation:request` and `elicitation:result`, `well-known.jwks` and `http:ip-filter` among them, and code wrote `FlowHooksOf("…")` with a `ctx` type of its own. `prompts:get-prompt`, `prompts:list-prompts`, `completion:complete` and the channel flows are typed since 1.9.0, with `PromptHook`, `ListPromptsHook` and `CompletionHook` exported for them.) --- # Loading apps from npm (ESM) > App.esm() loads a package's tools, resources and prompts from npm when the server starts, and runs them in your server's process. Source: https://frontmcp.dev/reference/server/esm [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `App.esm()` adds an app whose tools, resources and prompts come from an npm package. When the server starts, FrontMCP asks the npm registry which version matches the range you gave, downloads that version as a JavaScript bundle from esm.sh, and runs it in your server's process. The package's tools then sit next to your own, under a namespace. A team can publish tools once and add them to any server without rebuilding it. The flip side is that the package's code runs with your server's permissions, so load only packages you trust. ```ts App.esm(specifier, options?) ``` --- ## Reference ### `App.esm(specifier, options?)` List the result in `@FrontMcp({ apps })`, next to your own apps. ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [ HelpDeskApp, App.esm("@acme/status-tools@^1.0.0", { namespace: "status" }), ], }) export default class Server {} ``` [See more examples below.](https://frontmcp.dev/reference/server/esm#usage) #### Parameters | Parameter | Type | Description | | --- | --- | --- | | `specifier` | `string` | A package name, optionally followed by `@` and a version: an exact version (`1.2.0`), a semver range (`^1.0.0`, `~1.2.0`, `>=1.0.0 <2.0.0`) or a dist-tag (`next`). Without a version, `latest`. Names follow npm's rules, lowercase and optionally scoped; anything else, a URL included, makes `App.esm()` throw `Invalid package specifier: "…"`. See [choosing a version](https://frontmcp.dev/reference/server/esm#choosing-a-version). | | `options` | `EsmAppOptions` | Optional. See below. | #### Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `namespace` | `string` | `name` | Prefix for every tool, resource and prompt name: `status` turns `get_status` into `status:get_status`. Set it. The default is the package name, which gives names like `@acme/status-tools:get_status`. | | `name` | `string` | The package name | The app's name in logs, and the default namespace. The app's id is the `name` you set, else the `namespace`, else the package name. Two apps with one id stop the server from starting, with `DuplicateAppIdError`, so give every `App.esm()` of one package its own `namespace`: see [choosing a version](https://frontmcp.dev/reference/server/esm#choosing-a-version). | | `description` | `string` | | What the app is for, for your own tooling. | | `loader` | `PackageLoader` | The server's `loader` | Where to get this package from, and with what token: the same fields as [the server's `loader`](https://frontmcp.dev/reference/server/esm#the-loader). It **replaces** the server's loader for this package; the two aren't merged, so a `token` set on the server isn't sent. | | `cacheTTL` | `number` | `86400000` (24 hours) | Milliseconds a downloaded bundle is reused before it's downloaded again. Must be more than `0`. See [caching](https://frontmcp.dev/reference/server/esm#caching). | | `autoUpdate` | `{ enabled: boolean; intervalMs?: number }` | Off | Check the registry every `intervalMs` (default `300000`, 5 minutes) for a newer version in the range, and load it without a restart. See [updating without a restart](https://frontmcp.dev/reference/server/esm#updating-without-a-restart). | | `standalone` | `boolean \| "includeInParent"` | `false` | Serve the app on its own path, as for [`@App`](https://frontmcp.dev/reference/sdk/app#standalone-apps). The path is `/` and the `namespace`, like `/status`, unless you set a `name`, which takes its place. With neither, it's the package name: `/@acme/status-tools`. | | `filter` | `AppFilterConfig` | Load everything | Which of the package's tools, resources and prompts to load, by their names without the namespace. `*` matches any run of characters. `{ exclude: { tools: ["delete_*"] } }` leaves those out; `{ default: "exclude", include: { tools: ["get_*"] } }` loads only those. An entry left out isn't listed, and calling it gives `Tool "…" not found`. See [loading part of a package](https://frontmcp.dev/reference/server/esm#loading-part-of-a-package). | | `importMap` | `Record` | | Imports in the bundle to point elsewhere: `{ "": "" }`. A key ending in `/` covers every specifier that starts with it. FrontMCP asks the bundle server to leave those packages out of the bundle, rewrites the bundle's imports, and caches the result apart from a bundle loaded with another map. See [rewriting a package's imports](https://frontmcp.dev/reference/server/esm#rewriting-a-packages-imports). | #### Returns A plain object that describes the app, not a class: `{ name, urlType: "esm", url: specifier, namespace, description, standalone, filter }`, with an `id` taken from `namespace` when you set one and no `name`, plus `packageConfig: { loader, autoUpdate, cacheTTL, importMap }` with the ones you set. Nothing is fetched until the server starts. #### The loader `@FrontMcp({ loader })` says where every `App.esm()` package comes from, unless the app sets its own `loader`: ```ts main.ts @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, loader: { registryUrl: "https://npm.acme.example", url: "https://esm.acme.example", tokenEnvVar: "ACME_NPM_TOKEN", }, apps: [App.esm("@acme/internal-tools@^1.0.0", { namespace: "internal" })], }) export default class Server {} ``` | Field | Default | What it does | | --- | --- | --- | | `url` | | One server for both jobs: versions are resolved at `/` and bundles downloaded from `/@?bundle`, so it must answer both, as a self-hosted esm.sh in front of a registry would. | | `registryUrl` | `url`, else `https://registry.npmjs.org` | Where versions are resolved. Bundles still come from `url`, or `https://esm.sh` without one. | | `token` | | Sent as `Authorization: Bearer ` to the registry. It isn't sent when the bundle is downloaded. | | `tokenEnvVar` | | The name of an environment variable that holds the token, read when the package loads. If the variable isn't set, the server doesn't start. When both are set, `token` wins. | `url` and `registryUrl` must be full URLs, like `https://npm.acme.example`; the server doesn't start otherwise (`Invalid URL`). See [using a private registry](https://frontmcp.dev/reference/server/esm#using-a-private-registry). #### How a package is loaded When the server starts, for each `App.esm()` app, FrontMCP: 1. **Resolves the version.** It sends `GET /` (the `/` of a scoped name as `%2f`) and picks one version from the answer: for `latest` or another dist-tag, the version the tag points to; for a range, the highest published version that satisfies it. A word that isn't one of the package's dist-tags, like a misspelled `stabel`, isn't an error: FrontMCP loads the highest published version, prereleases included. It gives up after 15 seconds. 2. **Checks its cache** for that exact `name@version`. See [caching](https://frontmcp.dev/reference/server/esm#caching). 3. **Downloads the bundle** on a miss: `GET /@?bundle`, from esm.sh unless the loader says otherwise, with `&external=` for the packages in `importMap`. It gives up after 30 seconds. Neither time limit can be changed. 4. **Runs the bundle** with a dynamic `import()`. A CommonJS bundle (`module.exports = …`) works too. 5. **Reads the package's exports** (see [what a package exports](https://frontmcp.dev/reference/server/esm#what-a-package-exports)) and registers its tools, resources and prompts in the app, under the namespace: all of them, or the ones `filter` lets through. If any step fails, the server doesn't start, with one of the [errors below](https://frontmcp.dev/reference/server/esm#errors). The registry is asked on every start, even when the bundle is cached, so a server that loads packages can't start while the registry is unreachable. #### What a package exports FrontMCP looks for, in this order: 1. A default export with string `name` and `version` fields: a **manifest**, `{ name, version, description?, tools?, resources?, prompts? }`. It's the clearest form. 2. A default export that's an object of arrays (`{ tools, resources, prompts }`), or of `@Tool`, `@Resource` and `@Prompt` classes, or is one such class. 3. Named exports, the same three ways: `export const name`, `version` and `tools`; `export const tools = […]` alone; or exported decorated classes. Anything else, a default-exported `@App` or `@FrontMcp` class included, stops the server with `Invalid manifest in ESM package "…": ESM module does not export a valid FrontMcpPackageManifest.` A tool, resource or prompt can be a decorated class, which works like one in your own code, or a plain object: | Entry | Fields | Notes | | --- | --- | --- | | Tool | `name`, `execute(input)`, and optionally `description`, `inputSchema` (a JSON Schema object) | `execute()` should return a `CallToolResult`, `{ content: [...] }`, which is sent as it is. A string comes back as its text. Any other object comes back as the text `[object Object]`. The arguments aren't checked against `inputSchema`: `execute()` gets what the client sent. Without a description, the model sees `ESM tool: `. | | Resource | `name`, `uri`, `read()`, and optionally `description`, `mimeType` | `read()` returns a `ReadResourceResult`, `{ contents: [...] }`. The URI isn't namespaced. Without a description: `ESM resource: `. | | Prompt | `name`, `execute(args)`, and optionally `description`, `arguments` | `execute()` returns a `GetPromptResult`, `{ messages: [...] }`. Required `arguments` are checked before it runs. Without a description: `ESM prompt: `. | An entry without a `name` or without its function, or a resource whose `uri` has no scheme, is skipped without a word. A manifest can also list `skills`, `agents`, `jobs`, `workflows` and `providers`; FrontMCP accepts them and loads none of them, and the server logs a warning that names the kinds it skipped: `ESM app status: App.esm() loads only tools, resources and prompts; the package's skills, agents are not loaded`. (Changed in 1.9.3: before, it skipped them without a word.) #### Caching In Node, FrontMCP keeps each downloaded bundle in memory and on disk, one folder per `name@version` holding the bundle and a `meta.json`. The folder is `node_modules/.cache/frontmcp-esm/` in the working directory when it has a `node_modules` folder, and `~/.frontmcp/esm-cache/` otherwise. A later start with the same version reads the bundle from disk instead of downloading it, until it's `cacheTTL` old. In a browser, the cache is memory only, so each start downloads again. The cache key is only `name@version`: a bundle cached from one loader is reused when another loader resolves the same version. The version itself is always resolved with the registry, as above. #### Updating without a restart With `autoUpdate: { enabled: true }`, FrontMCP asks the registry every `intervalMs` which version the range resolves to now. When it's newer than the loaded one, FrontMCP downloads it and swaps the app's tools, resources and prompts for the new version's: tools that the new version dropped disappear, new ones appear, and calls reach the new code. A version outside the range, like `2.0.0` for `^1.0.0`, is never loaded. Clients connected with a session, over the protocol versions before 2026-07-28, get `notifications/tools/list_changed`; 2026-07-28 clients see the change the next time they list. A failed check is logged and tried again at the next interval. A failed download is logged too, and the old version keeps serving, but FrontMCP doesn't try that version again: it's loaded when a newer one is published, or at the next restart. ```ts main.ts App.esm("@acme/status-tools@^1.0.0", { namespace: "status", autoUpdate: { enabled: true, intervalMs: 10 * 60_000 }, }) ``` This was checked in Node against a stand-in registry: publishing `1.1.0` replaced `1.0.0`'s tools within one interval, and publishing `2.0.0` changed nothing. The examples on this page don't use it, because the check keeps running for as long as the server does. #### Running someone else's code A package loaded with `App.esm()` is code running inside your server. In Node it can read your environment variables, including secrets, open files and make network requests, with your server's permissions. So: - **Load only packages you trust**, from publishers you trust. The code FrontMCP runs is esm.sh's build of the package, not the tarball on npm, so you're trusting esm.sh too, or whoever serves your `loader.url`. - **Pin exact versions** for code you don't control. With a range, a new release is loaded at the next restart, without a review or a deploy on your side. With `autoUpdate`, it's loaded without a restart either. - FrontMCP doesn't check the bundle against the registry's integrity hash, or at all. - A private registry or mirror, with [`loader`](https://frontmcp.dev/reference/server/esm#the-loader), lets you decide which versions exist. #### Errors A package that can't be loaded stops the server with one of these classes, all exported by `@frontmcp/sdk`. Each keeps the cause's message after its own, and the cause itself in `originalError` (except the manifest and specifier errors). | Class | `code` | When | Message | | --- | --- | --- | --- | | `EsmInvalidSpecifierError` | `ESM_INVALID_SPECIFIER` | The specifier isn't a package name. Thrown by `App.esm()` itself. | `Invalid package specifier: "…"` | | `EsmRegistryAuthError` | `ESM_REGISTRY_AUTH_ERROR` | The registry answered `401` or `403`. | `Authentication failed for npm registry: Registry returned 401 for "…"` | | `EsmVersionResolutionError` | `ESM_VERSION_RESOLUTION_ERROR` | Any other failure while resolving the version: an unknown package, an unreachable registry, a range nothing satisfies, an unset `tokenEnvVar`. | `Failed to resolve version for "@": ` | | `EsmPackageLoadError` | `ESM_PACKAGE_LOAD_ERROR` | The bundle couldn't be downloaded or run. | `Failed to load ESM package ""@: ` | | `EsmManifestInvalidError` | `ESM_MANIFEST_INVALID` | The bundle ran, but exports nothing FrontMCP can use. | `Invalid manifest in ESM package "…": ESM module does not export a valid FrontMcpPackageManifest. …` | | `EsmCacheError` | `ESM_CACHE_ERROR` | A cached bundle couldn't be read or written. | `ESM cache failed for "…": ` | [When a package can't be loaded](https://frontmcp.dev/reference/server/esm#when-a-package-cant-be-loaded) and [using a private registry](https://frontmcp.dev/reference/server/esm#using-a-private-registry) show all but the cache error. `Tool.esm()` and the other single-entry loaders wrap them in [`ExternalEntryLoadError`](https://frontmcp.dev/reference/server/esm#loading-one-tool-from-a-package). #### Caveats - Every package loads at startup. If one can't be loaded, the server doesn't start: [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) rejects with the loader's error, as `createDirect()` does. On an edge runtime, like Cloudflare Workers, the server starts on the first request instead. Until it starts, every request gets `503` `{"error":"server_unavailable","code":"SERVER_START_FAILED"}` with a `Retry-After` header, and FrontMCP tries again only after that delay, which grows from a second to a minute. Changed in 1.8.4: before, `createFetchHandler()` always waited for the first request. Changed in 1.8.5: on an edge runtime, that request used to throw, and the next one tried again at once. - ESM apps have tools, resources and prompts only: no plugins, adapters or providers of their own, and the package's skills, agents, jobs and workflows aren't loaded. The server logs a warning when a manifest has them. - To load one tool, resource or prompt instead of the whole package, use [`Tool.esm()`](https://frontmcp.dev/reference/server/esm#loading-one-tool-from-a-package), `Resource.esm()` or `Prompt.esm()` in an app's lists. `Agent.esm()`, `Skill.esm()` and `Job.esm()` stop the server from starting. --- ## Usage > **Note** > > The examples on this page don't reach the npm registry or esm.sh: each has a `npm.example.ts` file that plays both. It answers FrontMCP's requests in-process, and records them so tests can check them; `publish()` puts a package version "on npm". Everything else is the real loader. You won't need the file in a real server. > > In the Playground, bundles are cached in memory only, and a package can only import what an [`importMap`](https://frontmcp.dev/reference/server/esm#rewriting-a-packages-imports) points at, which can't be the Playground's own `@frontmcp/sdk`, so packages here don't use decorated classes. `autoUpdate` isn't shown, because its checks never stop. Those were checked in Node. ### Loading a package `packages.ts` publishes three versions of `@acme/status-tools`. The server asks for `^1.0.0`, so FrontMCP resolves it to `1.2.0`, the highest 1.x, downloads that version's bundle, and lists its tool under the `status` namespace. Initial call configuration: ```js { tool: "status:get_status", arguments: { service: "api" } } ``` ```ts main.ts active import "./npm.example"; import "./packages"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [App.esm("@acme/status-tools@^1.0.0", { namespace: "status" })], }) export default class Server {} ``` ```ts packages.ts import { publish } from "./npm.example"; // What esm.sh serves for each version: the package's code, as one module. const bundle = (version: string) => ` export default { name: "@acme/status-tools", version: "${version}", tools: [ { name: "get_status", description: "Current status of one service", inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"] }, execute: async ({ service }) => ({ content: [{ type: "text", text: service + " is up (served by ${version})" }], }), }, ], };`; publish("@acme/status-tools", "1.0.0", bundle("1.0.0")); publish("@acme/status-tools", "1.2.0", bundle("1.2.0")); publish("@acme/status-tools", "2.0.0", bundle("2.0.0")); ``` ```ts npm.example.ts // Stands in for the npm registry and esm.sh, which the Playground doesn't reach. // It answers FrontMCP's requests to them in-process, and passes everything else // to the real fetch. A real server doesn't need this file. type Package = { versions: Record; tags: Record }; const packages: Record = {}; /** Every request FrontMCP sent to the registry or esm.sh, oldest first. */ export const requests: { url: string; authorization: string | null }[] = []; /** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */ export function publish(name: string, version: string, bundle?: string, tag = "latest") { const pkg = (packages[name] ??= { versions: {}, tags: {} }); pkg.versions[version] = bundle; pkg.tags[tag] = version; } const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" }); function registry(url: URL) { const name = decodeURIComponent(url.pathname.slice(1)); const pkg = packages[name]; if (!pkg) return notFound(); const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }])); return Response.json({ name, "dist-tags": pkg.tags, versions }); } function esmSh(url: URL) { const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? []; const bundle = packages[name]?.versions[version]; return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound(); } const hosts: Record Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh }; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); const answer = hosts[url.host]; if (!answer) return realFetch(input, init); requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") }); return answer(url); }; ``` ```ts status.test.ts import { test, expect } from "@frontmcp/testing"; import { requests } from "./npm.example"; test("the package's tool is listed under the namespace", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["status:get_status"]); }); test("^1.0.0 was resolved with the registry, and 1.2.0 was loaded", async ({ mcp }) => { const result = await mcp.tools.call("status:get_status", { service: "api" }); expect(result.text()).toBe("api is up (served by 1.2.0)"); expect(requests[0].url).toBe("https://registry.npmjs.org/@acme%2fstatus-tools"); // Then the bundle, from esm.sh, unless Node's disk cache already had it for (const { url } of requests.slice(1)) expect(url).toBe("https://esm.sh/@acme/status-tools@1.2.0?bundle"); }); ``` The call works like a call to any of your tools; the model sees `status:get_status` and doesn't know where it came from. In Node, the test usually finds only the registry request: the Playground's first start already put `1.2.0` in the [disk cache](https://frontmcp.dev/reference/server/esm#caching). ### Choosing a version The part after the last `@` picks the version. Here the same package is loaded five times, with five specifiers, each under its own namespace; `2.0.0-beta.1` is published under the `next` dist-tag: Initial call configuration: ```js { tool: "stable:version", arguments: {} } ``` ```ts main.ts active import "./npm.example"; import "./packages"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [ App.esm("@acme/deploy-tools@^1.0.0", { namespace: "stable" }), // the highest 1.x App.esm("@acme/deploy-tools@1.0.0", { namespace: "pinned" }), // exactly 1.0.0 App.esm("@acme/deploy-tools@next", { namespace: "beta" }), // the "next" dist-tag App.esm("@acme/deploy-tools", { namespace: "latest" }), // the "latest" dist-tag App.esm("@acme/deploy-tools@stable", { namespace: "typo" }), // 🚩 no such dist-tag ], }) export default class Server {} ``` ```ts packages.ts import { publish } from "./npm.example"; const bundle = (version: string) => ` export default { name: "@acme/deploy-tools", version: "${version}", tools: [ { name: "version", description: "Which version of @acme/deploy-tools is loaded", execute: async () => ({ content: [{ type: "text", text: "${version}" }] }), }, ], };`; publish("@acme/deploy-tools", "1.0.0", bundle("1.0.0")); publish("@acme/deploy-tools", "1.2.0", bundle("1.2.0")); publish("@acme/deploy-tools", "2.0.0-beta.1", bundle("2.0.0-beta.1"), "next"); ``` ```ts npm.example.ts // Stands in for the npm registry and esm.sh, which the Playground doesn't reach. // It answers FrontMCP's requests to them in-process, and passes everything else // to the real fetch. A real server doesn't need this file. type Package = { versions: Record; tags: Record }; const packages: Record = {}; /** Every request FrontMCP sent to the registry or esm.sh, oldest first. */ export const requests: { url: string; authorization: string | null }[] = []; /** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */ export function publish(name: string, version: string, bundle?: string, tag = "latest") { const pkg = (packages[name] ??= { versions: {}, tags: {} }); pkg.versions[version] = bundle; pkg.tags[tag] = version; } const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" }); function registry(url: URL) { const name = decodeURIComponent(url.pathname.slice(1)); const pkg = packages[name]; if (!pkg) return notFound(); const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }])); return Response.json({ name, "dist-tags": pkg.tags, versions }); } function esmSh(url: URL) { const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? []; const bundle = packages[name]?.versions[version]; return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound(); } const hosts: Record Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh }; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); const answer = hosts[url.host]; if (!answer) return realFetch(input, init); requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") }); return answer(url); }; ``` ```ts versions.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; test("every app is listed", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["beta:version", "latest:version", "pinned:version", "stable:version", "typo:version"]); }); test("each specifier loads its own version", async ({ mcp }) => { const loaded = async (namespace: string) => (await mcp.tools.call(`${namespace}:version`, {})).text(); expect(await loaded("stable")).toBe("1.2.0"); expect(await loaded("pinned")).toBe("1.0.0"); expect(await loaded("beta")).toBe("2.0.0-beta.1"); expect(await loaded("latest")).toBe("1.2.0"); }); test("a dist-tag the package doesn't have loads the highest version", async ({ mcp }) => { expect((await mcp.tools.call("typo:version", {})).text()).toBe("2.0.0-beta.1"); }); test("without namespaces, two apps of one package share an id, and the server doesn't start", async () => { const apps = [App.esm("@acme/deploy-tools@^1.0.0"), App.esm("@acme/deploy-tools@1.0.0")]; await expect(FrontMcpInstance.createDirect({ info: { name: "support", version: "1.0.0" }, apps })).rejects.toThrow( 'Two apps share the id "acme-deploy-tools": give each App.esm() / App.remote() its own `name` or `namespace`.', ); }); ``` Each app takes its id from its namespace. Without them, the five apps would share one id, made from the package name, and the server doesn't start, with `DuplicateAppIdError`, as the last test shows. Give every `App.esm()` of the same package a `namespace` of its own. (Changed in 1.9.3: before, an app without a `name` took its id from the package name, even with a namespace, and the server started; `tools/list` then left out some of their tools, different ones from start to start.) A range never picks a prerelease like `2.0.0-beta.1`; a dist-tag or an exact version does. So does a dist-tag the package doesn't have: `stable` isn't one here, and instead of failing, FrontMCP loads the highest version it can find, the beta. Check dist-tags carefully. The version is resolved again at every start, so with a range, the next release is loaded at the next restart. Pin an exact version when that matters, or [check for updates while running](https://frontmcp.dev/reference/server/esm#updating-without-a-restart). ### Writing a package FrontMCP can load A package that FrontMCP can load exports a manifest: its `name` and `version`, and arrays of `tools`, `resources` and `prompts`. Plain objects need no dependency on FrontMCP. Publish the package the way you publish any other; FrontMCP loads it through esm.sh, as an ES module or CommonJS. ### Export forms ### Example: Default export Initial call configuration: ```js { tool: "incidents:open_incidents", arguments: { service: "api" } } ``` ```ts packages.ts active import { publish } from "./npm.example"; // The package's src/index.js, as esm.sh serves it publish("@acme/incident-tools", "1.0.0", ` const incidents = [ { id: "INC-7", service: "api", title: "Elevated error rate" }, { id: "INC-8", service: "billing", title: "Invoices delayed" }, ]; export default { name: "@acme/incident-tools", version: "1.0.0", tools: [ { name: "open_incidents", description: "List open incidents, optionally for one service", inputSchema: { type: "object", properties: { service: { type: "string" } } }, execute: async ({ service }) => { const open = incidents.filter((i) => !service || i.service === service); return { content: [{ type: "text", text: JSON.stringify(open) }] }; }, }, ], resources: [ { name: "incidents", uri: "incidents://open", mimeType: "application/json", read: async () => ({ contents: [{ uri: "incidents://open", mimeType: "application/json", text: JSON.stringify(incidents) }], }), }, ], prompts: [ { name: "incident_summary", description: "Summarize the open incidents for a status page", arguments: [{ name: "audience", description: "Who will read it", required: true }], execute: async ({ audience }) => ({ messages: [{ role: "user", content: { type: "text", text: "Read incidents://open and summarize it for " + audience + "." } }], }), }, ], }; `); ``` ```ts main.ts import "./npm.example"; import "./packages"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [App.esm("@acme/incident-tools@^1.0.0", { namespace: "incidents" })], }) export default class Server {} ``` ```ts npm.example.ts // Stands in for the npm registry and esm.sh, which the Playground doesn't reach. // It answers FrontMCP's requests to them in-process, and passes everything else // to the real fetch. A real server doesn't need this file. type Package = { versions: Record; tags: Record }; const packages: Record = {}; /** Every request FrontMCP sent to the registry or esm.sh, oldest first. */ export const requests: { url: string; authorization: string | null }[] = []; /** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */ export function publish(name: string, version: string, bundle?: string, tag = "latest") { const pkg = (packages[name] ??= { versions: {}, tags: {} }); pkg.versions[version] = bundle; pkg.tags[tag] = version; } const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" }); function registry(url: URL) { const name = decodeURIComponent(url.pathname.slice(1)); const pkg = packages[name]; if (!pkg) return notFound(); const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }])); return Response.json({ name, "dist-tags": pkg.tags, versions }); } function esmSh(url: URL) { const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? []; const bundle = packages[name]?.versions[version]; return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound(); } const hosts: Record Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh }; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); const answer = hosts[url.host]; if (!answer) return realFetch(input, init); requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") }); return answer(url); }; ``` ```ts manifest.test.ts import { test, expect } from "@frontmcp/testing"; test("tools, resources and prompts are namespaced; URIs aren't", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["incidents:open_incidents"]); expect(await mcp.resources.list()).toEqual([ { name: "incidents:incidents", uri: "incidents://open", mimeType: "application/json", description: "ESM resource: incidents" }, ]); expect((await mcp.prompts.list()).map((p) => p.name)).toEqual(["incidents:incident_summary"]); expect((await mcp.resources.read("incidents://open")).json()).toHaveLength(2); }); test("arguments aren't checked against a JSON Schema inputSchema", async ({ mcp }) => { const result = await mcp.tools.call("incidents:open_incidents", { service: 42 }); expect(result).toBeSuccessful(); expect(result.text()).toBe("[]"); }); test("required prompt arguments are checked", async ({ mcp }) => { const result = await mcp.prompts.get("incidents:incident_summary", {}); expect(result.error?.message).toBe("Missing required argument: audience"); }); ``` ### Example: Named exports Without a default export, FrontMCP reads the module's named exports. `name` and `version` are optional here. Initial call configuration: ```js { tool: "incidents:open_incidents", arguments: {} } ``` ```ts packages.ts active import { publish } from "./npm.example"; publish("@acme/incident-lite", "1.0.0", ` export const tools = [ { name: "open_incidents", description: "List open incidents", execute: async () => ({ content: [{ type: "text", text: "INC-7: Elevated error rate" }] }), }, ]; `); ``` ```ts main.ts import "./npm.example"; import "./packages"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [App.esm("@acme/incident-lite@1.0.0", { namespace: "incidents" })], }) export default class Server {} ``` ```ts npm.example.ts // Stands in for the npm registry and esm.sh, which the Playground doesn't reach. // It answers FrontMCP's requests to them in-process, and passes everything else // to the real fetch. A real server doesn't need this file. type Package = { versions: Record; tags: Record }; const packages: Record = {}; /** Every request FrontMCP sent to the registry or esm.sh, oldest first. */ export const requests: { url: string; authorization: string | null }[] = []; /** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */ export function publish(name: string, version: string, bundle?: string, tag = "latest") { const pkg = (packages[name] ??= { versions: {}, tags: {} }); pkg.versions[version] = bundle; pkg.tags[tag] = version; } const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" }); function registry(url: URL) { const name = decodeURIComponent(url.pathname.slice(1)); const pkg = packages[name]; if (!pkg) return notFound(); const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }])); return Response.json({ name, "dist-tags": pkg.tags, versions }); } function esmSh(url: URL) { const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? []; const bundle = packages[name]?.versions[version]; return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound(); } const hosts: Record Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh }; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); const answer = hosts[url.host]; if (!answer) return realFetch(input, init); requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") }); return answer(url); }; ``` A package can also export `@Tool`, `@Resource` and `@Prompt` classes, in a manifest's arrays or as exports of their own. FrontMCP registers them like the classes in your own apps, with their input validated and their results shaped as usual. That was checked in Node, with the bundle importing the server's own `@frontmcp/sdk`; a Playground bundle can't import the Playground's `@frontmcp/sdk`, so the examples here use plain objects. ### Using a private registry Point `loader` at your registry and give it a token. Prefer `tokenEnvVar`, which names an environment variable, over `token`, so the token stays out of your code. Here the registry is `npm.acme.example` and the bundles come from `esm.acme.example`, a self-hosted esm.sh: Initial call configuration: ```js { tool: "internal:whoami", arguments: {} } ``` ```ts main.ts active import "./npm.example"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "support", version: "1.0.0" }, loader: { registryUrl: "https://npm.acme.example", url: "https://esm.acme.example", tokenEnvVar: "ACME_NPM_TOKEN", }, apps: [App.esm("@acme/internal-tools@^1.0.0", { namespace: "internal" })], }) export default class Server {} ``` ```ts npm.example.ts // Stands in for Acme's private registry (npm.acme.example), which only answers // with the right token, and its esm.sh (esm.acme.example). A real server doesn't // need this file. // Your deployment would set this. Here the stand-in does. process.env.ACME_NPM_TOKEN = "npm_acme_read_only"; const bundle = ` export default { name: "@acme/internal-tools", version: "1.0.0", tools: [{ name: "whoami", description: "Which registry this came from", execute: async () => ({ content: [{ type: "text", text: "Acme's private registry" }] }) }], };`; /** Every request FrontMCP sent to the registry or esm.sh, oldest first. */ export const requests: { url: string; authorization: string | null }[] = []; function registry(url: URL, authorization: string | null) { if (authorization !== "Bearer npm_acme_read_only") return new Response("", { status: 401, statusText: "Unauthorized" }); if (url.pathname !== "/@acme%2finternal-tools") return new Response("", { status: 404, statusText: "Not Found" }); return Response.json({ name: "@acme/internal-tools", "dist-tags": { latest: "1.0.0" }, versions: { "1.0.0": {} } }); } function esmSh(url: URL) { if (url.pathname !== "/@acme/internal-tools@1.0.0") return new Response("", { status: 404, statusText: "Not Found" }); return new Response(bundle, { headers: { "content-type": "application/javascript" } }); } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "npm.acme.example" && url.host !== "esm.acme.example") return realFetch(input, init); const authorization = new Headers(init?.headers).get("authorization"); requests.push({ url: url.href, authorization }); return url.host === "npm.acme.example" ? registry(url, authorization) : esmSh(url); }; ``` ```ts private.test.ts import { test, expect } from "@frontmcp/testing"; import { App, EsmRegistryAuthError, FrontMcpInstance } from "@frontmcp/sdk"; import { requests } from "./npm.example"; test("the token goes to the registry, and not to the bundle server", async ({ mcp }) => { expect((await mcp.tools.call("internal:whoami", {})).text()).toBe("Acme's private registry"); expect(requests[0]).toEqual({ url: "https://npm.acme.example/@acme%2finternal-tools", authorization: "Bearer npm_acme_read_only" }); for (const request of requests.slice(1)) { expect(request).toEqual({ url: "https://esm.acme.example/@acme/internal-tools@1.0.0?bundle", authorization: null }); } }); test("a wrong token stops the server from starting", async () => { const starting = FrontMcpInstance.createFetchHandler({ info: { name: "support", version: "1.0.0" }, loader: { registryUrl: "https://npm.acme.example", url: "https://esm.acme.example", token: "npm_expired" }, apps: [App.esm("@acme/internal-tools@^1.0.0", { namespace: "internal" })], }); const error = await starting.catch((e) => e); expect(error).toBeInstanceOf(EsmRegistryAuthError); expect(error.message).toBe('Authentication failed for npm registry: Registry returned 401 for "@acme/internal-tools"'); }); ``` The token only goes to the registry. The bundle server gets no credentials from FrontMCP, so it has to be able to fetch the package by itself: the public esm.sh can't build a package from a private registry, which is why this example has its own. To use a registry for one package only, give that `App.esm()` its own `loader`; it replaces the server's, token included. ### Loading part of a package `filter` picks which of a package's tools, resources and prompts the server loads. Patterns match the names the package gives them, before the namespace, and `*` matches any run of characters. Here the server takes the status tools of `@acme/status-admin`, but not the one that deletes incidents: Initial call configuration: ```js { tool: "status:get_status", arguments: {} } ``` ```ts main.ts active import "./npm.example"; import "./packages"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [ App.esm("@acme/status-admin@1.0.0", { namespace: "status", filter: { exclude: { tools: ["delete_*"] } }, }), ], }) export default class Server {} ``` ```ts packages.ts import { publish } from "./npm.example"; publish("@acme/status-admin", "1.0.0", ` const tool = (name) => ({ name, description: "The " + name + " tool", execute: async () => ({ content: [{ type: "text", text: name + " ran" }] }) }); export default { name: "@acme/status-admin", version: "1.0.0", tools: [tool("get_status"), tool("delete_incident")] }; `); ``` ```ts npm.example.ts // Stands in for the npm registry and esm.sh, which the Playground doesn't reach. // It answers FrontMCP's requests to them in-process, and passes everything else // to the real fetch. A real server doesn't need this file. type Package = { versions: Record; tags: Record }; const packages: Record = {}; /** Every request FrontMCP sent to the registry or esm.sh, oldest first. */ export const requests: { url: string; authorization: string | null }[] = []; /** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */ export function publish(name: string, version: string, bundle?: string, tag = "latest") { const pkg = (packages[name] ??= { versions: {}, tags: {} }); pkg.versions[version] = bundle; pkg.tags[tag] = version; } const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" }); function registry(url: URL) { const name = decodeURIComponent(url.pathname.slice(1)); const pkg = packages[name]; if (!pkg) return notFound(); const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }])); return Response.json({ name, "dist-tags": pkg.tags, versions }); } function esmSh(url: URL) { const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? []; const bundle = packages[name]?.versions[version]; return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound(); } const hosts: Record Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh }; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); const answer = hosts[url.host]; if (!answer) return realFetch(input, init); requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") }); return answer(url); }; ``` ```ts filter.test.ts import { test, expect } from "@frontmcp/testing"; test("the excluded tool isn't listed", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["status:get_status"]); }); test("or callable", async ({ mcp }) => { const result = await mcp.tools.call("status:delete_incident", {}); expect(result).toBeError(); expect(result.text()).toBe('Tool "status:delete_incident" not found'); }); test("the rest of the package works as usual", async ({ mcp }) => { expect((await mcp.tools.call("status:get_status", {})).text()).toBe("get_status ran"); }); ``` To load only what you list, start from nothing: `filter: { default: "exclude", include: { tools: ["get_*"] } }`. `include` and `exclude` take `tools`, `resources` and `prompts`, each a list of patterns. ### Rewriting a package's imports A bundle can import packages that its bundle server can't build in, like a private dependency, or that your server should provide. `importMap` names them: FrontMCP asks for the bundle without them (`&external=…`), and rewrites the bundle's imports to the targets you give. A target is anything `import()` accepts where the server runs. Here `@acme/greeter` imports `@acme/greetings`, and the server supplies it as a `data:` module: Initial call configuration: ```js { tool: "greet:hello", arguments: { name: "Ana" } } ``` ```ts main.ts active import "./npm.example"; import "./packages"; import { App, FrontMcp } from "@frontmcp/sdk"; const greetings = "export const greet = (name) => 'Hello, ' + name + '!';"; @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [ App.esm("@acme/greeter@1.0.0", { namespace: "greet", importMap: { "@acme/greetings": `data:text/javascript,${encodeURIComponent(greetings)}` }, }), ], }) export default class Server {} ``` ```ts packages.ts import { publish } from "./npm.example"; publish("@acme/greeter", "1.0.0", ` import { greet } from "@acme/greetings"; export default { name: "@acme/greeter", version: "1.0.0", tools: [ { name: "hello", description: "Greet someone", inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, execute: async ({ name }) => ({ content: [{ type: "text", text: greet(name) }] }), }, ], }; `); ``` ```ts npm.example.ts // Stands in for the npm registry and esm.sh, which the Playground doesn't reach. // It answers FrontMCP's requests to them in-process, and passes everything else // to the real fetch. A real server doesn't need this file. type Package = { versions: Record; tags: Record }; const packages: Record = {}; /** Every request FrontMCP sent to the registry or esm.sh, oldest first. */ export const requests: { url: string; authorization: string | null }[] = []; /** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */ export function publish(name: string, version: string, bundle?: string, tag = "latest") { const pkg = (packages[name] ??= { versions: {}, tags: {} }); pkg.versions[version] = bundle; pkg.tags[tag] = version; } const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" }); function registry(url: URL) { const name = decodeURIComponent(url.pathname.slice(1)); const pkg = packages[name]; if (!pkg) return notFound(); const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }])); return Response.json({ name, "dist-tags": pkg.tags, versions }); } function esmSh(url: URL) { const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? []; const bundle = packages[name]?.versions[version]; return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound(); } const hosts: Record Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh }; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); const answer = hosts[url.host]; if (!answer) return realFetch(input, init); requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") }); return answer(url); }; ``` ```ts greeter.test.ts import { test, expect } from "@frontmcp/testing"; import { requests } from "./npm.example"; test("the package's import reaches the module the server supplied", async ({ mcp }) => { expect((await mcp.tools.call("greet:hello", { name: "Ana" })).text()).toBe("Hello, Ana!"); }); test("the bundle is asked for without that package", async ({ mcp }) => { await mcp.tools.list(); // Unless Node's disk cache already had it for (const { url } of requests.filter((r) => r.url.startsWith("https://esm.sh/"))) { expect(url).toBe("https://esm.sh/@acme/greeter@1.0.0?bundle&external=@acme/greetings"); } }); ``` A key ending in `/`, like `"@acme/util/"`, rewrites every import that starts with it, keeping the rest of the path. The rewritten bundle is cached under its own key, so changing the map downloads the bundle again. ### Loading one tool from a package `Tool.esm(specifier, name)` loads a single tool from a package into one of your apps, next to your own tools. It keeps its own name, with no namespace, and the package's other tools aren't loaded. `Resource.esm()` and `Prompt.esm()` do the same for a resource or a prompt. A package specifier string in an app's `tools`, like `tools: ["@acme/status-admin@1.0.0"]`, loads every tool of the package, also without a namespace. Both load when the server starts, like `App.esm()`. Since FrontMCP 1.9.1; before, every list rejected what `Tool.esm()` returns, and a specifier string listed a placeholder that couldn't be called. Initial call configuration: ```js { tool: "get_status", arguments: {} } ``` ```ts main.ts active import "./npm.example"; import "./packages"; import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "ping", description: "Check the server is up", inputSchema: {} }) class Ping extends ToolContext { async execute() { return { ok: true }; } } @App({ id: "support", name: "Support", tools: [Ping, Tool.esm("@acme/status-admin@1.0.0", "get_status")] }) class SupportApp {} @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [SupportApp] }) export default class Server {} ``` ```ts packages.ts import { publish } from "./npm.example"; publish("@acme/status-admin", "1.0.0", ` const tool = (name) => ({ name, description: "The " + name + " tool", execute: async () => ({ content: [{ type: "text", text: name + " ran" }] }) }); export default { name: "@acme/status-admin", version: "1.0.0", tools: [tool("get_status"), tool("delete_incident")] }; `); ``` ```ts npm.example.ts // Stands in for the npm registry and esm.sh, which the Playground doesn't reach. // It answers FrontMCP's requests to them in-process, and passes everything else // to the real fetch. A real server doesn't need this file. type Package = { versions: Record; tags: Record }; const packages: Record = {}; /** Every request FrontMCP sent to the registry or esm.sh, oldest first. */ export const requests: { url: string; authorization: string | null }[] = []; /** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */ export function publish(name: string, version: string, bundle?: string, tag = "latest") { const pkg = (packages[name] ??= { versions: {}, tags: {} }); pkg.versions[version] = bundle; pkg.tags[tag] = version; } const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" }); function registry(url: URL) { const name = decodeURIComponent(url.pathname.slice(1)); const pkg = packages[name]; if (!pkg) return notFound(); const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }])); return Response.json({ name, "dist-tags": pkg.tags, versions }); } function esmSh(url: URL) { const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? []; const bundle = packages[name]?.versions[version]; return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound(); } const hosts: Record Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh }; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); const answer = hosts[url.host]; if (!answer) return realFetch(input, init); requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") }); return answer(url); }; ``` ```ts per-entry.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance, Tool } from "@frontmcp/sdk"; const toolNames = async (tools: unknown[]) => { @App({ id: "desk", name: "Desk", tools: tools as any }) class Desk {} const server = await FrontMcpInstance.createDirect({ info: { name: "support", version: "1.0.0" }, apps: [Desk] }); try { return (await server.listTools()).tools.map((t) => t.name); } finally { await server.dispose(); } }; test("Tool.esm() loads one tool, under its own name", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["get_status", "ping"]); expect((await mcp.tools.call("get_status", {})).text()).toBe("get_status ran"); }); test("a specifier string loads every tool of the package", async () => { expect(await toolNames(["@acme/status-admin@1.0.0"])).toEqual(["delete_incident", "get_status"]); }); test("`metadata` renames it or changes its description", async () => { expect(await toolNames([Tool.esm("@acme/status-admin@1.0.0", "get_status", { metadata: { name: "service_status" } })])).toEqual(["service_status"]); }); test("a name the package doesn't have stops the server from starting", async () => { await expect(toolNames([Tool.esm("@acme/status-admin@1.0.0", "get_stauts")])).rejects.toThrow( 'Tool "get_stauts" was not found in @acme/status-admin@1.0.0 (tools there: get_status, delete_incident)', ); }); ``` `Tool.esm()` takes `loader`, `cacheTTL` and `metadata`; entries from the same package and options share one download. `Agent.esm()`, `Skill.esm()` and `Job.esm()` exist too, but there's nothing to load them: a server that lists one doesn't start, with `ExternalEntryNotSupportedError`. A package that can't be loaded stops the server with `ExternalEntryLoadError`, whose message ends with the loader's own, [below](https://frontmcp.dev/reference/server/esm#when-a-package-cant-be-loaded). ### When a package can't be loaded The server doesn't start, and the error says what failed: `createFetchHandler()` rejects with it. Its class tells the step apart, and its message ends with the cause, which `originalError` holds too. Each test here starts a server of its own with a package that can't load: ```ts failures.test.ts active import { test, expect } from "@frontmcp/testing"; import { App, EsmInvalidSpecifierError, EsmManifestInvalidError, EsmPackageLoadError, EsmVersionResolutionError, FrontMcpInstance, } from "@frontmcp/sdk"; import "./npm.example"; import { publish } from "./npm.example"; publish("@acme/uptime-tools", "1.0.0", "export default { name: '@acme/uptime-tools', version: '1.0.0', tools: [] };"); publish("@acme/uptime-tools", "1.2.0", "export default { name: '@acme/uptime-tools', version: '1.2.0', tools: [] };"); publish("@acme/half-published", "1.0.0"); // in the registry, but esm.sh can't serve it publish("@acme/not-a-manifest", "1.0.0", "export const hello = 'world';"); /** Starts a server with one ESM app. It rejects when the package can't be loaded. */ async function start(app: unknown) { await FrontMcpInstance.createFetchHandler({ info: { name: "support", version: "1.0.0" }, apps: [app] } as any); } /** The error a server with this app fails to start with. */ const failure = (app: unknown) => start(app).then(() => undefined, (e) => e); test("a package the registry doesn't know", async () => { const error = await failure(App.esm("@acme/stauts-tools@^1.0.0")); expect(error).toBeInstanceOf(EsmVersionResolutionError); expect(error.message).toBe( 'Failed to resolve version for "@acme/stauts-tools@^1.0.0": Package "@acme/stauts-tools" not found in registry at https://registry.npmjs.org', ); }); test("a registry that can't be reached", async () => { const error = await failure(App.esm("@acme/uptime-tools@^1.0.0", { loader: { registryUrl: "https://npm.down.example" } })); expect(error).toBeInstanceOf(EsmVersionResolutionError); expect(error.originalError.message).toBe('Failed to fetch package info for "@acme/uptime-tools": fetch failed'); }); test("a range no published version satisfies", async () => { const error = await failure(App.esm("@acme/uptime-tools@^3.0.0")); expect(error).toBeInstanceOf(EsmVersionResolutionError); expect(error.originalError.message).toBe('No version of "@acme/uptime-tools" satisfies range "^3.0.0". Available versions: 1.0.0, 1.2.0'); }); test("a version esm.sh can't serve", async () => { const error = await failure(App.esm("@acme/half-published@1.0.0")); expect(error).toBeInstanceOf(EsmPackageLoadError); expect(error.message).toBe('Failed to load ESM package "@acme/half-published"@1.0.0: esm.sh returned 404 for "@acme/half-published@1.0.0": Not Found'); }); test("a module without a manifest", async () => { const error = await failure(App.esm("@acme/not-a-manifest@1.0.0")); expect(error).toBeInstanceOf(EsmManifestInvalidError); expect(error.message).toContain('Invalid manifest in ESM package "@acme/not-a-manifest": ESM module does not export a valid FrontMcpPackageManifest.'); }); test("a token variable that isn't set", async () => { const error = await failure(App.esm("@acme/uptime-tools@^1.0.0", { loader: { tokenEnvVar: "ACME_TOKEN_NOT_SET" } })); expect(error).toBeInstanceOf(EsmVersionResolutionError); expect(error.originalError.message).toBe('Environment variable "ACME_TOKEN_NOT_SET" is not set. Required for private npm registry authentication.'); }); test("a malformed specifier throws before the server exists", () => { expect(() => App.esm("Acme/Status-Tools")).toThrow(EsmInvalidSpecifierError); expect(() => App.esm("Acme/Status-Tools")).toThrow('Invalid package specifier: "Acme/Status-Tools"'); }); ``` ```ts npm.example.ts // Stands in for the npm registry and esm.sh, which the Playground doesn't reach. // It answers FrontMCP's requests to them in-process, and passes everything else // to the real fetch. A real server doesn't need this file. type Package = { versions: Record; tags: Record }; const packages: Record = {}; /** Every request FrontMCP sent to the registry or esm.sh, oldest first. */ export const requests: { url: string; authorization: string | null }[] = []; /** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */ export function publish(name: string, version: string, bundle?: string, tag = "latest") { const pkg = (packages[name] ??= { versions: {}, tags: {} }); pkg.versions[version] = bundle; pkg.tags[tag] = version; } const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" }); function registry(url: URL) { const name = decodeURIComponent(url.pathname.slice(1)); const pkg = packages[name]; if (!pkg) return notFound(); const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }])); return Response.json({ name, "dist-tags": pkg.tags, versions }); } function esmSh(url: URL) { const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? []; const bundle = packages[name]?.versions[version]; return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound(); } // A registry that's down: every request fails the way fetch() does when it can't connect const down = (): Response => { throw new TypeError("fetch failed"); }; const hosts: Record Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh, "npm.down.example": down }; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); const answer = hosts[url.host]; if (!answer) return realFetch(input, init); requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") }); return answer(url); }; ``` ```ts main.ts import "./npm.example"; import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "ping", description: "Check the server is up", inputSchema: {} }) class Ping extends ToolContext { async execute() { return { ok: true }; } } @App({ id: "support", name: "Support", tools: [Ping] }) class SupportApp {} @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [SupportApp] }) export default class Server {} ``` What each one means, and what to do, is under [Troubleshooting](https://frontmcp.dev/reference/server/esm#troubleshooting). --- ## Troubleshooting ### `Failed to resolve version for "…": Package "…" not found in registry at …` The registry answered `404` for the name. Check the spelling and the scope. For a private package, check that `registryUrl` points at the registry that has it: without one, FrontMCP asks `https://registry.npmjs.org`. ### `Failed to resolve version for "…": Failed to fetch package info for "…": fetch failed` FrontMCP couldn't reach the registry at all: it's down, the host is wrong, or the network doesn't let the server out. FrontMCP asks the registry at every start, so this stops the server even when the bundle is in the cache. The text after the colon is the runtime's own error, like `Failed to fetch` in browsers. ### `Failed to resolve version for "…": Timeout resolving version for "…" after 15000ms` The registry didn't answer within 15 seconds. The bundle download has its own limit, 30 seconds: `Failed to load ESM package "…"@…: Timeout fetching ESM bundle for "…@…" after 30000ms`. Neither can be changed. (Checked in Node against a stand-in that never answers.) ### `Failed to resolve version for "…": No version of "…" satisfies range "…"` The package exists, but no published version is in the range. The message lists up to five published versions. Widen the range, publish a matching version, or use a dist-tag. A range never matches a prerelease; use its exact version or its dist-tag. ### `Failed to load ESM package "…"@…: esm.sh returned 404 for "…@…": Not Found` The registry has the version, but the bundle URL answered `404`, or another status. Check that the bundle server can reach the package, and that `loader.url` points at a server that serves bundles. The message says "esm.sh" even when `loader.url` points somewhere else. With `url` set and no `registryUrl`, both requests go to `url`, so a plain npm registry there resolves versions and then fails here. ### `Invalid manifest in ESM package "…": ESM module does not export a valid FrontMcpPackageManifest` The bundle loaded, but FrontMCP found nothing it could use in its exports: no `name` and `version`, no `tools`, `resources` or `prompts` array, and no decorated classes. A default-exported `@App` or `@FrontMcp` class isn't recognized either. Export a manifest, as in [writing a package](https://frontmcp.dev/reference/server/esm#writing-a-package-frontmcp-can-load). If the server starts but some tools are missing, those entries were skipped, or [`filter`](https://frontmcp.dev/reference/server/esm#loading-part-of-a-package) left them out: every plain-object tool needs a `name` and an `execute` function, every resource a `name`, a `uri` with a scheme and a `read` function. ### `Failed to resolve version for "…": Environment variable "…" is not set. Required for private npm registry authentication.` `tokenEnvVar` names a variable that isn't set in the server's environment. Set it where the server runs; FrontMCP reads it when the package loads. ### `Authentication failed for npm registry: Registry returned 401 for "…"` The registry refused the token, or got none (`401` or `403`). Check the token and that it can read the package. FrontMCP sends it as `Authorization: Bearer `, only to the registry; an app's own `loader` replaces the server's, so its token must be set there too. Other statuses fail the version step instead, as `Failed to resolve version for "…": Registry returned for "…": `. ### `Invalid package specifier: "…"` `App.esm()` throws this as soon as it's called, so the server module doesn't load. The specifier must be an npm package name, lowercase, optionally scoped, optionally followed by `@` and a version. URLs aren't accepted. An empty string gives `Package specifier cannot be empty`. ### Tool names start with `@acme/status-tools:` That's the default namespace, the package name. Set `namespace` (or `name`) in the options: `App.esm("@acme/status-tools@^1.0.0", { namespace: "status" })`. ### A tool's result is the text `[object Object]` A plain-object tool's `execute()` returned an object that isn't a `CallToolResult`. Only `{ content: [...] }` is sent as it is; wrap other data in a text block: Initial call configuration: ```js { tool: "status:counts", arguments: {} } ``` ```ts packages.ts active import { publish } from "./npm.example"; publish("@acme/status-counts", "1.0.0", ` export default { name: "@acme/status-counts", version: "1.0.0", tools: [ { name: "counts_raw", description: "Count open incidents", // 🚩 A plain object: the client gets "[object Object]" execute: async () => ({ open: 2, resolved: 14 }), }, { name: "counts", description: "Count open incidents", // ✅ A CallToolResult execute: async () => ({ content: [{ type: "text", text: JSON.stringify({ open: 2, resolved: 14 }) }] }), }, ], }; `); ``` ```ts main.ts import "./npm.example"; import "./packages"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [App.esm("@acme/status-counts@1.0.0", { namespace: "status" })], }) export default class Server {} ``` ```ts npm.example.ts // Stands in for the npm registry and esm.sh, which the Playground doesn't reach. // It answers FrontMCP's requests to them in-process, and passes everything else // to the real fetch. A real server doesn't need this file. type Package = { versions: Record; tags: Record }; const packages: Record = {}; /** Every request FrontMCP sent to the registry or esm.sh, oldest first. */ export const requests: { url: string; authorization: string | null }[] = []; /** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */ export function publish(name: string, version: string, bundle?: string, tag = "latest") { const pkg = (packages[name] ??= { versions: {}, tags: {} }); pkg.versions[version] = bundle; pkg.tags[tag] = version; } const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" }); function registry(url: URL) { const name = decodeURIComponent(url.pathname.slice(1)); const pkg = packages[name]; if (!pkg) return notFound(); const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }])); return Response.json({ name, "dist-tags": pkg.tags, versions }); } function esmSh(url: URL) { const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? []; const bundle = packages[name]?.versions[version]; return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound(); } const hosts: Record Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh }; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); const answer = hosts[url.host]; if (!answer) return realFetch(input, init); requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") }); return answer(url); }; ``` ```ts counts.test.ts import { test, expect } from "@frontmcp/testing"; test("a plain object arrives as [object Object]", async ({ mcp }) => { expect((await mcp.tools.call("status:counts_raw", {})).text()).toBe("[object Object]"); }); test("a CallToolResult arrives as it is", async ({ mcp }) => { expect((await mcp.tools.call("status:counts", {})).json()).toEqual({ open: 2, resolved: 14 }); }); ``` A string is fine too: it arrives as the text. A decorated `@Tool` class in a package returns values the usual way. --- # Remote servers > App.remote() mounts another MCP server as an app, so its tools, resources and prompts appear on your server and calls are forwarded to it. Source: https://frontmcp.dev/reference/server/remote [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 `App.remote()` connects your server to another MCP server over HTTP and serves that server's tools, resources and prompts as one of your apps, under a namespace. Clients see one server; when they call a remote tool, FrontMCP forwards the call and passes the answer back. Use it to put several MCP servers behind one endpoint, or to offer a third-party server's tools next to your own without asking every client to connect to it. ```ts App.remote(url, options?) ``` --- ## Reference ### `App.remote(url, options?)` List the result in `@FrontMcp({ apps })`, next to your own apps. ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [ HelpDeskApp, App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { headers: { Authorization: `Bearer ${process.env.STATUS_TOKEN}` }, timeout: 10_000, }, }), ], }) export default class Server {} ``` [See more examples below.](https://frontmcp.dev/reference/server/remote#usage) #### Parameters | Parameter | Type | Description | | --- | --- | --- | | `url` | `string` | The remote server's MCP endpoint, with its scheme and path, like `https://status.example/mcp`. `App.remote()` throws `URI must have a valid scheme (e.g., file://, https://, custom://)` for a URL without a scheme. | | `options` | `RemoteUrlAppOptions` | Optional. See below. | #### Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `namespace` | `string` | `name` | Prefix for the remote's tool, resource and prompt names: `status` turns `get_status` into `status:get_status`. | | `name` | `string` | The URL's host up to the first dot | The app's name, and the default namespace. `https://status.example/mcp` gives `status`; `http://localhost:3099/` gives `localhost`, and `http://127.0.0.1:3099/` gives `127`. A name with spaces keeps them in the prefix. The app's id, which error messages use, is the name with spaces turned into `-`, or, when you set `namespace` and no `name`, the namespace. Two remote apps with one id stop the server from starting, with [`DuplicateAppIdError`](https://frontmcp.dev/reference/server/remote#two-apps-share-the-id-status). (Changed in 1.9.3: before, the id always came from the name.) | | `description` | `string` | | What the app is for, for your own tooling. | | `transportOptions` | `RemoteTransportOptions` | | How to reach the remote. See below. | | `cacheTTL` | `number` | `60000` | Milliseconds FrontMCP keeps the remote's list of tools, resources and prompts before it asks again. See [picking up changes](https://frontmcp.dev/reference/server/remote#picking-up-changes-on-the-remote-server). | | `refreshInterval` | `number` | `0` (off) | Ask the remote for its lists every this many milliseconds, in the background. See [picking up changes](https://frontmcp.dev/reference/server/remote#picking-up-changes-on-the-remote-server). | | `standalone` | `boolean \| "includeInParent"` | `false` | Serve the app on its own path, as for [`@App`](https://frontmcp.dev/reference/sdk/app#standalone-apps). The path is the app's id: `/status` for `https://status.example/mcp`. | | `remoteAuth` | `RemoteAuthConfig` | | Credentials for the remote. `{ mode: "static", credentials }` sends the same ones with every request; `{ mode: "forward" }` sends each caller's own token with that caller's calls, and needs a server in `transparent` mode; `{ mode: "mapped", mapper }` sends the credentials `mapper` returns for each caller; `{ mode: "oauth" }` sends nothing. See [authenticating](https://frontmcp.dev/reference/server/remote#authenticating-to-the-remote-server). | | `filter` | `AppFilterConfig` | Serve everything | Which of the remote's tools, resources and prompts to serve, by the remote's own names, with `*` wildcards: `{ exclude: { tools: ["delete_*"] } }`, or `{ default: "exclude", include: { tools: ["get_*"] } }`. An entry left out isn't listed, and calling it gives `Tool "…" not found`. See [serving part of a remote](https://frontmcp.dev/reference/server/remote#serving-part-of-a-remote-server). | `transportOptions`: | Field | Default | What it does | | --- | --- | --- | | `headers` | | Sent with every request to the remote, the first one included. `remoteAuth` credentials replace a header of the same name. | | `protocolVersion` | `"legacy"` | Which MCP revision to speak to the remote. `"legacy"`: a session, opened with `initialize`, as below. `"2026-07-28"`: each request on its own, after a `server/discover`; the server doesn't start if the remote doesn't answer it. `"auto"`: try `server/discover`, and fall back to `"legacy"` when the remote doesn't answer it. See [a 2026-07-28 remote](https://frontmcp.dev/reference/server/remote#talking-to-a-2026-07-28-server). Since 1.9.0. | | `timeout` | `30000` | Milliseconds a tool call may take. After that the try fails with `REMOTE_TIMEOUT_ERROR`, and is retried; the remote isn't told to stop. It doesn't apply to connecting, reading resources or getting prompts, which wait as long as the remote takes. | | `fallbackToSSE` | `true` | When the first connection attempt fails, try the older HTTP+SSE transport, with a `GET`, before giving up. That's why a failed connection reports an `SSE error`. | | `retryAttempts` | `2` | How many times to try a tool call again after a failure that may pass: a network failure, a timeout, or HTTP `408`, `425`, `429` or a `5xx` other than `501` and `505`. `0` tries once. See [retries](https://frontmcp.dev/reference/server/remote#retrying-calls-that-fail-on-the-way). | | `retryDelayMs` | `1000` | Milliseconds before the first retry. Each further retry waits twice as long as the one before, up to 10 seconds, give or take a tenth. A 2026-07-28 remote's `Retry-After` header replaces the wait, up to 10 seconds. | #### Returns A plain object that describes the app, not a class: `{ name, urlType: "url", url, namespace, standalone, transportOptions, … }`, with an `id` taken from `namespace` when you set one and no `name`. You can write it yourself instead, to set an `id` of your own: ```ts apps: [{ id: "status-page", name: "status", urlType: "url", url: "https://status.example/mcp", standalone: false }] ``` Nothing is fetched until the server starts. #### How a remote app works 1. **When the server starts**, FrontMCP connects: by default `initialize` (as client `frontmcp-gateway` 1.0.0, protocol version `2025-11-25`), `notifications/initialized`, a `GET` for an event stream, then `tools/list`, `resources/list`, `resources/templates/list` and `prompts/list`, each followed page by page until the remote sends no `nextCursor` ([below](https://frontmcp.dev/reference/server/remote#a-remote-with-more-than-one-page-of-tools)). If the remote can't be reached or refuses, the server doesn't start. Connecting has no time limit. 2. **On the first request that lists or calls anything**, FrontMCP asks for the lists again and registers the remote's tools, resources and prompts in the app. It asks again whenever they're older than `cacheTTL`. 3. **Each call is forwarded** under the remote's own name: `status:get_status` reaches the remote as `get_status`, with the client's arguments. A resource read is forwarded with the same URI, a prompt with its arguments. 4. **Every 30 seconds** FrontMCP lists the remote's tools to check it's still there. The server's [`/readyz`](https://frontmcp.dev/reference/server/observability#health) has a `remote:` check for it, which is `degraded`, and so still ready, until the first check has run. By default FrontMCP talks to the remote with the protocol before 2026-07-28, even when the remote supports 2026-07-28; [`transportOptions.protocolVersion`](https://frontmcp.dev/reference/server/remote#talking-to-a-2026-07-28-server) changes that. All clients share this one connection: the remote sees one client, with the headers you configured, never your users. #### What clients see | | On your server | Notes | | --- | --- | --- | | Tools | `:` | Description, `inputSchema` and `annotations` as the remote lists them; a tool without a description gets `Remote tool: `. `title` and `outputSchema` are dropped. Results, `structuredContent` and `isError` included, come back as the remote sent them. | | Resources | `:`, same URI | Without a description: `Remote resource: `. | | Resource templates | `:`, same URI template | Before 1.9.0 each template was listed twice, as `::`. | | Prompts | `:` | Arguments as the remote lists them. FrontMCP checks required arguments before it forwards. | Clients see your server's name and version, never the remote's. FrontMCP doesn't check a remote tool's arguments against its `inputSchema`: the remote does, and its error comes back as it sent it. #### When the remote server fails - **A tool call that fails** comes back as an `isError` result with `_meta.code` `REMOTE_TOOL_EXECUTION_ERROR`: `Remote tool "get_status" on "status" failed: …`, with the underlying error after the colon. It's kept in production. - **Retries.** A call that fails in a way that may pass is tried 3 times in all, about 1 and then 2 seconds apart; `retryAttempts` and `retryDelayMs` change both. That's a network failure (`fetch failed`, a refused or reset connection), a try that ran out of `timeout`, or an HTTP answer of `408`, `425`, `429`, or `5xx` other than `501` and `505`, whatever its body says. Any other status, like `400` or `404`, is the answer at once. A 2026-07-28 remote's `Retry-After` header sets the wait, up to 10 seconds. See [retries](https://frontmcp.dev/reference/server/remote#retrying-calls-that-fail-on-the-way). (Changed in 1.9.3: before, only network failures were retried, so a `503` wasn't, nor a timeout.) - **A call that takes longer than `timeout`** comes back as `REMOTE_TIMEOUT_ERROR`, `Remote operation "get_status" on "status" timed out after 30000ms`, after its retries. The remote isn't told to stop, so it may run the call once per try. See [limiting how long a call can take](https://frontmcp.dev/reference/server/remote#limiting-how-long-a-call-can-take). - **A remote that answers `401`** to a tool call, a resource read or a prompt fails it with `RemoteAuthError`, `Authentication failed for remote server "status": the remote server refused the credentials (HTTP 401)` (`REMOTE_AUTH_ERROR`), whatever the `remoteAuth` mode, and isn't retried. Since 1.9.3; before, it was a `REMOTE_TOOL_EXECUTION_ERROR`. - **The circuit breaker.** After 5 failed attempts within a minute, retries and timeouts included, FrontMCP stops forwarding calls to that remote for 30 seconds. Every call to any of its tools fails at once with `Circuit breaker is open for status. Retry in 30s` (`TOOL_EXECUTION_ERROR`, and "Internal FrontMCP error" in production). Other remote apps aren't affected. - **Resource reads and prompts** aren't retried or counted by the breaker. A failure is JSON-RPC error `-32603` with `data.code` `REMOTE_RESOURCE_READ_ERROR` or `REMOTE_PROMPT_GET_ERROR`, like `Remote resource "status://services/api" on "status" read failed: …`, or `-32602` with `REMOTE_RESOURCE_NOT_FOUND`, `Resource "…" not found on remote server "status"`, when the remote's message says "not found". - **A tool the remote no longer has** is dropped from your server when FrontMCP next reads the remote's list, once `cacheTTL` has passed, and a call then fails as for any unknown tool, `TOOL_NOT_FOUND`. Until then, the call is forwarded and fails the way the remote answers it. See [picking up changes](https://frontmcp.dev/reference/server/remote#picking-up-changes-on-the-remote-server). #### Caveats - On the default `"legacy"` revision, Streamable HTTP follows a redirect only when it stays on the same origin and keeps the request's method: `307` or `308` for a `POST`, or any supported redirect status for a `GET`. The origin is the scheme, host and port; an upgrade from `http` to `https` on the same host with default ports is also allowed. This applies with or without credentials. Other redirects fail with `Redirect to not followed`; point `url` at the final MCP endpoint. [The example below](https://frontmcp.dev/reference/server/remote#following-a-redirect-on-the-remote-server) checks both cases. - Changed in 1.10.3: before, an uncredentialed request followed every redirect, and a credentialed request followed none. In a browser, a real fetch can hide the redirect target (`opaqueredirect`), so use the final endpoint there too. - The remote must be up when your server starts, or your server doesn't start: [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) rejects with the connection error, as `createDirect()` does. On an edge runtime, like Cloudflare Workers, the server starts on the first request instead. Until it starts, every request gets `503` `{"error":"server_unavailable","code":"SERVER_START_FAILED"}` with a `Retry-After` header, and FrontMCP tries again only after that delay, which grows from a second to a minute. Changed in 1.8.4: before, `createFetchHandler()` always waited for the first request. Changed in 1.8.5: on an edge runtime, that request used to throw, and the next one tried again at once. - With `transportOptions.headers` or `remoteAuth: { mode: "static" }`, everyone who calls a remote tool reaches the remote with the same credentials. To pass each caller's own token on, the server must be in `transparent` mode, with [`remoteAuth: { mode: "forward" }`](https://frontmcp.dev/reference/server/remote#passing-each-callers-token-on); to send credentials you keep for each caller, use [`remoteAuth: { mode: "mapped" }`](https://frontmcp.dev/reference/server/remote#credentials-for-each-caller). - `refreshInterval` is read from the first remote app only, and applies to every remote app on the server. - Remote apps have no plugins, adapters or providers of their own. - To forward one tool, resource or prompt instead of the whole server, use [`Tool.remote()`](https://frontmcp.dev/reference/server/remote#forwarding-one-tool), `Resource.remote()` or `Prompt.remote()` in an app's lists. `Agent.remote()`, `Skill.remote()` and `Job.remote()` stop the server from starting. - `urlType: "worker"`, in a hand-written app object, isn't implemented: the server fails to start with `McpClientService.createTransport[worker]() is not implemented`. - This page is about your server calling another one. How **callers** sign in to your server through another identity provider is `auth: { mode: "remote" }`, covered in [Remote and proxied auth](https://frontmcp.dev/reference/auth/remote). --- ## Usage > **Note** > > The examples on this page don't use the network: each has a `status.example.ts` file that plays an MCP server at `https://status.example/mcp`. It answers FrontMCP's requests in-process, with just enough of the protocol, and records them so tests can check them. The remote could be any MCP server; you won't need the file in a real server. ### Mounting a remote server Here the help desk's own tool and the status server's tool are served side by side. The status server's tool gets the `status:` prefix, and a call to it is forwarded under its own name: Initial call configuration: ```js { tool: "status:get_status", arguments: { service: "api" } } ``` ```ts main.ts active import "./status.example"; import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, App.remote("https://status.example/mcp", { namespace: "status" })], }) export default class Server {} ``` ```ts status.example.ts // Stands in for an MCP server at https://status.example/mcp, which the Playground // can't reach. It answers FrontMCP's requests in-process, with just enough of the // protocol, and passes everything else to the real fetch. A real server doesn't // need this file: the remote can be any MCP server. const services: Record = { api: true, billing: false }; const tools = [ { name: "get_status", description: "Current status of one service", inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"] }, }, ]; /** Every JSON-RPC message the remote server received, oldest first. */ export const received: { method: string; params?: any }[] = []; function handle(method: string, params: any) { switch (method) { case "initialize": return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } }; case "tools/list": return { tools }; case "tools/call": { const status = { service: params.arguments.service, up: services[params.arguments.service] ?? false }; return { content: [{ type: "text", text: JSON.stringify(status) }], structuredContent: status }; } } } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "status.example") return realFetch(input, init); if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream const message = JSON.parse(String(init.body)); received.push({ method: message.method, params: message.params }); if (message.id === undefined) return new Response(null, { status: 202 }); // a notification const result = handle(message.method, message.params); const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } }; return Response.json({ jsonrpc: "2.0", id: message.id, ...reply }); }; ``` ```ts remote.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { received } from "./status.example"; test("the remote's tools are listed next to the local ones, prefixed", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["close_ticket", "status:get_status"]); }); test("/readyz has a check for the remote, which is ready before its first check has run", async () => { const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status" })], health: { includeDetails: true }, }); const response = await handler(new Request("https://desk.example.com/readyz")); expect(response.status).toBe(200); expect(["degraded", "healthy"]).toContain((await response.json()).probes["remote:status"].status); }); test("FrontMCP connected with initialize, then forwards calls under the remote's own name", async ({ mcp }) => { const result = await mcp.tools.call("status:get_status", { service: "billing" }); expect(result.json()).toEqual({ service: "billing", up: false }); expect(received.slice(0, 2).map((m) => m.method)).toEqual(["initialize", "notifications/initialized"]); expect(received[0].params).toMatchObject({ protocolVersion: "2025-11-25", clientInfo: { name: "frontmcp-gateway", version: "1.0.0" } }); expect(received.at(-1)).toEqual({ method: "tools/call", params: { name: "get_status", arguments: { service: "billing" } } }); }); ``` Without `namespace`, the prefix is the app's `name`, which comes from the URL: `status` here too. Set it anyway, so the names don't change if the URL does. Local tools don't get a prefix unless their names clash with another app's, as described in [`@App`](https://frontmcp.dev/reference/sdk/app#when-two-apps-use-the-same-name). ### What clients see Everything the remote offers is served under the namespace. The remote's tool metadata is passed on, except `title` and `outputSchema`; resource URIs are kept; and results come back as the remote sent them, errors included: Initial call configuration: ```js { tool: "status:get_status", arguments: { service: "billing" } } ``` ```ts main.ts active import "./status.example"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp")], }) export default class Server {} ``` ```ts status.example.ts // Stands in for an MCP server at https://status.example/mcp, which the Playground // can't reach. It answers FrontMCP's requests in-process, with just enough of the // protocol, and passes everything else to the real fetch. A real server doesn't // need this file: the remote can be any MCP server. const services: Record = { api: true, billing: false }; const text = (value: string) => ({ content: [{ type: "text", text: value }] }); const tools = [ { name: "get_status", title: "Service status", description: "Current status of one service", inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"] }, outputSchema: { type: "object", properties: { service: { type: "string" }, up: { type: "boolean" } } }, annotations: { readOnlyHint: true }, }, { name: "list_services", inputSchema: { type: "object", properties: {} } }, ]; const resources = [{ name: "incidents", uri: "status://incidents", mimeType: "application/json" }]; const resourceTemplates = [{ name: "service", uriTemplate: "status://services/{name}", mimeType: "application/json" }]; const prompts = [{ name: "status_report", description: "Write a status report", arguments: [{ name: "audience", required: true }] }]; /** Every JSON-RPC message the remote server received, oldest first. */ export const received: { method: string; params?: any }[] = []; function handle(method: string, params: any) { switch (method) { case "initialize": return { protocolVersion: params.protocolVersion, capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: "status", version: "3.1.0" } }; case "tools/list": return { tools }; case "resources/list": return { resources }; case "resources/templates/list": return { resourceTemplates }; case "prompts/list": return { prompts }; case "tools/call": { if (params.name === "list_services") return text(Object.keys(services).join(", ")); const { service } = params.arguments; if (!(service in services)) return { ...text(`No service called ${service}`), isError: true }; const status = { service, up: services[service] }; return { ...text(JSON.stringify(status)), structuredContent: status }; } case "resources/read": { const name = params.uri.replace("status://services/", ""); const body = params.uri === "status://incidents" ? [] : { service: name, up: services[name] ?? false }; return { contents: [{ uri: params.uri, mimeType: "application/json", text: JSON.stringify(body) }] }; } case "prompts/get": return { messages: [{ role: "user", content: { type: "text", text: `Write a status report for ${params.arguments.audience}.` } }] }; } } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "status.example") return realFetch(input, init); if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream const message = JSON.parse(String(init.body)); received.push({ method: message.method, params: message.params }); if (message.id === undefined) return new Response(null, { status: 202 }); // a notification const result = handle(message.method, message.params); const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } }; return Response.json({ jsonrpc: "2.0", id: message.id, ...reply }); }; ``` ```ts clients.test.ts import { test, expect } from "@frontmcp/testing"; import { received } from "./status.example"; test("tools keep their description, schema and annotations, not title or outputSchema", async ({ mcp }) => { const [getStatus, listServices] = await mcp.tools.list(); expect(getStatus).toEqual({ name: "status:get_status", description: "Current status of one service", inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"] }, annotations: { readOnlyHint: true }, }); expect(listServices.description).toBe("Remote tool: list_services"); }); test("results and errors come back as the remote sent them", async ({ mcp }) => { expect((await mcp.tools.call("status:get_status", { service: "api" })).raw.structuredContent).toEqual({ service: "api", up: true }); const missing = await mcp.tools.call("status:get_status", { service: "search" }); expect(missing).toBeError(); expect(missing.text()).toBe("No service called search"); }); test("arguments are checked by the remote, not by FrontMCP", async ({ mcp }) => { await mcp.tools.call("status:get_status", { service: 42 }); expect(received.at(-1)).toEqual({ method: "tools/call", params: { name: "get_status", arguments: { service: 42 } } }); }); test("resources keep their URIs; templates are prefixed like the rest", async ({ mcp }) => { expect(await mcp.resources.list()).toEqual([ { name: "status:incidents", uri: "status://incidents", mimeType: "application/json", description: "Remote resource: incidents" }, ]); const templates = await mcp.resources.listTemplates(); expect(templates.map((t) => t.name)).toEqual(["status:service"]); expect((await mcp.resources.read("status://services/billing")).json()).toEqual({ service: "billing", up: false }); }); test("prompts are prefixed, and required arguments are checked first", async ({ mcp }) => { expect((await mcp.prompts.list()).map((p) => p.name)).toEqual(["status:status_report"]); const prompt = await mcp.prompts.get("status:status_report", { audience: "customers" }); expect(prompt.messages[0].content).toEqual({ type: "text", text: "Write a status report for customers." }); expect((await mcp.prompts.get("status:status_report", {})).error?.message).toBe("Missing required argument: audience"); }); ``` ### A remote with more than one page of tools A FrontMCP server pages `tools/list` past 40 tools, and other servers may page any of their lists. FrontMCP follows each list's `nextCursor` to the end, for tools, resources, resource templates and prompts, and serves everything it got. Before 1.8.6 it read only the first page, so a remote with more than 40 tools had only 40 proxied, with no error. Here the remote answers two items to a page: Initial call configuration: ```js { tool: "status:check_billing", arguments: {} } ``` ```ts main.ts active import "./status.example"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status" })], }) export default class Server {} ``` ```ts status.example.ts // Plays an MCP server at https://status.example/mcp that answers two items to a page. const names = ["check_api", "check_billing", "check_search", "check_mail", "check_chat"]; const tools = names.map((name) => ({ name, description: `Status of ${name.replace("check_", "")}`, inputSchema: { type: "object", properties: {} } })); const resources = ["incidents", "maintenance", "history"].map((name) => ({ name, uri: `status://${name}`, mimeType: "application/json" })); const PAGE_SIZE = 2; /** Every JSON-RPC message the remote server received, oldest first. */ export const received: { method: string; params?: any }[] = []; function page(items: Item[], key: string, cursor?: string) { const start = Number(cursor ?? 0); const end = start + PAGE_SIZE; return { [key]: items.slice(start, end), ...(end < items.length ? { nextCursor: String(end) } : {}) }; } function handle(method: string, params: any) { switch (method) { case "initialize": return { protocolVersion: params.protocolVersion, capabilities: { tools: {}, resources: {} }, serverInfo: { name: "status", version: "3.1.0" } }; case "tools/list": return page(tools, "tools", params?.cursor); case "resources/list": return page(resources, "resources", params?.cursor); case "resources/templates/list": return { resourceTemplates: [] }; case "prompts/list": return { prompts: [] }; case "tools/call": return { content: [{ type: "text", text: JSON.stringify({ tool: params.name }) }] }; } } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "status.example") return realFetch(input, init); if (init?.method !== "POST") return new Response(null, { status: 405 }); const message = JSON.parse(String(init.body)); received.push({ method: message.method, params: message.params }); if (message.id === undefined) return new Response(null, { status: 202 }); const result = handle(message.method, message.params); const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } }; return Response.json({ jsonrpc: "2.0", id: message.id, ...reply }); }; ``` ```ts paging.test.ts import { test, expect } from "@frontmcp/testing"; import { received } from "./status.example"; test("every page of the remote's tools is served, not only the first", async ({ mcp }) => { const names = (await mcp.tools.list()).map((tool) => tool.name); expect(names).toHaveLength(5); expect(names).toContain("status:check_chat"); }); test("FrontMCP asked for each page with the cursor the remote gave", async ({ mcp }) => { await mcp.tools.list(); const cursors = received.filter((message) => message.method === "tools/list").map((message) => message.params?.cursor); expect(cursors.slice(0, 3)).toEqual([undefined, "2", "4"]); }); test("resources are paged the same way", async ({ mcp }) => { const uris = (await mcp.resources.list()).map((resource) => resource.uri); expect(uris).toHaveLength(3); expect(uris).toContain("status://history"); }); test("a tool from the last page can be called", async ({ mcp }) => { expect((await mcp.tools.call("status:check_chat")).json()).toEqual({ tool: "check_chat" }); }); ``` ### Talking to a 2026-07-28 server By default FrontMCP opens a session with the remote, as clients did before protocol 2026-07-28. With `transportOptions.protocolVersion: "2026-07-28"`, it asks `server/discover` instead, and sends every request on its own, with the 2026-07-28 headers and `_meta`, and no session to keep. Since FrontMCP 1.9.0; before, `protocolVersion` was dropped from the options: Initial call configuration: ```js { tool: "status:get_status", arguments: { service: "api" } } ``` ```ts main.ts active import "./status.example"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { protocolVersion: "2026-07-28" } })], }) export default class Server {} ``` ```ts status.example.ts // Stands in for an MCP server at https://status.example/mcp that speaks protocol // 2026-07-28 only. It answers FrontMCP's requests in-process, with just enough of // the protocol, and passes everything else to the real fetch. A real server // doesn't need this file. const services: Record = { api: true, billing: false }; const tools = [ { name: "get_status", description: "Current status of one service", inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"] }, }, ]; /** Every request the remote server received, oldest first: its method, its MCP headers and its params. */ export const received: { method: string; headers: Record; params?: any }[] = []; function handle(method: string, params: any) { switch (method) { case "server/discover": return { supportedVersions: ["2026-07-28"], capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } }; case "tools/list": return { tools }; case "resources/list": return { resources: [] }; case "resources/templates/list": return { resourceTemplates: [] }; case "prompts/list": return { prompts: [] }; case "tools/call": { const status = { service: params.arguments.service, up: services[params.arguments.service] ?? false }; return { content: [{ type: "text", text: JSON.stringify(status) }], structuredContent: status }; } } } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "status.example") return realFetch(input, init); if (init?.method !== "POST") return new Response(null, { status: 405 }); const headers = Object.fromEntries([...new Headers(init.headers)].filter(([name]) => name.startsWith("mcp-"))); const message = JSON.parse(String(init.body)); received.push({ method: message.method, headers, params: message.params }); const result = handle(message.method, message.params); const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } }; return Response.json({ jsonrpc: "2.0", id: message.id, ...reply }); }; ``` ```ts stateless.test.ts import { test, expect } from "@frontmcp/testing"; import { received } from "./status.example"; test("FrontMCP discovers the remote instead of opening a session", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["status:get_status"]); expect(received[0].method).toBe("server/discover"); expect(received.map((r) => r.method)).not.toContain("initialize"); expect(received[0].params._meta).toEqual({ "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { name: "frontmcp-gateway", version: "1.0.0" }, "io.modelcontextprotocol/clientCapabilities": {}, }); }); test("each call carries the 2026-07-28 headers, and no session", async ({ mcp }) => { expect((await mcp.tools.call("status:get_status", { service: "billing" })).json()).toEqual({ service: "billing", up: false }); expect(received.at(-1)).toMatchObject({ method: "tools/call", headers: { "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "get_status" }, }); expect(received.every((r) => !("mcp-session-id" in r.headers))).toBe(true); }); ``` A remote that only speaks the older protocol doesn't answer `server/discover`, so with `"2026-07-28"` your server doesn't start: [`Method not found: server/discover`](https://frontmcp.dev/reference/server/remote#failed-to-connect-to-remote-mcp-server-status-at--method-not-found-serverdiscover). `"auto"` tries `server/discover` and, when it isn't answered, opens a session as `"legacy"` does, which suits a remote you don't control; that was checked in Node. ### Following a redirect on the remote server A remote can move its endpoint without changing origin. A `307` or `308` preserves the MCP `POST` and its credentials; a `301`, `302` or `303` would change its method, so the request fails. A redirect to another host fails too, even without credentials. Use the final endpoint as the remote's `url` to avoid either refusal. This stand-in returns readable redirect responses so the tests can check the transport's decisions in Node and in the Playground. A real browser fetch can hide the target instead, with `Redirect not followed: this runtime does not expose the redirect target`. ```ts main.ts import "./status.example"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status" })], }) export default class Server {} ``` ```ts redirects.test.ts active import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { received } from "./status.example"; const credentials = { mode: "static" as const, credentials: { type: "bearer" as const, value: "status-key-1" } }; function connect(path: string, authenticated: boolean) { return FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote(`https://status.example${path}`, { namespace: "status", transportOptions: { fallbackToSSE: false }, ...(authenticated ? { remoteAuth: credentials } : {}), })], }); } test("307 and 308 preserve the POST, with or without credentials", async () => { for (const authenticated of [false, true]) { for (const status of [307, 308]) { received.length = 0; const server = await connect(`/move/${status}`, authenticated); try { const result = await server.callTool("status:get_status", {}); expect(result.content).toEqual([{ type: "text", text: "all systems up" }]); const calls = received.filter((r) => r.method === "tools/call"); expect(calls.map((r) => r.path)).toEqual([`/move/${status}`, "/mcp"]); expect(calls.every((r) => r.httpMethod === "POST")).toBe(true); expect(calls.map((r) => r.authorization)).toEqual(authenticated ? ["Bearer status-key-1", "Bearer status-key-1"] : [null, null]); } finally { await server.dispose(); } } } }); test("a POST redirect that would change the method is refused", async () => { for (const status of [301, 302, 303]) { await expect(connect(`/move/${status}`, false)).rejects.toThrow("Redirect to https://status.example/mcp not followed"); } }); test("a redirect to another origin is refused, with or without credentials", async () => { for (const authenticated of [false, true]) { received.length = 0; await expect(connect("/elsewhere", authenticated)).rejects.toThrow("Redirect to https://other.example/mcp not followed"); expect(received.every((r) => r.host === "status.example")).toBe(true); } }); ``` ```ts status.example.ts // Stands in for a remote endpoint that redirects. No network is used. export const received: { host: string; path: string; httpMethod?: string; method?: string; authorization: string | null }[] = []; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (!["status.example", "other.example"].includes(url.host)) return realFetch(input, init); const message = init?.method === "POST" ? JSON.parse(String(init.body)) : undefined; received.push({ host: url.host, path: url.pathname, httpMethod: init?.method, method: message?.method, authorization: new Headers(init?.headers).get("authorization") }); if (url.pathname.startsWith("/move/")) { return new Response(null, { status: Number(url.pathname.split("/").at(-1)), headers: { location: "/mcp" } }); } if (url.pathname === "/elsewhere") return new Response(null, { status: 307, headers: { location: "https://other.example/mcp" } }); if (!message) return new Response(null, { status: 405 }); if (message.id === undefined) return new Response(null, { status: 202 }); let result: unknown; switch (message.method) { case "initialize": result = { protocolVersion: message.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } }; break; case "tools/list": result = { tools: [{ name: "get_status", description: "Current service status", inputSchema: { type: "object", properties: {} } }] }; break; case "tools/call": result = { content: [{ type: "text", text: "all systems up" }] }; break; } const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } }; return Response.json({ jsonrpc: "2.0", id: message.id, ...reply }); }; ``` ### Authenticating to the remote server Give the remote its credentials with `remoteAuth: { mode: "static", credentials }`. FrontMCP sends them with every request to the remote, starting with `initialize`. On the default Streamable HTTP transport, it follows only redirects that stay on the same origin and keep the method; see [redirect handling](https://frontmcp.dev/reference/server/remote#following-a-redirect-on-the-remote-server). Read the secret from the environment, not from your code: Initial call configuration: ```js { tool: "status:get_status", arguments: { service: "api" } } ``` ```ts main.ts active import "./status.example"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [ App.remote("https://status.example/mcp", { namespace: "status", remoteAuth: { mode: "static", credentials: { type: "bearer", value: process.env.STATUS_TOKEN! } }, }), ], }) export default class Server {} ``` ```ts status.example.ts // Stands in for an MCP server at https://status.example/mcp that only answers // requests with the right token. It answers FrontMCP's requests in-process, and // passes everything else to the real fetch. A real server doesn't need this file. // Your deployment would set this. Here the stand-in does. process.env.STATUS_TOKEN = "status-key-1"; /** The Authorization header and redirect mode of every request the remote server received, oldest first. */ export const received: { authorization: string | null; redirect: RequestRedirect | undefined }[] = []; function handle(method: string, params: any) { switch (method) { case "initialize": return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } }; case "tools/list": return { tools: [{ name: "get_status", description: "Current status of one service", inputSchema: { type: "object", properties: { service: { type: "string" } } } }] }; case "tools/call": return { content: [{ type: "text", text: `${params.arguments.service} is up` }] }; } } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "status.example") return realFetch(input, init); const authorization = new Headers(init?.headers).get("authorization"); received.push({ authorization, redirect: init?.redirect }); if (authorization !== "Bearer status-key-1") return new Response("Unauthorized", { status: 401 }); if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream const message = JSON.parse(String(init.body)); if (message.id === undefined) return new Response(null, { status: 202 }); // a notification const result = handle(message.method, message.params); const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } }; return Response.json({ jsonrpc: "2.0", id: message.id, ...reply }); }; ``` ```ts auth.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { received } from "./status.example"; test("every fetch carries the credentials and asks for manual redirect handling", async ({ mcp }) => { expect((await mcp.tools.call("status:get_status", { service: "api" })).text()).toBe("api is up"); expect(new Set(received.map((r) => r.authorization))).toEqual(new Set(["Bearer status-key-1"])); expect(new Set(received.map((r) => r.redirect))).toEqual(new Set(["manual"])); }); test("transportOptions.headers works too", async () => { const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { headers: { Authorization: "Bearer status-key-1" } } })], }); const result = await server.callTool("status:get_status", { service: "db" }); await server.dispose(); expect(result.content).toEqual([{ type: "text", text: "db is up" }]); }); test("without credentials, the server can't start", async () => { const starting = FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status" })], }); await expect(starting).rejects.toThrow( 'Failed to connect to remote MCP server "status" at https://status.example/mcp: SSE error: Non-200 status code (401)', ); }); test("callers still need your server's own credentials", async () => { const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, auth: { mode: "static", tokens: ["desk-key-1"] }, apps: [App.remote("https://status.example/mcp", { remoteAuth: { mode: "static", credentials: { type: "bearer", value: "status-key-1" } } })], }); const response = await handler(new Request("http://localhost/", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })); expect(response.status).toBe(401); }); ``` The credentials are between your server and the remote. Callers still authenticate to your server with its own [`auth`](https://frontmcp.dev/reference/sdk/auth), as the last test shows, and call remote tools like any other. | `credentials.type` | Sent as | | --- | --- | | `"bearer"` | `Authorization: Bearer ` | | `"basic"` | `Authorization: Basic `. The value is sent as it is, so encode `user:password` in Base64 yourself. | | `"apiKey"` | `: `, with `headerName` defaulting to `X-API-Key`. | `transportOptions.headers` reaches the remote the same way, for any header you need. When both set the same header, `remoteAuth` wins. `remoteAuth: { mode: "oauth" }` is accepted, but sends nothing: against a remote that needs credentials, the server doesn't start. #### Passing each caller's token on When the remote trusts the same identity provider as your server, it can see who is calling. `remoteAuth: { mode: "forward" }` sends each tool call with the caller's own token, as `Authorization: Bearer `. Only a server in [`transparent` mode](https://frontmcp.dev/reference/auth/modes) receives its callers' tokens from the identity provider; any other server refuses to start with `forward`, since the token it holds was minted for itself. Starting up, FrontMCP connects to the remote and lists its tools without credentials, so the remote has to answer those without a token: ```ts forward.test.ts active import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { calls } from "./status.example"; import { JWKS, NOUR, SAM } from "./tokens"; const app = App.remote("https://status.example/mcp", { namespace: "status", remoteAuth: { mode: "forward" } }); const config = { info: { name: "help-desk", version: "1.0.0" }, auth: { mode: "transparent" as const, provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", allowAnonymous: true, providerConfig: { jwks: JWKS }, }, apps: [app], }; /** Calls a remote tool over HTTP, as a client signed in with `token` would. */ async function callAs(token: string | undefined, name: string) { const handler = await FrontMcpInstance.createFetchHandler(config); const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name, ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }), }), ); return (await response.json()).result.content[0].text; } test("each call carries its caller's token", async () => { expect(await callAs(NOUR, "status:whoami")).toBe(`Bearer ${NOUR}`); expect(await callAs(SAM, "status:whoami")).toBe(`Bearer ${SAM}`); }); test("an anonymous caller's call carries none", async () => { expect(await callAs(undefined, "status:whoami")).toBe("no token"); }); test("connecting and listing carry none either", async () => { await callAs(NOUR, "status:whoami"); expect(calls.filter((c) => c.method !== "tools/call").every((c) => c.authorization === null)).toBe(true); }); test("a server that isn't transparent refuses to start", async () => { await expect(FrontMcpInstance.createFetchHandler({ info: config.info, apps: [app] })).rejects.toThrow( "remoteAuth: { mode: 'forward' } on status would send the remote a token this server minted or holds itself", ); }); ``` ```ts status.example.ts // Stands in for an MCP server at https://status.example/mcp whose whoami tool // answers with the Authorization header it received. It answers FrontMCP's requests // in-process, and passes everything else to the real fetch. A real server doesn't // need this file. /** The method and Authorization header of every request the remote server received. */ export const calls: { method: string; authorization: string | null }[] = []; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "status.example") return realFetch(input, init); if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream const message = JSON.parse(String(init.body)); const authorization = new Headers(init?.headers).get("authorization"); calls.push({ method: message.method, authorization }); if (message.id === undefined) return new Response(null, { status: 202 }); // a notification const result = message.method === "initialize" ? { protocolVersion: message.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } } : message.method === "tools/list" ? { tools: [{ name: "whoami", description: "Who the remote thinks is calling", inputSchema: { type: "object", properties: {} } }] } : message.method === "tools/call" ? { content: [{ type: "text", text: authorization ?? "no token" }] } : undefined; const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } }; return Response.json({ jsonrpc: "2.0", id: message.id, ...reply }); }; ``` ```ts tokens.ts // Access tokens from auth.example.com, signed ahead of time with the key in JWKS. // nour is an agent and sam a customer, both at acme. export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsIm5hbWUiOiJOb3VyIEhhZGRhZCIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJhY21lIiwic2l0ZXMiOlsiYmVybGluIiwibGlzYm9uIl0sImh0dHBzOi8vZGVzay5leGFtcGxlLmNvbS9yb2xlcyI6WyJsZWFkIl19.u6qb8pAPftFUuehhbk66UgD3jymiUiJ8_sSD5D_-Tb3COSuxJXFcSrSmquWh1z_5awaC9QUrYtVYptr6XBxnrA"; export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwibmFtZSI6IlNhbSBPcnRpeiIsInNjb3BlIjoidGlja2V0czpyZWFkIiwidGVuYW50IjoiYWNtZSIsInNpdGVzIjpbImJlcmxpbiJdfQ.qv5lgslG_X822NsMQ8Hdy39WO3Zrzq1JORDhGABpgNoQ1QIIcFQlQ_S6qKag2AOKZB5z20s4YA_eMxOc9a-N-A"; // The provider's public key. Usually FrontMCP fetches it; here it's inline so the example runs offline. export const JWKS = { keys: [{"kty":"EC","x":"E5CNMEh_im_-kUoq-qbsGQa7NQBkf3vt8hxAFPH9OGk","y":"HCve2s_TG0bffFc8xEokraDFtq2SWxIE6z2GJfAY52g","crv":"P-256","kid":"desk-3","alg":"ES256","use":"sig"}] }; ``` ```ts main.ts import "./status.example"; import { App, FrontMcp } from "@frontmcp/sdk"; // The Playground has no sign-in, so the tests start the transparent server themselves. @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status" })] }) export default class Server {} ``` `tokenClaim: ""` forwards that claim of the caller's token instead of the whole token, and `headerName` sends it in another header. A caller without a token, like an anonymous one, reaches the remote with none, and FrontMCP logs `No gateway auth token to forward for status`. #### Credentials for each caller When the remote has credentials of its own for each of your callers, like an API key per customer, `remoteAuth: { mode: "mapped", mapper }` picks them. FrontMCP calls `mapper` with the caller's auth info, `{ token, clientId, scopes, … }`, for each tool call, resource read and prompt request, and sends the credentials it returns, of any `credentials.type` above. It works whatever your server's `auth` mode. A mapper that throws fails the call with `RemoteAuthError`, and so does a remote that answers `401`. Since 1.9.3; before, `App.remote()` rejected `mapped`. Here Nour has a key, Sam's key has been revoked, and an anonymous caller has none: ```ts mapped.test.ts active import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { calls } from "./status.example"; import { JWKS, NOUR, SAM } from "./tokens"; const statusKeys: Record = { nour: "status-key-nour", sam: "status-key-revoked" }; const app = App.remote("https://status.example/mcp", { namespace: "status", remoteAuth: { mode: "mapped", mapper: (authInfo) => { const key = statusKeys[authInfo?.clientId ?? ""]; if (!key) throw new Error(`no status key for ${authInfo?.clientId}`); return { type: "apiKey", value: key }; }, }, }); /** Calls a remote tool over HTTP, as a client signed in with `token` would. */ async function callAs(token: string | undefined) { const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, auth: { mode: "transparent", provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", allowAnonymous: true, providerConfig: { jwks: JWKS } }, apps: [app], }); const response = await handler( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "status:get_status", ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "status:get_status", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }), }), ); return (await response.json()).result; } test("each call carries the key the mapper picked for its caller", async () => { expect((await callAs(NOUR)).content[0].text).toBe("all systems up"); expect(calls.at(-1)).toEqual({ method: "tools/call", apiKey: "status-key-nour" }); }); test("a mapper that throws fails the call with RemoteAuthError", async () => { const result = await callAs(undefined); expect(result.isError).toBe(true); expect(result.content[0].text).toMatch(/^Authentication failed for remote server "status": Auth mapping failed: no status key for anon:/); expect(result._meta.code).toBe("REMOTE_AUTH_ERROR"); }); test("so does a key the remote refuses", async () => { const result = await callAs(SAM); expect(result.isError).toBe(true); expect(result.content[0].text).toBe('Authentication failed for remote server "status": the remote server refused the credentials (HTTP 401)'); }); test("connecting and listing carry no key", async () => { await callAs(NOUR); expect(calls.filter((c) => c.method !== "tools/call").every((c) => c.apiKey === null)).toBe(true); }); ``` ```ts status.example.ts // Stands in for an MCP server at https://status.example/mcp that runs tools only // for a valid API key. It answers FrontMCP's requests in-process, and passes // everything else to the real fetch. A real server doesn't need this file. /** The method and X-API-Key header of every request the remote server received. */ export const calls: { method: string; apiKey: string | null }[] = []; const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "status.example") return realFetch(input, init); if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream const message = JSON.parse(String(init.body)); const apiKey = new Headers(init?.headers).get("x-api-key"); calls.push({ method: message.method, apiKey }); if (message.id === undefined) return new Response(null, { status: 202 }); // a notification if (message.method === "tools/call" && apiKey !== "status-key-nour") return new Response("invalid API key", { status: 401 }); const result = message.method === "initialize" ? { protocolVersion: message.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } } : message.method === "tools/list" ? { tools: [{ name: "get_status", description: "Current status of every service", inputSchema: { type: "object", properties: {} } }] } : message.method === "tools/call" ? { content: [{ type: "text", text: "all systems up" }] } : undefined; const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } }; return Response.json({ jsonrpc: "2.0", id: message.id, ...reply }); }; ``` ```ts tokens.ts // Access tokens from auth.example.com, signed ahead of time with the key in JWKS. // nour is an agent and sam a customer, both at acme. export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsIm5hbWUiOiJOb3VyIEhhZGRhZCIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJhY21lIiwic2l0ZXMiOlsiYmVybGluIiwibGlzYm9uIl0sImh0dHBzOi8vZGVzay5leGFtcGxlLmNvbS9yb2xlcyI6WyJsZWFkIl19.u6qb8pAPftFUuehhbk66UgD3jymiUiJ8_sSD5D_-Tb3COSuxJXFcSrSmquWh1z_5awaC9QUrYtVYptr6XBxnrA"; export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwibmFtZSI6IlNhbSBPcnRpeiIsInNjb3BlIjoidGlja2V0czpyZWFkIiwidGVuYW50IjoiYWNtZSIsInNpdGVzIjpbImJlcmxpbiJdfQ.qv5lgslG_X822NsMQ8Hdy39WO3Zrzq1JORDhGABpgNoQ1QIIcFQlQ_S6qKag2AOKZB5z20s4YA_eMxOc9a-N-A"; // The provider's public key. Usually FrontMCP fetches it; here it's inline so the example runs offline. export const JWKS = { keys: [{"kty":"EC","x":"E5CNMEh_im_-kUoq-qbsGQa7NQBkf3vt8hxAFPH9OGk","y":"HCve2s_TG0bffFc8xEokraDFtq2SWxIE6z2GJfAY52g","crv":"P-256","kid":"desk-3","alg":"ES256","use":"sig"}] }; ``` ```ts main.ts import "./status.example"; import { App, FrontMcp } from "@frontmcp/sdk"; // The Playground has no sign-in, so the tests start the server themselves. @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status" })] }) export default class Server {} ``` The client gets an `isError` result with `_meta.code` `REMOTE_AUTH_ERROR`, and its message is kept in production. Like `forward`, `mapped` sends nothing while connecting and listing, so the remote has to answer those without credentials. ### Limiting how long a call can take `transportOptions.timeout` caps each try of a tool call. A try that runs out is [retried](https://frontmcp.dev/reference/server/remote#retrying-calls-that-fail-on-the-way), and when the last one runs out too, the client gets `REMOTE_TIMEOUT_ERROR`. The remote isn't told to stop, so it carries on with every try, and a slow tool runs once per try: Initial call configuration: ```js { tool: "status:uptime_report", arguments: {} } ``` ```ts main.ts active import "./status.example"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { timeout: 100, retryDelayMs: 50 } })], }) export default class Server {} ``` ```ts status.example.ts // Stands in for an MCP server at https://status.example/mcp whose uptime report // takes 300 ms. It answers FrontMCP's requests in-process, and passes everything // else to the real fetch. A real server doesn't need this file. /** Reports the remote server finished, even after FrontMCP stopped waiting. */ export const finished: string[] = []; async function handle(method: string, params: any) { switch (method) { case "initialize": return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } }; case "tools/list": return { tools: [{ name: "uptime_report", description: "Uptime of every service over 90 days", inputSchema: { type: "object", properties: {} } }] }; case "tools/call": await new Promise((resolve) => setTimeout(resolve, 300)); finished.push(params.name); return { content: [{ type: "text", text: "api 99.95%, billing 99.9%" }] }; } } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "status.example") return realFetch(input, init); if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream const message = JSON.parse(String(init.body)); if (message.id === undefined) return new Response(null, { status: 202 }); // a notification const result = await handle(message.method, message.params); const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } }; return Response.json({ jsonrpc: "2.0", id: message.id, ...reply }); }; ``` ```ts timeout.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { finished } from "./status.example"; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("each of the 3 tries times out, and the remote finishes all 3", async ({ mcp }) => { finished.length = 0; const result = await mcp.tools.call("status:uptime_report", {}); expect(result).toBeError("REMOTE_TIMEOUT_ERROR"); expect(result.text()).toBe('Remote operation "uptime_report" on "status" timed out after 100ms'); await wait(300); expect(finished).toEqual(["uptime_report", "uptime_report", "uptime_report"]); }); test("with retryAttempts: 0, it runs once", async () => { const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { timeout: 100, retryAttempts: 0 } })], }); finished.length = 0; await expect(server.callTool("status:uptime_report", {})).rejects.toThrow('Remote operation "uptime_report" on "status" timed out after 100ms'); await wait(300); await server.dispose(); expect(finished).toEqual(["uptime_report"]); }); ``` For a tool that mustn't run twice, like one that sends a message or charges a card, set `retryAttempts: 0` on its remote app, as the second test does, or make the remote ignore a repeat. Each timed-out try counts as a failure for the [circuit breaker](https://frontmcp.dev/reference/server/remote#circuit-breaker-is-open-for-status-retry-in-30s). The timeout only covers tool calls: a resource read or a prompt waits as long as the remote takes. (Changed in 1.9.3: before, a call that ran out of `timeout` wasn't retried, so the remote ran it once.) ### Retrying calls that fail on the way A tool call that fails in a way that may pass is tried again: by default twice more, 1 and then 2 seconds apart. That's a network failure, like `fetch failed` or a refused or reset connection, a try that ran out of [`timeout`](https://frontmcp.dev/reference/server/remote#limiting-how-long-a-call-can-take), or an HTTP answer of `408`, `425`, `429`, or `5xx` other than `501` and `505`. FrontMCP goes by the status, not by the body. `retryAttempts` sets how many more times, and `retryDelayMs` the first wait. Here the connection drops twice, and the third try gets through: Initial call configuration: ```js { tool: "status:get_status", arguments: {} } ``` ```ts main.ts active import "./status.example"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { retryAttempts: 2, retryDelayMs: 50 } })], }) export default class Server {} ``` ```ts status.example.ts // Stands in for an MCP server at https://status.example/mcp whose tool calls fail // the way `failures` says, then succeed. It answers FrontMCP's requests in-process, // and passes everything else to the real fetch. A real server doesn't need this file. /** How the next tool calls fail, one entry each: "drop" for the connection, or an HTTP status. Tests set it. */ export const failures: ("drop" | number)[] = ["drop", "drop"]; /** How many tool calls reached the remote, the failed ones included. */ export const attempts = { count: 0 }; function handle(method: string, params: any) { switch (method) { case "initialize": return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } }; case "server/discover": return { supportedVersions: ["2026-07-28"], capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } }; case "tools/list": return { tools: [{ name: "get_status", description: "Current status of every service", inputSchema: { type: "object", properties: {} } }] }; case "tools/call": return { content: [{ type: "text", text: "all systems up" }] }; } } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "status.example") return realFetch(input, init); if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream const message = JSON.parse(String(init.body)); if (message.id === undefined) return new Response(null, { status: 202 }); // a notification if (message.method === "tools/call") { attempts.count++; const failure = failures.shift(); if (failure === "drop") throw new TypeError("fetch failed"); if (failure) return new Response("try again later", { status: failure, headers: { "Retry-After": "1" } }); } const result = handle(message.method, message.params); const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } }; return Response.json({ jsonrpc: "2.0", id: message.id, ...reply }); }; ``` ```ts retry.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; import { attempts, failures } from "./status.example"; /** Calls the tool once, after the remote's tool calls are set to fail as `plan` says. */ async function callFailing(mcp: any, plan: ("drop" | number)[]) { failures.splice(0, failures.length, ...plan); attempts.count = 0; return mcp.tools.call("status:get_status", {}); } test("two dropped connections, then the third try answers", async ({ mcp }) => { expect((await callFailing(mcp, ["drop", "drop"])).text()).toBe("all systems up"); expect(attempts.count).toBe(3); }); test("a 503 and a 429 are tried again too, without reading Retry-After", async ({ mcp }) => { const started = Date.now(); expect((await callFailing(mcp, [503, 429])).text()).toBe("all systems up"); expect(attempts.count).toBe(3); expect(Date.now() - started).toBeLessThan(1000); }); test("a 400 is the answer at once", async ({ mcp }) => { const result = await callFailing(mcp, [400]); expect(result).toBeError("REMOTE_TOOL_EXECUTION_ERROR"); expect(result.text()).toBe('Remote tool "get_status" on "status" failed: Streamable HTTP error: Error POSTing to endpoint: try again later'); expect(attempts.count).toBe(1); }); test("with retryAttempts: 0, the first failure is the answer", async () => { const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { retryAttempts: 0 } })], }); failures.splice(0, failures.length, "drop"); attempts.count = 0; await expect(server.callTool("status:get_status", {})).rejects.toThrow('Remote tool "get_status" on "status" failed: fetch failed'); await server.dispose(); expect(attempts.count).toBe(1); }); test("a 2026-07-28 remote's Retry-After sets the wait", async () => { const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { protocolVersion: "2026-07-28", retryDelayMs: 50 } })], }); failures.splice(0, failures.length, 503); const started = Date.now(); const result = await server.callTool("status:get_status", {}); await server.dispose(); expect(result.content).toEqual([{ type: "text", text: "all systems up" }]); expect(Date.now() - started).toBeGreaterThanOrEqual(1000); }); ``` The remote answers each failure with `Retry-After: 1`. On a [2026-07-28 remote](https://frontmcp.dev/reference/server/remote#talking-to-a-2026-07-28-server), that header replaces the wait, up to 10 seconds, as the last test shows: the retry waits a second, not 50 ms. On the default `"legacy"` session, FrontMCP doesn't read it, and waits `retryDelayMs`. Each failed try counts toward the [circuit breaker](https://frontmcp.dev/reference/server/remote#circuit-breaker-is-open-for-status-retry-in-30s), so one call that fails three times counts three times. A `401` isn't retried: it fails with [`RemoteAuthError`](https://frontmcp.dev/reference/server/remote#credentials-for-each-caller). Changed in 1.9.3: before, FrontMCP looked for words like `fetch failed` or `503` in the error's message, which carries the remote's response body, not its status, so a `503` wasn't retried, nor a call that ran out of `timeout`, and `Retry-After` wasn't read. A call that timed out or got a `5xx` may have run on the remote already, so a retry can run it twice: give a remote whose tools mustn't run twice `retryAttempts: 0`. ### Picking up changes on the remote server FrontMCP keeps the remote's lists for `cacheTTL` milliseconds, a minute by default. The next request after that asks the remote again, and replaces the remote's tools, resources and prompts with what it lists now: new tools appear, and tools the remote removed disappear. Before 1.9.0, removed tools stayed listed, and calling them failed with `REMOTE_TOOL_NOT_FOUND`: Initial call configuration: ```js { tool: "status:get_status", arguments: {} } ``` ```ts main.ts active import "./status.example"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status", cacheTTL: 200 })], }) export default class Server {} ``` ```ts status.example.ts // Stands in for an MCP server at https://status.example/mcp whose tools change // while it runs. It answers FrontMCP's requests in-process, and passes everything // else to the real fetch. A real server doesn't need this file. const tool = (name: string) => ({ name, description: `The ${name} tool`, inputSchema: { type: "object", properties: {} } }); /** The remote's tools, right now. Tests change it. */ export const tools = [tool("get_status")]; export const addTool = (name: string) => tools.push(tool(name)); export const removeTool = (name: string) => tools.splice(tools.findIndex((t) => t.name === name), 1); function handle(method: string, params: any) { switch (method) { case "initialize": return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } }; case "tools/list": return { tools }; case "tools/call": return { content: [{ type: "text", text: `${params.name} ran` }] }; } } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "status.example") return realFetch(input, init); if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream const message = JSON.parse(String(init.body)); if (message.id === undefined) return new Response(null, { status: 202 }); // a notification const result = handle(message.method, message.params); const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } }; return Response.json({ jsonrpc: "2.0", id: message.id, ...reply }); }; ``` ```ts changes.test.ts import { test, expect } from "@frontmcp/testing"; import { addTool, removeTool } from "./status.example"; const names = async (mcp: any) => (await mcp.tools.list()).map((t: { name: string }) => t.name); const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("a new remote tool appears once cacheTTL has passed", async ({ mcp }) => { expect(await names(mcp)).toEqual(["status:get_status"]); addTool("list_incidents"); expect(await names(mcp)).toEqual(["status:get_status"]); await wait(250); expect(await names(mcp)).toEqual(["status:get_status", "status:list_incidents"]); expect((await mcp.tools.call("status:list_incidents", {})).text()).toBe("list_incidents ran"); }); test("a removed remote tool is dropped once cacheTTL has passed", async ({ mcp }) => { removeTool("get_status"); await wait(250); expect(await names(mcp)).not.toContain("status:get_status"); const result = await mcp.tools.call("status:get_status", {}); expect(result).toBeError("TOOL_NOT_FOUND"); expect(result.text()).toBe('Tool "status:get_status" not found'); }); ``` A shorter `cacheTTL` finds changes sooner, at the cost of four list requests to the remote each time it runs out. `refreshInterval` asks the remote for its lists in the background instead, every so many milliseconds. It notices a change only when the **number** of tools, resources or prompts changes: then the next request registers the new ones, as above. A tool renamed on the remote, with the count unchanged, isn't picked up until `cacheTTL` runs out. Only the first remote app's `refreshInterval` counts, for every remote app on the server. That was checked in Node; the examples here don't use it, because it keeps asking for as long as the server runs. ### Serving part of a remote server `filter` picks which of the remote's tools, resources and prompts your server serves. Patterns match the remote's own names, before the namespace, and `*` matches any run of characters. Here clients get the status server's read tools, but not the one that deletes incidents: Initial call configuration: ```js { tool: "status:get_status", arguments: {} } ``` ```ts main.ts active import "./status.example"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [ App.remote("https://status.example/mcp", { namespace: "status", filter: { exclude: { tools: ["delete_*"] } }, }), ], }) export default class Server {} ``` ```ts status.example.ts // Stands in for an MCP server at https://status.example/mcp. It answers FrontMCP's // requests in-process, and passes everything else to the real fetch. A real server // doesn't need this file. const tools = ["get_status", "delete_incident"].map((name) => ({ name, description: `The ${name} tool`, inputSchema: { type: "object", properties: {} } })); function handle(method: string, params: any) { switch (method) { case "initialize": return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } }; case "tools/list": return { tools }; case "tools/call": return { content: [{ type: "text", text: `${params.name} ran` }] }; } } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "status.example") return realFetch(input, init); if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream const message = JSON.parse(String(init.body)); if (message.id === undefined) return new Response(null, { status: 202 }); // a notification const result = handle(message.method, message.params); const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } }; return Response.json({ jsonrpc: "2.0", id: message.id, ...reply }); }; ``` ```ts filter.test.ts import { test, expect } from "@frontmcp/testing"; test("the excluded tool isn't listed", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["status:get_status"]); }); test("or forwarded", async ({ mcp }) => { const result = await mcp.tools.call("status:delete_incident", {}); expect(result).toBeError(); expect(result.text()).toBe('Tool "status:delete_incident" not found'); }); test("the remote's other tools work as usual", async ({ mcp }) => { expect((await mcp.tools.call("status:get_status", {})).text()).toBe("get_status ran"); }); ``` To serve only what you list, start from nothing: `filter: { default: "exclude", include: { tools: ["get_*"] } }`. `include` and `exclude` take `tools`, `resources` and `prompts`, each a list of patterns. The remote itself still has the tools: `filter` only decides what your server lists and forwards. ### Forwarding one tool `Tool.remote(url, name)` takes a single tool from a remote server and puts it in one of your apps, next to your own tools. It keeps the remote's name, with no namespace, and the rest of the remote's tools aren't served. `Resource.remote()` and `Prompt.remote()` do the same for a resource or a prompt. Since FrontMCP 1.9.1; before, every list rejected what they return. Initial call configuration: ```js { tool: "get_status", arguments: { service: "billing" } } ``` ```ts main.ts active import "./status.example"; import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket, Tool.remote("https://status.example/mcp", "get_status")], }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }) export default class Server {} ``` ```ts status.example.ts // Stands in for an MCP server at https://status.example/mcp, which the Playground // can't reach. It answers FrontMCP's requests in-process, with just enough of the // protocol, and passes everything else to the real fetch. A real server doesn't // need this file: the remote can be any MCP server. const services: Record = { api: true, billing: false }; const tools = [ { name: "get_status", description: "Current status of one service", inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"] }, }, { name: "delete_incident", description: "Delete an incident", inputSchema: { type: "object", properties: { id: { type: "string" } } } }, ]; /** Every JSON-RPC message the remote server received, oldest first. */ export const received: { method: string; params?: any }[] = []; function handle(method: string, params: any) { switch (method) { case "initialize": return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } }; case "tools/list": return { tools }; case "tools/call": { const status = { service: params.arguments.service, up: services[params.arguments.service] ?? false }; return { content: [{ type: "text", text: JSON.stringify(status) }], structuredContent: status }; } } } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "status.example") return realFetch(input, init); if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream const message = JSON.parse(String(init.body)); received.push({ method: message.method, params: message.params }); if (message.id === undefined) return new Response(null, { status: 202 }); // a notification const result = handle(message.method, message.params); const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } }; return Response.json({ jsonrpc: "2.0", id: message.id, ...reply }); }; ``` ```ts per-entry.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance, Tool } from "@frontmcp/sdk"; import { received } from "./status.example"; test("only that tool is served, under its own name", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["close_ticket", "get_status"]); }); test("calls are forwarded to the remote", async ({ mcp }) => { expect((await mcp.tools.call("get_status", { service: "api" })).json()).toEqual({ service: "api", up: true }); expect(received.at(-1)).toEqual({ method: "tools/call", params: { name: "get_status", arguments: { service: "api" } } }); }); test("`metadata` renames it or changes its description", async () => { @App({ id: "desk", name: "Desk", tools: [Tool.remote("https://status.example/mcp", "get_status", { metadata: { name: "service_status" } })] }) class Desk {} const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] }); expect((await server.listTools()).tools.map((t) => t.name)).toEqual(["service_status"]); await server.dispose(); }); test("a name the remote doesn't have stops the server from starting", async () => { @App({ id: "desk", name: "Desk", tools: [Tool.remote("https://status.example/mcp", "get_stauts")] }) class Desk {} await expect(FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] })).rejects.toThrow( 'Tool "get_stauts" was not found in https://status.example/mcp (tools there: get_status, delete_incident)', ); }); ``` `Tool.remote()` takes `transportOptions` and `metadata`; entries with the same URL and options share one connection. `Agent.remote()`, `Skill.remote()` and `Job.remote()` exist too, but there's nothing to load them: a server that lists one doesn't start, with `ExternalEntryNotSupportedError`. --- ## Troubleshooting ### `Redirect to not followed` The default `"legacy"` Streamable HTTP transport refused the remote's redirect. It follows only redirects within the same origin that keep the method; a `POST` needs `307` or `308`. Point `App.remote()` at the final MCP endpoint, or have the remote preserve the method when redirecting within its origin. Set `transportOptions: { fallbackToSSE: false }` to see the Streamable HTTP error while connecting, rather than the fallback's `SSE error`. See [redirect handling](https://frontmcp.dev/reference/server/remote#following-a-redirect-on-the-remote-server). Changed in 1.10.3: before, requests without credentials followed every redirect and requests with credentials followed none. ### `Redirect not followed: this runtime does not expose the redirect target` A browser fetch returned an opaque redirect response, so the transport can't read its target. Set the remote's `url` to the final MCP endpoint. The redirect Playground uses readable stand-in responses to check the policy; real browser fetches can behave differently. ### `Redirect to plain http not followed; try instead` The remote's HTTPS endpoint redirected to HTTP. Point `App.remote()` at the final HTTPS MCP endpoint instead. ### `Failed to connect to remote MCP server "status" at …: SSE error: Non-200 status code (401)` Your server couldn't connect to the remote, so it didn't start. FrontMCP first tried the Streamable HTTP transport, then the older SSE one, and this is the SSE attempt's error. The status says why: - **`401` or `403`**: the remote wants credentials, or refused the ones it got. Send them with `remoteAuth: { mode: "static", credentials }` or `transportOptions.headers`; `remoteAuth: { mode: "oauth" }`, `forward` and `mapped` send nothing while connecting. See [authenticating](https://frontmcp.dev/reference/server/remote#authenticating-to-the-remote-server). - **`404`**: the URL's path is wrong. Use the remote's MCP endpoint, like `/mcp`, not its home page. - **`fetch failed`**, `ECONNREFUSED`: the remote isn't running, or the host or port is wrong. The remote must be up before your server starts. ### `Failed to connect to remote MCP server "status" at …: Method not found: server/discover` The app has `transportOptions.protocolVersion: "2026-07-28"`, and the remote doesn't speak that revision. Use `"auto"`, which falls back to a session, or leave `protocolVersion` out. See [a 2026-07-28 remote](https://frontmcp.dev/reference/server/remote#talking-to-a-2026-07-28-server). ### `Remote operation "…" on "status" timed out after 30000ms` The remote took longer than `transportOptions.timeout` to answer a tool call, on every try. Raise the timeout for a remote that's slow on purpose, or find out why it's slow. The remote keeps working on each try after FrontMCP gives up, so it may have run the call 3 times: see [limiting how long a call can take](https://frontmcp.dev/reference/server/remote#limiting-how-long-a-call-can-take). ### `Remote tool "…" on "status" failed: …` The remote couldn't be reached during the call, or answered with a protocol or HTTP error. The text after the colon is the underlying error: `fetch failed` for a network failure, `Streamable HTTP error: Error POSTing to endpoint: …` with the remote's response body for an HTTP error, or `HTTP 503: …` from a 2026-07-28 remote. A network failure, a `408`, `425`, `429` or a `5xx` other than `501` and `505` comes after 3 tries; any other status after one. A tool that ran and failed on the remote isn't this: its own `isError` result comes back as it is. ### `Circuit breaker is open for status. Retry in 30s` Calls to this remote failed 5 times within a minute, so FrontMCP stopped forwarding them for 30 seconds. Every tool of the remote fails like this until then, even one that works; in production the client sees "Internal FrontMCP error" with `TOOL_EXECUTION_ERROR`. Fix what made the calls fail, and wait. Each try counts, so a call that is [retried](https://frontmcp.dev/reference/server/remote#retrying-calls-that-fail-on-the-way) and fails, like the HTTP `500` here, counts 3 times, and the second one opens the circuit. FrontMCP checks the breaker when a call starts, so that second call still makes its 3 tries: Initial call configuration: ```js { tool: "status:get_status", arguments: {} } ``` ```ts main.ts active import "./status.example"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { retryDelayMs: 50 } })], }) export default class Server {} ``` ```ts status.example.ts // Stands in for an MCP server at https://status.example/mcp whose flaky_report // tool answers HTTP 500. It answers FrontMCP's requests in-process, and passes // everything else to the real fetch. A real server doesn't need this file. const tools = ["get_status", "flaky_report"].map((name) => ({ name, description: `The ${name} tool`, inputSchema: { type: "object", properties: {} } })); function handle(method: string, params: any) { switch (method) { case "initialize": return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } }; case "tools/list": return { tools }; case "tools/call": return { content: [{ type: "text", text: "all systems up" }] }; } } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "status.example") return realFetch(input, init); if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream const message = JSON.parse(String(init.body)); if (message.id === undefined) return new Response(null, { status: 202 }); // a notification if (message.params?.name === "flaky_report") return new Response("database unavailable", { status: 500 }); const result = handle(message.method, message.params); const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } }; return Response.json({ jsonrpc: "2.0", id: message.id, ...reply }); }; ``` ```ts breaker.test.ts import { test, expect } from "@frontmcp/testing"; test("two failed calls, 3 tries each, open the circuit for the whole remote", async ({ mcp }) => { for (let i = 0; i < 2; i++) { const result = await mcp.tools.call("status:flaky_report", {}); expect(result).toBeError("REMOTE_TOOL_EXECUTION_ERROR"); expect(result.text()).toBe('Remote tool "flaky_report" on "status" failed: Streamable HTTP error: Error POSTing to endpoint: database unavailable'); } const blocked = await mcp.tools.call("status:get_status", {}); expect(blocked).toBeError("TOOL_EXECUTION_ERROR"); expect(blocked.text()).toMatch(/Circuit breaker is open for status\. Retry in \d+s/); }); ``` ### `Tool "status:get_status" not found` for a tool that worked The remote stopped offering it: since 1.9.0, FrontMCP drops a tool the remote removed when it next reads the remote's list. Check the remote's own `tools/list`. Before 1.9.0 the tool stayed listed, and calls failed with `REMOTE_TOOL_NOT_FOUND`, `Tool "get_status" not found on remote server "status"`, until a restart. See [picking up changes](https://frontmcp.dev/reference/server/remote#picking-up-changes-on-the-remote-server). ### Tool names start with `localhost:` or `127:` The default namespace is the app's name, taken from the URL's host up to the first dot. Set `namespace` (or `name`) in the options. ### `Authentication failed for remote server "status": …` A `RemoteAuthError` (`REMOTE_AUTH_ERROR`). After the colon: - **`the remote server refused the credentials (HTTP 401)`**: the remote answered a tool call, resource read or prompt request with `401`. Check the credentials your `remoteAuth` sends, or the caller's token with `forward`. - **`Auth mapping failed: …`**: your `remoteAuth: { mode: "mapped" }` mapper threw, with its message after the colon. See [credentials for each caller](https://frontmcp.dev/reference/server/remote#credentials-for-each-caller). A remote that refuses the connection while your server starts is a `RemoteConnectionError` instead, and the server doesn't start, as [above](https://frontmcp.dev/reference/server/remote#failed-to-connect-to-remote-mcp-server-status-at--sse-error-non-200-status-code-401). The error classes FrontMCP throws for remote apps are listed in [error classes](https://frontmcp.dev/reference/sdk/error-classes#remote-apps). ### `Two apps share the id "status"` The whole message is ``Two apps share the id "status": give each App.esm() / App.remote() its own `name` or `namespace`.`` Two of the server's `App.remote()` or `App.esm()` apps have one id, so the server doesn't start (`DuplicateAppIdError`). An app's id is its `name`, or its `namespace` when it has no `name`, else the URL's host up to the first dot: `https://mcp.github.com` and `https://mcp.linear.app` are both `mcp`. Give each app its own `namespace`: Initial call configuration: ```js { tool: "status:get_status", arguments: {} } ``` ```ts main.ts active import "./status.example"; import { App, FrontMcp } from "@frontmcp/sdk"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [ App.remote("https://status.example/mcp", { namespace: "status" }), App.remote("https://status.example/v2/mcp", { namespace: "status_v2" }), ], }) export default class Server {} ``` ```ts status.example.ts // Stands in for an MCP server at https://status.example with an endpoint at /mcp // and one at /v2/mcp. It answers FrontMCP's requests in-process, and passes // everything else to the real fetch. A real server doesn't need this file. function handle(method: string, params: any) { switch (method) { case "initialize": return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } }; case "tools/list": return { tools: [{ name: "get_status", description: "Current status of every service", inputSchema: { type: "object", properties: {} } }] }; case "tools/call": return { content: [{ type: "text", text: "all systems up" }] }; } } const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.host !== "status.example") return realFetch(input, init); if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream const message = JSON.parse(String(init.body)); if (message.id === undefined) return new Response(null, { status: 202 }); // a notification const result = handle(message.method, message.params); const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } }; return Response.json({ jsonrpc: "2.0", id: message.id, ...reply }); }; ``` ```ts ids.test.ts import { test, expect } from "@frontmcp/testing"; import { App, FrontMcpInstance } from "@frontmcp/sdk"; const info = { name: "help-desk", version: "1.0.0" }; test("with a namespace each, both are served", async ({ mcp }) => { expect((await mcp.tools.list()).map((t) => t.name).sort()).toEqual(["status:get_status", "status_v2:get_status"]); }); test("without, both take their id from the host, and the server doesn't start", async () => { const apps = [App.remote("https://status.example/mcp"), App.remote("https://status.example/v2/mcp")]; await expect(FrontMcpInstance.createDirect({ info, apps })).rejects.toThrow( 'Two apps share the id "status": give each App.esm() / App.remote() its own `name` or `namespace`.', ); }); test("a namespace gives the app its id", () => { expect(App.remote("https://status.example/v2/mcp", { namespace: "status_v2" })).toMatchObject({ id: "status_v2", name: "status" }); }); ``` Since 1.9.3. Before, the id always came from the name, and the server started. --- # Protocol versions > Which MCP revisions a FrontMCP server speaks on one endpoint, how it picks the revision for each request, and what changed in MCP 2026-07-28. Source: https://frontmcp.dev/reference/server/protocol-versions [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A FrontMCP server speaks five MCP revisions, from 2024-11-05 to 2026-07-28, over Streamable HTTP and stdio. There's no setting to choose one: each request is served under the revision its client declares. The four older revisions share one design, a session that the client opens with `initialize`. 2026-07-28 drops it: every request stands on its own and carries its own version and capabilities, and the server never sends a request back to the client. This page says how FrontMCP tells the two apart, and lists what 2026-07-28 changed, with a link to the page that shows each change. The Playground's client speaks 2026-07-28, so the examples on this site do too; what older clients get was checked on a Node server. [Choosing a transport](https://frontmcp.dev/reference/deployment/mcp-clients#choosing-a-transport) compares stdio, Streamable HTTP and the older HTTP+SSE, and what 2026-07-28 changed for each. ```ts // Over HTTP, a 2026-07-28 request names its revision in a header and in the body: // MCP-Protocol-Version: 2026-07-28 // { "method": "tools/list", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } } @FrontMcp({ info, apps, transport: { defaultProtocolVersion: "2026-07-28" } }) // for requests that name none ``` --- ## Reference ### Supported revisions | Revision | How a client starts | Sessions | | --- | --- | --- | | `2026-07-28` | With any request. `server/discover` tells it what the server offers, if it wants to know first. | None. | | `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05` | With `initialize`. Over HTTP, the answer includes an `Mcp-Session-Id` the client sends from then on; over stdio, the connection holds the session. | One per client. | `server/discover` lists the five in `supportedVersions`, newest first. `initialize` answers with the revision the client asked for when it's one of the older four, and with `2025-11-25` when it's one FrontMCP doesn't know. An `initialize` that asks for `2026-07-28` in its params, without the header or `_meta` below, is an older client: it gets `2025-11-25` and a session. (`initialize` also accepts `2024-10-07`, which isn't listed.) The `initialize` answers were checked on a Node server. ### How a request's revision is chosen FrontMCP serves a request as 2026-07-28 when any of these is true: - its params have `_meta["io.modelcontextprotocol/protocolVersion"]`, which only 2026-07-28 clients send; - its `MCP-Protocol-Version` header names a revision other than the older four: `2026-07-28`, or one FrontMCP doesn't know, which gets [`-32022`](https://frontmcp.dev/reference/server/protocol-versions#unsupported-protocol-version-); - its method is `server/discover` or `subscriptions/listen`, which only 2026-07-28 has. Otherwise the request belongs to an older revision: an `initialize`, a request with an `Mcp-Session-Id`, or one whose `MCP-Protocol-Version` header is one of the older four. A request that names no revision at all is served by [`transport.defaultProtocolVersion`](https://frontmcp.dev/reference/server/protocol-versions#transportdefaultprotocolversion). Over HTTP, a request served as 2026-07-28 because it said so must say so in both places, with the same value, and send the other [mirrored headers](https://frontmcp.dev/reference/sdk/create-fetch-handler#request-headers): a missing or different one gets HTTP `400` and `-32020`, before anything else runs. ### Legacy HTTP+SSE requests On the Node server, `GET /sse` opens the legacy stream and names `/message?sessionId=…` for the client's posts. The message endpoint accepts any `Accept` value, or no header: accepted messages get `202 Accepted`, and their results arrive on the SSE stream. The posts still need a valid session, and [`transport.protocol`](https://frontmcp.dev/reference/sdk/frontmcp#transport) must enable legacy SSE. The Playground uses `createFetchHandler()`, which doesn't serve this transport; see [Choosing a transport](https://frontmcp.dev/reference/deployment/mcp-clients#choosing-a-transport). Changed in 1.10.11: before, `Accept: */*` or `application/*` on an `initialize` post to the message endpoint got `404`, preventing the official SDK's `SSEClientTransport` from connecting with its default headers. See [`Cannot POST /mcp/message`](https://frontmcp.dev/reference/deployment/mcp-clients#cannot-post-mcpmessage). Streamable HTTP posts to the entry path keep their Accept rules. For clients that use `initialize`, send `Accept: application/json, text/event-stream`; `*/*`, `application/*` and `text/plain` still aren't served there. MCP 2026-07-28 request routing is unchanged. ### Revisions over stdio One stdio connection serves both protocol eras. `server/discover` and `subscriptions/listen` select 2026-07-28, as does a request with `_meta["io.modelcontextprotocol/protocolVersion"]`. Stdio has no HTTP headers, so the mirrored-header checks don't apply. Results carry `resultType` and the server's `info` in `_meta`, just as they do over HTTP. Until the connection sends `initialize`, a call that names no revision follows `transport.defaultProtocolVersion`, which defaults to `"legacy"` on stdio. After `initialize`, calls that name no revision stay in that session, even with `"2026-07-28"` configured. Explicit 2026-07-28 requests still work on the same connection. A connection that uses 2026-07-28 without `initialize` gets no session broadcasts. Ask for log messages and progress on each request, and use `subscriptions/listen` for changes. `notifications/cancelled` with `params.requestId` aborts an inline call's `this.signal` while its request is in flight, or ends its listen stream; closing the connection aborts requests in flight. [Tasks](https://frontmcp.dev/reference/server/tasks#tasks-over-stdio) belong to the connection that created them. Changed in 1.10.5: before, `server/discover` over stdio returned `-32601`, and a 2026-07-28 tool call was answered as an older revision. `initialize` sessions keep their existing behavior. This also applies to `frontmcp dev --stdio --serve`; the [HTTP loopback bridge](https://frontmcp.dev/reference/cli#dev---stdio) without `--serve` doesn't mirror the required headers. ### Internal protocol imports FrontMCP uses `@frontmcp/protocol` internally for the underlying SDK's types, schemas and server transports, and `@frontmcp/protocol/client` for its client and client transports. Changed in 1.10.10: before, the root also exported the client APIs. Neither path is an application API: import MCP types from `@frontmcp/sdk`, and use [`McpClient` from `@frontmcp/testing`](https://frontmcp.dev/reference/testing) for a raw test client. This import change does not change the supported revisions or their request formats. ### `transport.defaultProtocolVersion` A request names no revision when it has no `MCP-Protocol-Version` header, no `_meta` version and no `Mcp-Session-Id`, and isn't an `initialize`: a bare JSON-RPC call. `transport.defaultProtocolVersion` decides how to serve it: | Value | A request that names no revision | | --- | --- | | `"2026-07-28"` | Served as 2026-07-28. The mirrored headers aren't required, since the client never agreed to send them, but one it does send must match the body. | | `"legacy"` | Served as an older revision. FrontMCP's Node HTTP server wants a session first, and answers `-32600` ``Session not initialized — send `initialize` first``. Behind `createFetchHandler()`, which keeps no sessions, the call is answered on its own, as an event stream. | The default depends on how the server is served: `"legacy"` on FrontMCP's Node HTTP server and on stdio, and `"2026-07-28"` behind [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler): Cloudflare Workers, Deno, Bun, and the Playground. On stdio the setting applies only until `initialize`; it does not change the routing of explicitly versioned requests. The HTTP defaults were checked on Node; [Serving requests that name no revision](https://frontmcp.dev/reference/server/protocol-versions#serving-requests-that-name-no-revision) shows the second. ### What 2026-07-28 changed | What changed | What FrontMCP does | Shown on | | --- | --- | --- | | **No sessions, no `initialize`.** Each request carries the client's version, `clientInfo` and `clientCapabilities` in `_meta`, and a capability counts only on the request that declares it. | `initialize` and `ping` get `404` and `-32601` ``Method not found: initialize was removed in protocol 2026-07-28``. An anonymous caller is a new caller on every request. | [Sessions](https://frontmcp.dev/reference/sdk/create-fetch-handler#sessions), [A server anyone can call](https://frontmcp.dev/learn/authenticating-clients#a-server-anyone-can-call) | | **`server/discover`** tells a client the revisions, capabilities and instructions, as `initialize` did. | Lists the tasks extension, whether or not a tool runs as a task. | [What `server/discover` returns](https://frontmcp.dev/reference/server/apps#what-serverdiscover-returns) | | **Mirrored HTTP headers.** `MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name`, and `Mcp-Param-` for arguments marked `x-mcp-header`, so a proxy can route without reading the body. | Checked against the body before anything else runs: `-32020` when one is missing or different. | [Request headers](https://frontmcp.dev/reference/sdk/create-fetch-handler#request-headers), [Header parameters](https://frontmcp.dev/reference/adapters/openapi#header-parameters) | | **Every result says what it is**, in `resultType`: `complete`, `input_required` or `task`. | Every result also carries the server's `info` in `_meta["io.modelcontextprotocol/serverInfo"]`. Lists, reads and `server/discover` carry `ttlMs` and `cacheScope`, how long and how widely a client may cache them: 60 seconds for `tools/list`, `0` for `resources/read`. | [What `server/discover` returns](https://frontmcp.dev/reference/server/apps#what-serverdiscover-returns) | | **No requests from the server.** Elicitation, sampling and roots become an `input_required` result, which the client answers by sending its request again. | The tool runs again from the top with the answer. `requestState` is signed with `VAULT_SECRET` (or `JWT_SECRET`), so every instance needs the same one. | [How an elicitation travels](https://frontmcp.dev/reference/sdk/elicit#how-an-elicitation-travels), [`this.sample()`](https://frontmcp.dev/reference/sdk/contexts#thissample-and-thislistroots), [High availability](https://frontmcp.dev/reference/deployment/high-availability) | | **Notifications belong to a request.** A client asks for log messages and progress on the request itself, with `io.modelcontextprotocol/logLevel` and a `progressToken`; `logging/setLevel` is gone. | Over HTTP the answer is an event stream; over stdio it is JSON lines. Both send the requested notifications before the result. A request that doesn't ask gets no log messages. | [Asking for log messages](https://frontmcp.dev/reference/sdk/notify#asking-for-log-messages), [Asking for progress](https://frontmcp.dev/reference/sdk/progress#asking-for-progress) | | **`subscriptions/listen`** replaces the `GET` stream and `resources/subscribe`. | A stream that starts with what the server will send. | [Listening for changes](https://frontmcp.dev/reference/server/protocol-versions#listening-for-changes), [Channels](https://frontmcp.dev/reference/sdk/channel#receiving-events-on-a-2026-07-28-client) | | **Tasks are an extension**, `io.modelcontextprotocol/tasks`, without `tasks/result` and `tasks/list`, and with `tasks/update`. | Over HTTP, only signed-in callers can start a task. Over stdio, the connection owns its tasks. | [Background tasks](https://frontmcp.dev/reference/server/tasks) | | **Trace context in `_meta`.** A client may send `traceparent` and `tracestate` in a request's `_meta`. | Copied to the result's `_meta`. Only the `traceparent` header joins the server's trace. | [Joining a trace](https://frontmcp.dev/reference/sdk/context#joining-a-trace) | | **New error codes.** `-32020`, `-32021` and `-32022`; a missing resource is `-32602` (it was `-32002`). `GET` and `DELETE` on the endpoint get `405`. | As the protocol says. | [Error codes](https://frontmcp.dev/reference/errors#protocol-errors) | #### Caveats - **`frontmcp test` speaks an older revision.** Its client asks for `2025-06-18` and can't speak 2026-07-28, while the Playground's tests speak 2026-07-28: see [Testing](https://frontmcp.dev/reference/testing). A test of a missing resource, for one, gets `-32002` in one and `-32602` in the other. - **A remote app speaks the older revision to its server** unless you set `transportOptions.protocolVersion`: see [Talking to a 2026-07-28 server](https://frontmcp.dev/reference/server/remote#talking-to-a-2026-07-28-server). --- ## Usage ### Asking which revisions a server speaks A client that wants to know before it calls anything sends `server/discover`. The tests also send a revision FrontMCP doesn't know, and a request whose header and body disagree: ```ts versions.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; test("server/discover lists every revision the server speaks", async ({ mcp }) => { const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" })) as any; expect(result.supportedVersions).toEqual(["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]); }); test("a revision FrontMCP doesn't know: 400 and -32022, with the ones it does", async () => { const server = await FrontMcpInstance.createFetchHandler(config); const response = await server(listTools("2099-01-01", "2099-01-01")); expect(response.status).toBe(400); expect((await response.json()).error).toEqual({ code: -32022, message: "Unsupported protocol version: 2099-01-01", data: { supported: ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"], requested: "2099-01-01" }, }); }); test("a header and a body that disagree: 400 and -32020", async () => { const server = await FrontMcpInstance.createFetchHandler(config); const response = await server(listTools("2026-07-28", "2025-11-25")); expect(response.status).toBe(400); expect((await response.json()).error).toEqual({ code: -32020, message: "Header mismatch: mcp-protocol-version header value '2026-07-28' does not match body value '2025-11-25'", }); }); /** A tools/list that names one revision in its header and another in its body. */ function listTools(header: string, body: string) { return new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": header, "mcp-method": "tools/list" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": body } } }), }); } ``` ```ts main.ts import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, tickets: [] }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }; @FrontMcp(config) export default class Server {} ``` Changed in 1.9.5: `initialize` answers with one of the four older revisions above. A client that asks for one FrontMCP doesn't speak, like `2024-10-07`, gets `2025-11-25`; before, it got `2024-10-07` back. An older client doesn't send `server/discover`. It asks for a revision in `initialize`, and reads the one it got from the answer's `protocolVersion`. ### Calling over stdio > **Note** > > Stdio needs a Node process with stdin and stdout, so the Playground can't run it. Start a server with [`runStdio()`](https://frontmcp.dev/reference/sdk/frontmcp-instance#serving-over-stdio), or a [built server with `--stdio`](https://frontmcp.dev/reference/deployment/mcp-clients#starting-the-server-over-stdio). Send one JSON object per line, without HTTP headers or an `initialize` handshake: ```json {"jsonrpc":"2.0","id":1,"method":"server/discover"} {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_tickets","arguments":{"query":"log"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"desk-client","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} ``` The first answer lists the five revisions in `result.supportedVersions`. The second has `result.resultType: "complete"` and the tool's result. A tool that asks a question returns `input_required`; the client [answers by retrying the call](https://frontmcp.dev/reference/sdk/elicit#how-an-elicitation-travels), as over HTTP. A missing capability gets `-32021`, and an unsupported revision gets `-32022`. ### Serving requests that name no revision Some clients and scripts send plain JSON-RPC, with no header, no `_meta` and no session. Behind `createFetchHandler()`, as here, such a request is served as 2026-07-28, and the headers it doesn't send aren't required: ```ts bare.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; const call = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "search_tickets", arguments: { query: "log" } } }; const post = (headers: Record) => new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(call) }); test("a bare call is served as 2026-07-28", async () => { const server = await FrontMcpInstance.createFetchHandler(config); const response = await server(post({})); expect(response.status).toBe(200); expect((await response.json()).result).toMatchObject({ resultType: "complete", structuredContent: { query: "log", tickets: [] } }); }); test("a header it does send must still match the body", async () => { const server = await FrontMcpInstance.createFetchHandler(config); const response = await server(post({ "mcp-name": "close_ticket" })); expect(response.status).toBe(400); expect((await response.json()).error.message).toBe("Header mismatch: mcp-name header value 'close_ticket' does not match body value 'search_tickets'"); }); ``` ```ts main.ts import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } }) class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, tickets: [] }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }; @FrontMcp(config) export default class Server {} ``` FrontMCP's Node server answers the same bare call with `-32600` ``Session not initialized — send `initialize` first``, because it defaults to `"legacy"`. To serve such clients there, set the default: ```ts main.ts @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], transport: { defaultProtocolVersion: "2026-07-28" }, }) export default class Server {} ``` With it, the Node server answered the bare call as above, and `initialize` still opened a session for older clients. This was checked on a Node server. ### Listening for changes A 2026-07-28 client has no session to subscribe with, so it sends `subscriptions/listen`, naming the notifications it wants, and keeps reading the answer: an HTTP event stream, or JSON lines on stdio, that stays open. Its first message, `notifications/subscriptions/acknowledged`, repeats the request's list without what the server can't send: ```ts listen.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; test("the stream starts by saying what it will carry", async () => { const server = await FrontMcpInstance.createFetchHandler(config); const wanted = { toolsListChanged: true, promptsListChanged: true, resourcesListChanged: true, resourceSubscriptions: ["desk://queue"] }; const response = await server(listen(wanted)); expect(response.headers.get("content-type")).toContain("text/event-stream"); const stream = response.body!.getReader(); expect(await nextMessage(stream)).toEqual({ jsonrpc: "2.0", method: "notifications/subscriptions/acknowledged", params: { // No promptsListChanged: the server has no prompts. notifications: { toolsListChanged: true, resourcesListChanged: true, resourceSubscriptions: ["desk://queue"] }, _meta: { "io.modelcontextprotocol/subscriptionId": 7 }, }, }); void stream.cancel(); // the client goes away }); function listen(notifications: Record) { return new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", accept: "text/event-stream", "mcp-protocol-version": "2026-07-28", "mcp-method": "subscriptions/listen" }, body: JSON.stringify({ jsonrpc: "2.0", id: 7, method: "subscriptions/listen", params: { notifications, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }), }); } // The next message on the stream, or null when none comes within 200 ms. async function nextMessage(stream: ReadableStreamDefaultReader) { const chunk = await Promise.race([stream.read(), new Promise((resolve) => setTimeout(() => resolve(null), 200))]); if (!chunk || chunk.done) return null; const data = new TextDecoder().decode(chunk.value).split("\n").find((line) => line.startsWith("data: ")); return data ? JSON.parse(data.slice("data: ".length)) : null; } ``` ```ts main.ts import { App, FrontMcp, Resource, ResourceContext, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } }) class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { return { id, status: "closed" }; } } @Resource({ name: "queue", uri: "desk://queue", mimeType: "application/json", description: "The open tickets, oldest first" }) class Queue extends ResourceContext { async execute(uri: string) { return { contents: [{ uri, text: JSON.stringify(["T-1", "T-2"]) }] }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket], resources: [Queue] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }; @FrontMcp(config) export default class Server {} ``` Every message on the stream carries the request's `id` as `_meta["io.modelcontextprotocol/subscriptionId"]`. The stream carries a list change when the server's own list of tools, prompts or resources changes, and [channel events](https://frontmcp.dev/reference/sdk/channel#receiving-events-on-a-2026-07-28-client) to a client that asks for them. A resource's own `this.notifyUpdated()` reaches it, as `notifications/resources/updated` to the streams that listed its URI, and so does a tool's [`this.notifyResourceUpdated(uri)`](https://frontmcp.dev/reference/sdk/contexts#telling-clients-a-resource-changed), which takes the same path. Changed in 1.9.5: before, a tool's `this.notifyResourceUpdated(uri)` went only to older clients' sessions. The resource's notification was checked on Node. ### Calling a 2026-07-28 server from code FrontMCP has a 2026-07-28 client of its own, `McpStatelessClient` from `@frontmcp/sdk` (the MCP SDK's 1.x client, which FrontMCP builds on, stops at 2025-11-25: see [the comparison](https://frontmcp.dev/compare/official-typescript-sdk)). It sends the headers and `_meta` on every request, answers `input_required` results with its `handlers` and sends the request again, and polls [tasks](https://frontmcp.dev/reference/server/tasks#calling-a-task-tool-from-code) to the end: ```ts client.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance, McpStatelessClient } from "@frontmcp/sdk"; import { config } from "./main"; async function connect(options: Partial[0]> = {}) { const server = await FrontMcpInstance.createFetchHandler(config); return new McpStatelessClient({ url: "https://desk.example.com/", // Hands each request to the server above, so this runs here. A real client leaves it out. fetchImpl: (url, init) => server(new Request(url, init)), ...options, }); } test("discover() and listTools()", async () => { const desk = await connect(); expect((await desk.discover()).supportedVersions).toContain("2026-07-28"); expect((await desk.listTools()).map((tool) => tool.name)).toEqual(["close_ticket"]); }); test("callTool() answers the tool's question, and resolves with the result", async () => { const asked: unknown[] = []; const desk = await connect({ capabilities: { elicitation: { form: {} } }, handlers: { onElicit: async (params) => { asked.push(params.message); return { action: "accept", content: { confirm: true } }; }, }, }); const result = await desk.callTool("close_ticket", { id: "T-1" }); expect(asked).toEqual(["Close ticket T-1?"]); expect(result.structuredContent).toEqual({ id: "T-1", status: "closed" }); }); test("without the elicitation capability, the call fails with -32021", async () => { const desk = await connect(); await expect(desk.callTool("close_ticket", { id: "T-1" })).rejects.toMatchObject({ name: "McpStatelessError", code: -32021, message: "This request requires the `elicitation` client capability", }); }); ``` ```ts main.ts import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "close_ticket", description: "Close a ticket once the user confirms", inputSchema: { id: z.string() } }) class CloseTicket extends ToolContext { async execute({ id }: { id: string }) { const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() })); return { id, status: answer.content?.confirm ? "closed" : "open" }; } } @App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true } }; @FrontMcp(config) export default class Server {} ``` | Option | Description | | --- | --- | | `url` | **Required.** The server's MCP endpoint. | | `capabilities` | Sent on every request, like `{ elicitation: { form: {} } }` or `{ extensions: { "io.modelcontextprotocol/tasks": {} } }`. Defaults to none. | | `headers` | Sent on every request, like `Authorization`. | | `clientInfo` | `{ name, version }`. Defaults to `frontmcp-20260728-client`. | | `handlers` | `onElicit`, `onSample` and `onListRoots`: how to answer what a tool asks. A request with no handler fails with `Server requested elicitation but no handler is configured`. | | `logLevel`, `onNotification` | `logLevel` asks for log messages on every request; `onNotification` receives each notification that arrives before a result. | | `maxInputRounds` | How many times a tool may ask before the call fails with `Server kept requesting input after 8 rounds`. Defaults to 8. | | `requestTimeoutMs` | How long to wait for an answer to one request. | | `fetchImpl` | The `fetch` to send with. Defaults to the global one. | Besides `discover()`, `listTools()` and `callTool()`, it has `readResource()`, `listResources()`, `listResourceTemplates()`, `listPrompts()`, `getPrompt()`, `cancelTask(taskId)`, `listen(notifications, onNotification)` for [`subscriptions/listen`](https://frontmcp.dev/reference/server/protocol-versions#listening-for-changes), and `request(method, params)`. Errors are `McpStatelessError`s, with the JSON-RPC `code`. A tool that fails returns a result with `isError: true`, as with any client. ### Serving clients on older revisions Nothing has to be set: an older client opens a session with `initialize` on the same endpoint, and every request it sends with its `Mcp-Session-Id` is served as before. Which older transports it may use, Streamable HTTP or the old SSE one, is [`transport.protocol`](https://frontmcp.dev/reference/sdk/frontmcp#transport); sessions need `MCP_SESSION_SECRET` in production. A few things differ for these clients, each on its page: | Feature | Before 2026-07-28 | | --- | --- | | Asking the user | FrontMCP sends `elicitation/create` to the client, and `this.elicit()` waits for the answer. A client that can't show forms gets a `sendElicitationResult` tool: see [`this.elicit`](https://frontmcp.dev/reference/sdk/elicit#the-model-is-asked-to-call-sendelicitationresult). | | Log messages | The client sets a level with `logging/setLevel`: see [`this.notify`](https://frontmcp.dev/reference/sdk/notify). | | Resource changes | `resources/subscribe`, and `notifications/resources/updated` to the subscribed sessions: see [Telling clients a resource changed](https://frontmcp.dev/reference/sdk/contexts#telling-clients-a-resource-changed). | | Tasks | `task` in the call, `tasks/result`, `tasks/list`, and status notifications: see [Clients on older protocol versions](https://frontmcp.dev/reference/server/tasks#clients-on-older-protocol-versions). | | Anonymous callers | One caller for the whole session, so a task it starts can be read back. | | Behind `createFetchHandler()` | No session is kept: see [Serving clients on older protocol versions](https://frontmcp.dev/reference/sdk/create-fetch-handler#serving-clients-on-older-protocol-versions). | --- ## Troubleshooting ### `Method not found` for `server/discover` over stdio Changed in 1.10.5: before, the stdio server returned `-32601` for `server/discover`. Upgrade the server. When reloading it, use `frontmcp dev --stdio --serve`. `runStdio()` and built servers started with `--stdio` serve discovery without `initialize`. ### `Unsupported protocol version: …` HTTP `400` with JSON-RPC error `-32022`. The request's `MCP-Protocol-Version` header or `_meta["io.modelcontextprotocol/protocolVersion"]` names a revision FrontMCP doesn't speak; `data.supported` lists the ones it does. The first example's [second test](https://frontmcp.dev/reference/server/protocol-versions#asking-which-revisions-a-server-speaks) gets it. Send one of those, or upgrade the server when a newer one is what the client needs. Over stdio, the same request gets JSON-RPC error `-32022`, without an HTTP status. ### `Header mismatch: mcp-protocol-version header value '…' does not match body value '…'` HTTP `400` with `-32020`. The header and `_meta["io.modelcontextprotocol/protocolVersion"]` name different revisions. They must be the same. Other mismatches, and missing headers, are in [`createFetchHandler()`'s troubleshooting](https://frontmcp.dev/reference/sdk/create-fetch-handler#header-mismatch-the-mcp-protocol-version-header-is-required). ### `Method not found: initialize was removed in protocol 2026-07-28` HTTP `404` with `-32601`. The client sent `initialize` with a 2026-07-28 header or `_meta`, so FrontMCP served it as 2026-07-28, which has no `initialize`. A 2026-07-28 client starts with any request, or `server/discover`; an older client must not send the 2026-07-28 header. The same goes for `ping`, `logging/setLevel`, `resources/subscribe`, `tasks/result` and `tasks/list`. ### ``Session not initialized — send `initialize` first`` JSON-RPC error `-32600` from FrontMCP's Node server. The request named no revision and carried no session, and the server defaults to `"legacy"`. Have the client declare 2026-07-28 with the header and `_meta`, or set [`transport: { defaultProtocolVersion: "2026-07-28" }`](https://frontmcp.dev/reference/server/protocol-versions#serving-requests-that-name-no-revision). --- # Background tasks > Let a slow tool run as an MCP task, so the client gets a task id at once, polls for the result, answers the tool's questions and can cancel it. Source: https://frontmcp.dev/reference/server/tasks [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A tool call holds its request open until the tool returns. For a tool that takes minutes, like exporting every ticket, that's a model waiting the whole time and a request a proxy may cut off. A tool with `execution.taskSupport` can run as a **task** instead: the client gets a task id straight away, the tool keeps running on the server, and the client asks `tasks/get` how it's going until the result is there. The client can answer questions the tool asks on the way, and cancel it. Tasks are part of MCP, so the client has to support them. Under MCP 2026-07-28 they're the `io.modelcontextprotocol/tasks` extension, which a client declares on each request. Over HTTP only a signed-in caller can start one; over stdio the connection owns its tasks. [Jobs](https://frontmcp.dev/learn/running-jobs-in-the-background) are a different feature: FrontMCP's own, started and read through ordinary tools (`execute_job`, `get_job_status`), so they work with any client. ```ts @Tool({ name, description, inputSchema, execution: { taskSupport: "optional" } }) // or "required" @FrontMcp({ info, apps, tasks?: { defaultTtlMs?, defaultPollIntervalMs?, redis?, sqlite?, runner? } }) ``` --- ## Reference ### `execution.taskSupport` Set it on a tool to say whether it may run as a task. `tools/list` sends it as the tool's `execution`, so clients know before they call. ```ts export-tickets.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "export_tickets", description: "Export every ticket with a status as CSV. Slow: reads the database a page at a time.", inputSchema: { status: z.enum(["open", "closed"]) }, execution: { taskSupport: "optional" }, }) export class ExportTickets extends ToolContext { async execute({ status }: { status: "open" | "closed" }) { // ... } } ``` [See more examples below.](https://frontmcp.dev/reference/server/tasks#usage) | Value | A 2026-07-28 client that declares the tasks extension | A 2026-07-28 client that doesn't | | --- | --- | --- | | `"forbidden"`, or no `execution` | The tool runs inline. | The tool runs inline. | | `"optional"` | Gets a task, every time. The client can't ask for an inline run instead. | The tool runs inline. | | `"required"` | Gets a task. | `-32602` `Tool "…" runs as a task; declare the io.modelcontextprotocol/tasks extension in clientCapabilities`. | Clients on protocol versions before 2026-07-28 choose per call instead: see [Clients on older protocol versions](https://frontmcp.dev/reference/server/tasks#clients-on-older-protocol-versions). The tool itself doesn't change. It runs as a task with the same input and the same caller (`this.auth`) as an inline call, and what it returns becomes the task's `result`. ### `tasks` `@FrontMcp({ tasks })` configures where tasks are kept and how long. Nothing has to be set for tasks to work on a long-lived server. | Option | Default | Description | | --- | --- | --- | | `enabled` | on | `false` turns tasks off. Tools with `taskSupport: "optional"` then always run inline, those with `"required"` fail with `-32601` `Tool "…" requires task-augmented invocation`, and `tasks/get` fails with `-32603` `Task store is not configured`. | | `defaultTtlMs` | `3600000` (an hour) | How long a task is kept after it starts. Afterwards `tasks/get` answers `Task not found`. Under 2026-07-28 a client can't ask for another, so every task gets this. | | `maxConcurrentPerSession` | `16` | Maximum unfinished tasks per session; under MCP 2026-07-28, per authenticated HTTP subject or stdio connection. Positive integer, at most `1000`. A terminal task frees its slot. Changed in 1.9.5: before, this option was not enforced. | | `maxTtlMs` | `86400000` (a day) | The most an older client may ask for with `task.ttl`; a longer `ttl` is cut to this. It doesn't limit `defaultTtlMs`. | | `defaultPollIntervalMs` | `2000` | Sent with every task as `pollIntervalMs`: how often the client should ask. | | `keyPrefix` | `"mcp:task:"` | The prefix of every key in Redis. | | `redis` | the server's `redis` | Keep tasks in Redis, so every instance can read and cancel them: `{ provider: "redis", host, port?, password?, db?, tls? }`. See [Keeping tasks in Redis](https://frontmcp.dev/reference/server/tasks#keeping-tasks-in-redis). | | `sqlite` | the server's `sqlite`, unless there's a `redis` | Keep tasks in a SQLite file: `{ path, encryption?: { secret }, walMode?, ttlCleanupIntervalMs?, busyTimeoutMs? }`. Needs `@frontmcp/storage-sqlite`. Without `encryption`, each task's input and result are in the file as plain JSON; with it, they're encrypted, and only the task id, owner, status and times stay readable. | | `runner` | `"in-process"` | Where a task runs. `"cli"` starts a separate process for each task: see [Running each task in its own process](https://frontmcp.dev/reference/server/tasks#running-each-task-in-its-own-process). | | `cliRunnerCommand` | the command that started the server | With `runner: "cli"`, the command that starts a task's process: `{ exe, args? }`. | | `strict` | `false` | On an edge runtime, refuse to start rather than accept tasks that can't run there. See [Where tasks can't run](https://frontmcp.dev/reference/server/tasks#where-tasks-cant-run). | Without `redis` or `sqlite`, here or at the top level, tasks are kept in the server's memory, and a restart loses them. ### A task The call's answer, and every `tasks/get`, describe the task: ```json { "taskId": "b6ce64e1-c3ee-419c-867f-3e3ffb325820", "status": "completed", "createdAt": "2026-10-10T11:43:22.953Z", "lastUpdatedAt": "2026-10-10T11:43:23.158Z", "ttlMs": 3600000, "pollIntervalMs": 2000, "result": { "content": [{ "type": "text", "text": "{\"rows\":1200}" }], "structuredContent": { "rows": 1200 } } } ``` | Field | Description | | --- | --- | | `taskId` | A random UUID. Only the caller who started the task can use it. | | `status` | `working`, `input_required`, `completed`, `failed` or `cancelled`. See [Statuses](https://frontmcp.dev/reference/server/tasks#statuses). | | `statusMessage` | A sentence about the status: `The operation is now in progress.`, `The task is waiting for additional input.`, `The task was cancelled by request.`, or why it failed. | | `createdAt`, `lastUpdatedAt` | ISO timestamps. | | `ttlMs`, `pollIntervalMs` | From [`tasks`](https://frontmcp.dev/reference/server/tasks#tasks). | | `result` | Once `completed`: the `tools/call` result the tool would have returned inline, an `isError` result included. | | `error` | Once `failed`: the JSON-RPC error, `{ code, message }`. | | `inputRequests` | While `input_required`: what the tool is asking, in the same shape as an [elicitation](https://frontmcp.dev/reference/sdk/elicit#how-an-elicitation-travels). | The call that starts the task answers `{ resultType: "task", task }`, where a normal call answers `resultType: "complete"`. #### Statuses A task starts `working`. It ends `completed` when the tool returns, and also when it throws or calls `this.fail()`: the task's `result` is then the `isError` result an inline call gets (a client before 2026-07-28 sees that task as `failed`: see [the caveats](https://frontmcp.dev/reference/server/tasks#caveats)). It ends `failed` for an error a call would answer with a JSON-RPC error, like a missing client capability, and `cancelled` when the client cancels it. Those three are final: nothing changes a task after that, not even the tool finishing later. A tool that calls `this.elicit()` moves the task to `input_required` until the client answers with `tasks/update`, and then back to `working`. #### Methods Under 2026-07-28, each of these needs the extension declared on the request, and the same caller that started the task: | Method | Params | Answers | | --- | --- | --- | | `tasks/get` | `{ taskId }` | The task. | | `tasks/update` | `{ taskId, inputResponses }` | `{}`, and the task goes back to `working`. Only while it's `input_required`. | | `tasks/cancel` | `{ taskId }` | `{}`. The task is `cancelled`, and the tool's `this.signal` aborts. A task that has already ended stays as it was, and still gets `{}`. | `tasks/result` and `tasks/list`, which older clients use, get `404` and `-32601` `Method not found: tasks/result was removed in protocol 2026-07-28`. 2026-07-28 sends no notification when a task's status changes: the client polls. #### Caveats - **Over HTTP under 2026-07-28, only a signed-in caller can start or read a task.** The protocol has no sessions, and anonymous callers on a public server get a new id on every request, so FrontMCP would have no way to give a task back to the caller who started it. It refuses instead: see [the error](https://frontmcp.dev/reference/server/tasks#tasks-require-an-authenticated-caller-under-protocol-2026-07-28). The Playground's own calls are anonymous, and don't declare the extension, so the tests on this page start a second server, behind a static key. - **Over HTTP, a 2026-07-28 task belongs to its caller:** the `sub` of the caller's token. With a [static key](https://frontmcp.dev/reference/auth/modes), that's derived from the key, so every client with the same key can read the others' tasks. Anyone else gets `Task not found`, the same as for an id that doesn't exist. - **Check `result.isError` under MCP 2026-07-28.** A tool error produces a `completed` task with an `isError` result; `failed` is for a JSON-RPC-level error. Under 2025-11-25 it still has status `failed`, and `tasks/result` replays the tool's `isError` result. Changed in 1.9.5: before, 2026-07-28 tool errors were failed tasks. - **Task errors match inline calls.** `this.fail()` preserves public messages, a thrown `PublicMcpError` preserves its code, and other errors are masked in production. Changed in 1.9.5: before, task error mapping differed from inline calls. See [A failed task's error has no message](https://frontmcp.dev/reference/server/tasks#a-failed-tasks-error-has-no-message). - **The tool runs again from the top after `tasks/update`,** as an [elicitation](https://frontmcp.dev/reference/sdk/elicit#how-an-elicitation-travels) does. Put side effects after the last `this.elicit()`. - **The tool's [`authorities`](https://frontmcp.dev/reference/auth/authorities) are checked before the task starts.** A caller they refuse gets `AUTHORITY_DENIED` in the call's answer, as for an inline call, and no task is created. This was checked on Node. - A `tasks/cancel` doesn't stop a tool that ignores `this.signal`. The task is `cancelled` either way, and the tool's result is dropped. --- ## Usage ### Letting a tool run as a task Give the tool `execution: { taskSupport: "optional" }`. A client that doesn't do tasks, like this Playground's, still gets the result inline: that's the Call tab. A client that declares the extension gets a task, and polls it. Open the **Tests** tab: Initial call configuration: ```js { tool: "export_tickets", arguments: { status: "open" } } ``` ```ts export-tickets.tool.ts active import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk"; import { readPage } from "./database"; @Tool({ name: "export_tickets", description: "Export every ticket with a status as CSV. Slow: reads the database a page at a time.", inputSchema: { status: z.enum(["open", "closed"]) }, execution: { taskSupport: "optional" }, }) export class ExportTickets extends ToolContext { async execute({ status }: { status: "open" | "closed" }) { const rows: string[] = []; for (let page = 1; page <= 4; page++) rows.push(...(await readPage(status, page))); return { status, rows: rows.length, csv: ["id,status", ...rows].join("\n") }; } } @Tool({ name: "failed_export", inputSchema: {}, execution: { taskSupport: "optional" } }) export class FailedExport extends ToolContext { async execute() { return this.fail(new PublicMcpError("Export is unavailable", "EXPORT_UNAVAILABLE")); } } ``` ```ts tasks.test.ts import { test, expect } from "@frontmcp/testing"; import { client, serve } from "./client"; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("a client without the extension gets the result inline", async ({ mcp }) => { const result = await mcp.tools.call("export_tickets", { status: "open" }); expect(result.json()).toMatchObject({ status: "open", rows: 4 }); }); test("with the extension, the call answers at once with a working task", async () => { const nour = client(await serve(), "key-nour"); const started = await nour("tools/call", { name: "export_tickets", arguments: { status: "open" } }); expect(started.result).toMatchObject({ resultType: "task", task: { taskId: expect.any(String), status: "working", ttlMs: 3600000, pollIntervalMs: 100 }, }); }); test("tasks/get says working, then completed with the result", async () => { const nour = client(await serve(), "key-nour"); const { taskId } = (await nour("tools/call", { name: "export_tickets", arguments: { status: "open" } })).result.task; const during = (await nour("tasks/get", { taskId })).result; await wait(300); const after = (await nour("tasks/get", { taskId })).result; expect(during).toMatchObject({ taskId, status: "working", statusMessage: "The operation is now in progress." }); expect(during.result).toBeUndefined(); expect(after).toMatchObject({ status: "completed", result: { structuredContent: { status: "open", rows: 4 } } }); }); test("another caller can't read the task", async () => { const server = await serve(); const { taskId } = (await client(server, "key-nour")("tools/call", { name: "export_tickets", arguments: { status: "open" } })).result.task; expect((await client(server, "key-sam")("tasks/get", { taskId })).error).toEqual({ code: -32602, message: "Task not found" }); }); test("an anonymous caller can't start one", async () => { const anonymous = client(await serve({ mode: "public" })); const refused = await anonymous("tools/call", { name: "export_tickets", arguments: { status: "open" } }); expect(refused.error).toEqual({ code: -32602, message: "Tasks require an authenticated caller under protocol 2026-07-28: there are no protocol sessions, so an anonymous task could not be scoped to its creator", }); }); test("unfinished tasks count toward the cap; cancelling frees a slot", async () => { const nour = client(await serve(undefined, { maxConcurrentPerSession: 1 }), "key-nour"); const start = () => nour("tools/call", { name: "export_tickets", arguments: { status: "open" } }); const first = await start(); const refused = await start(); expect(refused.result.isError).toBe(true); expect(refused.result._meta.code).toBe("CONCURRENCY_LIMIT"); await nour("tasks/cancel", { taskId: first.result.task.taskId }); expect((await start()).result.task.taskId).toBeDefined(); }); test("this.fail preserves its public message in a completed isError task", async () => { const nour = client(await serve(), "key-nour"); const started = await nour("tools/call", { name: "failed_export", arguments: {} }); await wait(100); const { result } = await nour("tasks/get", { taskId: started.result.task.taskId }); expect(result.status).toBe("completed"); expect(result.result.isError).toBe(true); expect(result.result._meta.code).toBe("EXPORT_UNAVAILABLE"); expect(result.result.content[0].text).toBe("Export is unavailable"); }); ``` ```ts client.ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; /** The help desk behind two static keys, so its callers are signed in. */ export function serve(auth: object = { mode: "static", tokens: ["key-nour", "key-sam"] }, tasks: object = {}) { return FrontMcpInstance.createFetchHandler({ ...config, auth, tasks: { ...config.tasks, ...tasks } } as typeof config); } /** A 2026-07-28 client that declares the tasks extension, sending `key` if there is one. */ export function client(server: Awaited>, key?: string) { let id = 0; return async (method: string, params: Record = {}) => { const response = await server( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(typeof params.name === "string" ? { "mcp-name": params.name } : {}), ...(key ? { authorization: `Bearer ${key}` } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": { extensions: { "io.modelcontextprotocol/tasks": {} } }, }, }, }), }), ); return response.json(); }; } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { ExportTickets, FailedExport } from "./export-tickets.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets, FailedExport] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], tasks: { defaultPollIntervalMs: 100 }, }; @FrontMcp(config) export default class Server {} ``` ```ts database.ts // Stands in for a slow database: 4 pages, 50 ms each. export async function readPage(status: string, page: number) { await new Promise((resolve) => setTimeout(resolve, 50)); return [`T-${page},${status}`]; } ``` The call answered before the export had read a page. `main.ts` lowers `defaultPollIntervalMs`, the hint each task carries for how often to ask, to 100 ms; it's two seconds by default. The task keeps its result for `ttlMs`, an hour by default, so a client can come back for it later. ### Requiring a task A tool that should never hold a request open sets `taskSupport: "required"`. A client that can't do tasks then gets an error instead of a long wait, which is what this Playground's client gets: Initial call configuration: ```js { tool: "archive_closed_tickets", arguments: {} } ``` ```ts archive.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "archive_closed_tickets", description: "Move every closed ticket to cold storage. Runs as a background task.", inputSchema: {}, execution: { taskSupport: "required" }, }) export class ArchiveClosedTickets extends ToolContext { async execute() { await new Promise((resolve) => setTimeout(resolve, 100)); return { archived: 340 }; } } ``` ```ts required.test.ts import { test, expect } from "@frontmcp/testing"; import { client, serve } from "./client"; test("a client without the extension is refused", async ({ mcp }) => { const result = await mcp.tools.call("archive_closed_tickets", {}); expect(result.error).toEqual({ code: -32602, message: 'Tool "archive_closed_tickets" runs as a task; declare the io.modelcontextprotocol/tasks extension in clientCapabilities', }); }); test("a client with the extension gets a task", async () => { const nour = client(await serve(), "key-nour"); const started = await nour("tools/call", { name: "archive_closed_tickets", arguments: {} }); expect(started.result.task.status).toBe("working"); }); test("tools/list tells clients which tools run as tasks", async ({ mcp }) => { const [tool] = await mcp.tools.list(); expect(tool.execution).toEqual({ taskSupport: "required" }); }); ``` **Hidden fixture/test file (runs with the example):** ```ts client.ts hidden import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; export function serve(auth: object = { mode: "static", tokens: ["key-nour", "key-sam"] }) { return FrontMcpInstance.createFetchHandler({ ...config, auth } as typeof config); } export function client(server: Awaited>, key?: string) { let id = 0; return async (method: string, params: Record = {}) => { const response = await server( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(typeof params.name === "string" ? { "mcp-name": params.name } : {}), ...(key ? { authorization: `Bearer ${key}` } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": { extensions: { "io.modelcontextprotocol/tasks": {} } }, }, }, }), }), ); return response.json(); }; } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { ArchiveClosedTickets } from "./archive.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [ArchiveClosedTickets] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }; @FrontMcp(config) export default class Server {} ``` `client.ts` is the same as in the first example. ### Cancelling a task `tasks/cancel` marks the task `cancelled` and aborts the tool's [`this.signal`](https://frontmcp.dev/reference/sdk/contexts#thissignal). The tool decides what stopping means: here it stops between pages, and leaves the tickets it has already archived archived. ```ts archive.tool.ts active import { Tool, ToolContext } from "@frontmcp/sdk"; import { archivePage, progress } from "./database"; @Tool({ name: "archive_closed_tickets", description: "Move every closed ticket to cold storage. Runs as a background task.", inputSchema: {}, execution: { taskSupport: "required" }, }) export class ArchiveClosedTickets extends ToolContext { async execute() { for (let page = 1; page <= 20; page++) { if (this.signal?.aborted) { progress.stoppedAt = page; break; } await archivePage(page); } return { pages: progress.archived }; } } ``` ```ts cancel.test.ts import { test, expect } from "@frontmcp/testing"; import { client, serve } from "./client"; import { progress } from "./database"; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("a cancelled task stays cancelled, and the tool stops", async () => { const nour = client(await serve(), "key-nour"); const { taskId } = (await nour("tools/call", { name: "archive_closed_tickets", arguments: {} })).result.task; await wait(120); expect((await nour("tasks/cancel", { taskId })).result).toMatchObject({ resultType: "complete" }); await wait(100); expect((await nour("tasks/get", { taskId })).result).toMatchObject({ status: "cancelled", statusMessage: "The task was cancelled by request.", }); expect(progress.stoppedAt).toBeGreaterThan(1); expect(progress.stoppedAt).toBeLessThan(20); }); test("cancelling a task that has ended changes nothing", async () => { const nour = client(await serve(), "key-nour"); const { taskId } = (await nour("tools/call", { name: "archive_closed_tickets", arguments: {} })).result.task; await nour("tasks/cancel", { taskId }); expect((await nour("tasks/cancel", { taskId })).error).toBeUndefined(); expect((await nour("tasks/get", { taskId })).result.status).toBe("cancelled"); }); ``` ```ts database.ts export const progress = { archived: 0, stoppedAt: 0 }; // Stands in for slow storage: each page takes 40 ms. export async function archivePage(page: number) { await new Promise((resolve) => setTimeout(resolve, 40)); progress.archived = page; } ``` **Hidden fixture/test file (runs with the example):** ```ts client.ts hidden import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; export function serve(auth: object = { mode: "static", tokens: ["key-nour", "key-sam"] }) { return FrontMcpInstance.createFetchHandler({ ...config, auth } as typeof config); } export function client(server: Awaited>, key?: string) { let id = 0; return async (method: string, params: Record = {}) => { const response = await server( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(typeof params.name === "string" ? { "mcp-name": params.name } : {}), ...(key ? { authorization: `Bearer ${key}` } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": { extensions: { "io.modelcontextprotocol/tasks": {} } }, }, }, }), }), ); return response.json(); }; } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { ArchiveClosedTickets } from "./archive.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [ArchiveClosedTickets] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }; @FrontMcp(config) export default class Server {} ``` Pass `this.signal` on to whatever the tool waits for, like `this.fetch()` (which takes it already) or a database driver, so a cancel stops that too. A tool that never looks at the signal runs to the end; its task is still `cancelled`, and what it returns is dropped. ### Asking the user from a task A tool that calls [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit) in a task doesn't fail. The task waits in `input_required`, `tasks/get` shows the question in `inputRequests`, and the client answers it with `tasks/update`. The task then runs the tool again with the answer. The client has to declare `elicitation` too, as for any elicitation. The Call tab, which doesn't do tasks, shows the same question as a form: Initial call configuration: ```js { tool: "purge_closed_tickets", arguments: {} } ``` ```ts purge.tool.ts active import { Tool, ToolContext, z } from "@frontmcp/sdk"; import { runs } from "./database"; @Tool({ name: "purge_closed_tickets", description: "Delete tickets closed more than a year ago, after the user confirms", inputSchema: {}, execution: { taskSupport: "optional" }, }) export class PurgeClosedTickets extends ToolContext { async execute() { runs.count++; const answer = await this.elicit("Delete 340 tickets closed more than a year ago?", z.object({ confirm: z.boolean() })); if (answer.status !== "accept" || !answer.content?.confirm) return { deleted: 0 }; return { deleted: 340 }; } } ``` ```ts input.test.ts import { test, expect } from "@frontmcp/testing"; import { client, serve } from "./client"; import { runs } from "./database"; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); test("the task waits for input, then finishes with the answer", async () => { const nour = client(await serve(), "key-nour"); const { taskId } = (await nour("tools/call", { name: "purge_closed_tickets", arguments: {} })).result.task; await wait(50); const waiting = (await nour("tasks/get", { taskId })).result; expect(waiting).toMatchObject({ status: "input_required", inputRequests: { "elicitation-1": { method: "elicitation/create", params: { message: "Delete 340 tickets closed more than a year ago?" } }, }, }); const answer = { "elicitation-1": { action: "accept", content: { confirm: true } } }; expect((await nour("tasks/update", { taskId, inputResponses: answer })).error).toBeUndefined(); await wait(50); expect((await nour("tasks/get", { taskId })).result).toMatchObject({ status: "completed", result: { structuredContent: { deleted: 340 } } }); expect(runs.count).toBe(2); }); test("an answer for a task that isn't waiting is refused", async () => { const nour = client(await serve(), "key-nour"); const { taskId } = (await nour("tools/call", { name: "purge_closed_tickets", arguments: {} })).result.task; await wait(50); const answer = { "elicitation-1": { action: "decline" } }; await nour("tasks/update", { taskId, inputResponses: answer }); await wait(50); expect((await nour("tasks/update", { taskId, inputResponses: answer })).error).toEqual({ code: -32602, message: `Task ${taskId} is not awaiting input (status: completed)`, }); }); ``` ```ts client.ts import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; export function serve(auth: object = { mode: "static", tokens: ["key-nour", "key-sam"] }) { return FrontMcpInstance.createFetchHandler({ ...config, auth } as typeof config); } /** As before, and the client also declares that it can show forms. */ export function client(server: Awaited>, key?: string) { let id = 0; return async (method: string, params: Record = {}) => { const response = await server( new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(typeof params.name === "string" ? { "mcp-name": params.name } : {}), ...(key ? { authorization: `Bearer ${key}` } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": { extensions: { "io.modelcontextprotocol/tasks": {} }, elicitation: { form: {} }, }, }, }, }), }), ); return response.json(); }; } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { PurgeClosedTickets } from "./purge.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [PurgeClosedTickets] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true }, }; @FrontMcp(config) export default class Server {} ``` ```ts database.ts export const runs = { count: 0 }; ``` The tool ran twice: once up to the question, and once more, from the top, with the answer. A client that doesn't declare `elicitation` gets a `failed` task instead, with the message ``This request requires the `elicitation` client capability``. ### Calling a task tool from code FrontMCP's own 2026-07-28 client, `McpStatelessClient` from `@frontmcp/sdk`, polls for you. When it declares the extension, `callTool()` gets the task, polls `tasks/get` every `pollIntervalMs`, and resolves with the task's `result`. It rejects when the task fails or is cancelled, and answers a task's questions with its `handlers`, as for [an inline elicitation](https://frontmcp.dev/reference/server/protocol-versions#calling-a-2026-07-28-server-from-code). ```ts stateless.test.ts active import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance, McpStatelessClient } from "@frontmcp/sdk"; import { config } from "./main"; test("callTool() waits for the task, and resolves with its result", async () => { const server = await FrontMcpInstance.createFetchHandler({ ...config, auth: { mode: "static", tokens: ["key-nour"] } }); const desk = new McpStatelessClient({ url: "https://desk.example.com/", headers: { authorization: "Bearer key-nour" }, capabilities: { extensions: { "io.modelcontextprotocol/tasks": {} } }, // Hands each request to the server above, so this runs here. A real client leaves it out. fetchImpl: (url, init) => server(new Request(url, init)), }); const result = await desk.callTool("export_tickets", { status: "closed" }); expect(result.structuredContent).toMatchObject({ status: "closed", rows: 4 }); }); ``` ```ts export-tickets.tool.ts import { Tool, ToolContext, z } from "@frontmcp/sdk"; const readPage = async (status: string, page: number) => { await new Promise((resolve) => setTimeout(resolve, 50)); return [`T-${page},${status}`]; }; @Tool({ name: "export_tickets", description: "Export every ticket with a status as CSV. Slow: reads the database a page at a time.", inputSchema: { status: z.enum(["open", "closed"]) }, execution: { taskSupport: "optional" }, }) export class ExportTickets extends ToolContext { async execute({ status }: { status: "open" | "closed" }) { const rows: string[] = []; for (let page = 1; page <= 4; page++) rows.push(...(await readPage(status, page))); return { status, rows: rows.length, csv: ["id,status", ...rows].join("\n") }; } } ``` ```ts main.ts import { App, FrontMcp } from "@frontmcp/sdk"; import { ExportTickets } from "./export-tickets.tool"; @App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], tasks: { defaultPollIntervalMs: 100 }, }; @FrontMcp(config) export default class Server {} ``` Against a real server, give it the server's `url` and leave out `fetchImpl`. ### Clients on older protocol versions Clients on protocol versions before 2026-07-28 use the tasks of MCP 2025-11-25, inside their session. These were checked on a Node server, since the Playground speaks 2026-07-28 only: | | Before 2026-07-28 | 2026-07-28 | | --- | --- | --- | | How the client asks | Per call: `task: { ttl? }` in the `tools/call` params. | Declares the extension; every call to an `"optional"` or `"required"` tool is a task. | | Who may | Any caller with a session, anonymous ones included. The task belongs to the session. | Over HTTP, signed-in callers; the task belongs to the user or key. Over stdio, anonymous callers; the connection owns the task. | | The call's answer | `{ task, content: [], _meta: { "io.modelcontextprotocol/related-task": { taskId } } }`, with `ttl` and `pollInterval` | `{ resultType: "task", task }`, with `ttlMs` and `pollIntervalMs` | | `ttl` | The client's, cut to `maxTtlMs`; `defaultTtlMs` when it asks for none. | Always `defaultTtlMs`. | | `tasks/get` | The task, without its result. | The task, with `result` or `error` once it has ended. | | `tasks/result` | Waits until the task ends, then answers what the call would have: the tool's result, or its JSON-RPC error. For a cancelled task, `-32603` `Terminal task has no outcome`. | Removed. | | `tasks/list` | The session's tasks. | Removed. | | `tasks/cancel` | Answers the cancelled task (`The task was cancelled by request.`). A task that has ended gets `-32602` `Cannot cancel task: already in terminal status '…'`. | Answers `{}` after running `tasks:cancel`, including cancel hooks and SIGTERM for a CLI worker. Changed in 1.9.5: before, it did not run that flow. | | `tasks/update` | None. | Answers a task's `inputRequests`. | | Status notifications | `notifications/tasks/status` on the session's `GET` stream, when the task starts and when it ends. | None. | | Asking for a task on a `"forbidden"` tool | `-32601` `Tool "help-desk:search_tickets" does not support task-augmented invocation` | Runs inline. | | Not asking on a `"required"` tool | `-32601` `Tool "help-desk:archive_closed_tickets" requires task-augmented invocation` | `-32602`, as [above](https://frontmcp.dev/reference/server/tasks#executiontasksupport). | | An unknown task, or another session's | `-32602` `Failed to retrieve task: Task not found` | `-32602` `Task not found` | `initialize` advertises `capabilities.tasks` (`cancel`, `list`, `requests.tools.call`) when a tool sets `taskSupport`; `server/discover` always lists the extension (see [What `server/discover` returns](https://frontmcp.dev/reference/server/apps#what-serverdiscover-returns)). [Protocol versions](https://frontmcp.dev/reference/server/protocol-versions) covers how FrontMCP tells the two kinds of client apart. ### Tasks over stdio A 2026-07-28 stdio caller is anonymous, but the server can identify its connection. Tasks belong to that connection: it can create, read, update and cancel them; another connection gets `Task not found`. The client declares `io.modelcontextprotocol/tasks` in `_meta["io.modelcontextprotocol/clientCapabilities"].extensions` on each request, just as over HTTP. `tasks.maxConcurrentPerSession` counts unfinished tasks for that connection. Once the call returns a task handle, stop the task with `tasks/cancel` and its `taskId`; `notifications/cancelled` targets requests still in flight. New in 1.10.5. Stdio serves the 2026-07-28 tasks extension. The authentication requirement above applies to HTTP; stdio doesn't need credentials to keep tasks apart. > **Note** > > The Playground uses a fetch handler, so it can't show connection-owned stdio tasks. Run the server with [`runStdio()`](https://frontmcp.dev/reference/sdk/frontmcp-instance#serving-over-stdio) or [`--stdio`](https://frontmcp.dev/reference/deployment/mcp-clients#starting-the-server-over-stdio), and send newline-delimited JSON-RPC on stdin. ### Keeping tasks in Redis With tasks in memory, only the instance that started a task knows about it. A server that runs as [several instances](https://frontmcp.dev/reference/deployment/high-availability) behind a load balancer keeps them in Redis, so a `tasks/get` or `tasks/cancel` can land anywhere: ```ts main.ts @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], redis: { host: process.env.REDIS_HOST!, port: 6379 }, // sessions, and tasks too // or tasks only: tasks: { redis: { provider: "redis", host: process.env.REDIS_HOST!, port: 6379 } }, }) export default class Server {} ``` The task still runs on the instance that started it. Checked with two Node servers sharing one Redis: a task started on the first was readable from the second, and a `tasks/cancel` sent to the second aborted the tool's `this.signal` on the first, through Redis pub/sub. Vercel KV can't do this: it has no pub/sub. With a Vercel KV `redis`, FrontMCP serves without tasks and logs ``[tasks] Background tasks are disabled: Vercel KV has no pub/sub``, unless `tasks.enabled` is `true`, which [refuses to start](https://frontmcp.dev/reference/server/tasks#vercel-kv-is-not-supported-for-task-stores). ### Running each task in its own process With the default `runner: "in-process"`, a task is a promise in the server's process: a restart loses it. `runner: "cli"` starts a separate Node process for each task instead, which runs the tool, writes the outcome to the store and exits. It needs a store both processes can read, a SQLite file or Redis: ```bash npm install @frontmcp/storage-sqlite ``` ```ts main.ts import { homedir } from "node:os"; import { join } from "node:path"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], tasks: { runner: "cli", sqlite: { path: join(homedir(), ".help-desk", "tasks.db") }, // a full path: "~" isn't expanded }, }) export default class Server {} ``` The worker records its host; only that host checks whether it died or sends SIGTERM, so another host sharing the Redis store does not signal a local process with the same PID. The task's process runs the same file as the server, with `__run-task ` added to its arguments and the task's id in the `FRONTMCP_RUN_TASK_ID` environment variable, which makes `@FrontMcp` run that one task and exit instead of serving. To start it some other way, set `cliRunnerCommand`, like `{ exe: "node", args: ["dist/main.js"] }`: the arguments are added after `args`. Checked on a Node server with `runner: "cli"` and a SQLite file: - Each task ran in its own process, and the server answered while it did. - A completed task could still be read after the server restarted. - When a task's process was killed, the next `tasks/get` reported the task `failed`, with `Task runner exited before completing the task`. - A client before 2026-07-28 that cancelled got the process stopped with `SIGTERM`: the tool's `this.signal` aborted, with the reason `SIGTERM`. **A 2026-07-28 `tasks/cancel` also stops the worker through the `tasks:cancel` flow**. Changed in 1.9.5: before, it only marked the task cancelled and let the worker continue. - `tasks/result` from a client before 2026-07-28 answered up to `pollIntervalMs` after the task ended, since a SQLite file can't tell the server the moment another process writes to it. ### Where tasks can't run A task needs a process that keeps running after the response that started it. On an edge runtime, like Cloudflare Workers or Vercel Edge Functions, FrontMCP serves without tasks, and logs: ```text [tasks] Background tasks are unavailable on this edge/serverless runtime: the in-process runner cannot continue past the HTTP response, and an in-memory store is not shared between isolates. Serving without tasks. Configure `tasks.redis` (Upstash Redis over HTTP works on Workers) to enable them. ``` Tools then behave as with `tasks.enabled: false`. With a `redis` (here or at the top level), or `tasks: { enabled: true }`, it sets tasks up anyway and warns that ``task bodies will not execute``; `tasks: { enabled: true }` without Redis doesn't start at all, with `Tasks require distributed storage on Edge runtime. Configure Redis or Upstash.` Add `strict: true` to refuse to start instead of warning. This was checked in Node by making FrontMCP detect an edge runtime (`EDGE_RUNTIME` and `VERCEL_ENV` set). FrontMCP doesn't detect other serverless platforms. In an AWS Lambda function it sets tasks up as on any Node server, though the platform may freeze the function once the response is sent. There, use [jobs](https://frontmcp.dev/learn/running-jobs-in-the-background) with a store that outlives the function, or a queue of your own. --- ## Troubleshooting ### `Tasks require an authenticated caller under protocol 2026-07-28` The full message goes on: `there are no protocol sessions, so an anonymous task could not be scoped to its creator`. A 2026-07-28 HTTP client that declared the tasks extension, and sent no credentials, called a tool with `taskSupport`, or sent `tasks/get`, `tasks/update` or `tasks/cancel`. The first example's [last test](https://frontmcp.dev/reference/server/tasks#letting-a-tool-run-as-a-task) gets it. Put [`auth`](https://frontmcp.dev/reference/auth/modes) in front of the server so callers sign in, or keep the tool inline (`"forbidden"`, the default) for anonymous callers. A client that doesn't declare the extension gets an `"optional"` tool's result inline. Over stdio the connection identifies the owner, so no credentials are needed. ### `Tool "…" runs as a task; declare the io.modelcontextprotocol/tasks extension in clientCapabilities` JSON-RPC error `-32602`. The tool has `taskSupport: "required"` and the client didn't declare the extension, as in [Requiring a task](https://frontmcp.dev/reference/server/tasks#requiring-a-task). Use a client that supports tasks, or make the tool `"optional"`. ### `tasks/get requires the io.modelcontextprotocol/tasks extension` HTTP `400` with JSON-RPC error `-32021`, and `data.requiredCapabilities` naming the extension. Under 2026-07-28, capabilities are per request: declare the extension on every `tasks/get`, `tasks/update` and `tasks/cancel`, not just on the call that started the task. ### `Task not found` JSON-RPC error `-32602`. The id is wrong, the task has outlived its `ttlMs`, it's another caller's, or it was started on another instance and tasks are in memory. Keep them in [Redis](https://frontmcp.dev/reference/server/tasks#keeping-tasks-in-redis) when the server runs as several instances. ### `Method not found: tasks/result was removed in protocol 2026-07-28` HTTP `404` with `-32601`, for `tasks/result` or `tasks/list`. A 2026-07-28 client polls `tasks/get`, whose answer carries the `result` once the task has completed. There's no list: keep the ids you were given. ### `Task … is not awaiting input (status: …)` JSON-RPC error `-32602` from `tasks/update`. The task isn't `input_required` any more: it was answered already, or it ended. Read it with `tasks/get` first. ### `Too many tasks in progress for this caller` The caller reached `tasks.maxConcurrentPerSession` (`16` by default). The call fails with `CONCURRENCY_LIMIT` in an `isError` result. Wait for a task to finish, or cancel one. The limit counts unfinished tasks per session; under MCP 2026-07-28, per authenticated HTTP subject or stdio connection. ### A failed task's error has no message Changed in 1.9.5: task errors use the same mapping as inline calls. `this.fail()` keeps its public message and a thrown `PublicMcpError` keeps its code. Update if a task loses either. ```ts // The public message reaches the client this.fail(new PublicMcpError("There are no closed tickets to export.")); // Throwing preserves the public message and code too throw new PublicMcpError("There are no closed tickets to export.", "NO_CLOSED_TICKETS"); ``` ### `[tasks] runner: "cli" requires a persistent backend` The server didn't start. The message goes on: ``(`tasks.sqlite`, `tasks.redis`, or a top-level `sqlite`) — the detached worker cannot share an in-memory store with this host.`` Give the tasks a SQLite file or Redis, as in [Running each task in its own process](https://frontmcp.dev/reference/server/tasks#running-each-task-in-its-own-process). ### `Vercel KV is not supported for task stores` The message goes on: `Task result blocking and cancel signalling require pub/sub. Use Redis or Upstash instead.` The server has `tasks: { enabled: true }` and its tasks would be in Vercel KV. Give them `tasks.redis` with Redis or Upstash, or remove `enabled: true` to serve without tasks. ### `Task store is not configured` JSON-RPC error `-32603` from `tasks/get`, `tasks/update` or `tasks/cancel`: the server has `tasks: { enabled: false }`, or runs on an [edge runtime](https://frontmcp.dev/reference/server/tasks#where-tasks-cant-run) without Redis. --- # Configuration files > Every place a FrontMCP server takes settings from, the @FrontMcp options, the environment variables FrontMCP reads, .env and YAML files through ConfigPlugin, and the frontmcp.config file the CLI reads, and which one wins. Source: https://frontmcp.dev/reference/server/config-files [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A FrontMCP server takes its settings from four places: the options in its [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp) decorator, the environment variables FrontMCP reads for itself, your own settings in `.env` and YAML files, which the built-in `ConfigPlugin` loads into `this.config`, and `frontmcp.config.ts`, which only the `frontmcp` command-line tool reads. This page lists every one and says which wins when two disagree. ```ts // 1. Code: the server's options @FrontMcp({ info, apps, http: { port: 3000 }, plugins: [ConfigPlugin.init({ schema, loadYaml: true })] }) // 2. The environment FrontMCP reads: PORT, NODE_ENV, MCP_SESSION_SECRET, … // 3. .env, .env.local and config.yml: your settings, read by ConfigPlugin into this.config // 4. frontmcp.config.ts: read by `frontmcp dev`, `build`, `test`, `inspector` and `registry`, never by the server ``` --- ## Reference ### Where settings come from | Source | Read by | When | For | | --- | --- | --- | --- | | [`@FrontMcp` options](https://frontmcp.dev/reference/sdk/frontmcp) | FrontMCP | When the decorated class is imported | Everything about the server: apps, port, auth, storage, logging. | | [Environment variables](https://frontmcp.dev/reference/server/config-files#environment-variables) | FrontMCP | Some when the `@FrontMcp` options are read, some at startup, some on each request | Secrets, where the server runs, and defaults for a few options. | | [`.env`, `.env.local`, `config.yml`](https://frontmcp.dev/reference/server/config-files#configplugin) | `ConfigPlugin` | When the server starts | Your own settings, like API URLs and limits, read with `this.config`. | | [`frontmcp.config.*`](https://frontmcp.dev/reference/server/config-files#frontmcpconfig) | The `frontmcp` CLI | When you run a `frontmcp` command | How the CLI starts, builds and tests the project. The server never reads it. | ### Which setting wins 1. **An option you set in `@FrontMcp` wins over the environment variable that provides its default.** `http.port` wins over `PORT`, `http.entryPath` over `FRONTMCP_HTTP_ENTRY_PATH`, `http.security.bindAddress` over `FRONTMCP_BIND_ADDRESS`, and `http.security.dnsRebindingProtection.allowedHosts` over `FRONTMCP_ALLOWED_HOSTS`, and each field of `http.securityHeaders` over the `FRONTMCP_*` response-header variable for it. Leave an option out to let the environment decide. 2. **Some settings exist only as environment variables:** the secrets and `NODE_ENV`. `FRONTMCP_TRUST_PROXY` also controls proxy trust unless `throttle.ipFilter.trustProxy` is `true`. Set them where the process starts. 3. **For your own settings, with a schema:** a real environment variable wins over `.env.local`, which wins over `.env`, which wins over the YAML file, which wins over the schema's defaults. [Without a schema](https://frontmcp.dev/reference/server/config-files#flat-mode) it's different: a `.env` file wins over the real environment. 4. **The CLI passes settings to the server as environment variables.** `frontmcp dev` turns `--port` or `frontmcp.config`'s `transport.http.port` into `PORT`, and `transport.http.path` into `FRONTMCP_HTTP_ENTRY_PATH`, so an option set in `@FrontMcp` still wins over both. A bundle from `frontmcp build` carries a deployment's `server` block and `env` the same way, as variables it sets when it starts, unless the environment already has them: see [what a build carries](https://frontmcp.dev/reference/server/config-files#caveats-1). 5. **A `.env` file only reaches FrontMCP's own variables if it's loaded before FrontMCP reads them.** `frontmcp dev` and `frontmcp test` load `.env` and `.env.local` before the server starts. `ConfigPlugin` loads them when the server starts, which is after FrontMCP has read `PORT`: see [`PORT` in `.env` is ignored](https://frontmcp.dev/reference/server/config-files#port-in-env-is-ignored). ### Environment variables The environment variables FrontMCP reads. The last column says how this site checked each one: **Ran** means we saw FrontMCP behave this way; **Read** means we read it in FrontMCP's published code without running it. Flags marked *1/true* are on only when set to `1` or `true`. #### Starting and serving | Variable | What it does | Checked | | --- | --- | --- | | `PORT` | The default for `http.port`; otherwise `3000`. Read when the `@FrontMcp` options are read: when the decorated class is imported, or when `create()` or `createFetchHandler()` runs. (Before 1.9.0, once, when `@frontmcp/sdk` was imported.) | Ran | | `FRONTMCP_HTTP_ENTRY_PATH` | The default for `http.entryPath`, the path of the MCP endpoint; otherwise the root. | Ran | | `FRONTMCP_BIND_ADDRESS` | The default for `http.security.bindAddress`: `loopback` (`127.0.0.1`, the default), `all` (`0.0.0.0`) or an address. | Ran | | `FRONTMCP_ALLOWED_HOSTS` | A comma-separated list of the `Host` headers the server answers, compared whole, port included; others get `403 {"error":"Forbidden","message":"Invalid Host header"}`. It replaces the list FrontMCP works out from the address it listens on, so to keep local clients, add `localhost:3000` and `127.0.0.1:3000` too. A server listening on all addresses checks no `Host` at all until you set this, and logs a warning saying so. | Ran | | `FRONTMCP_TRUST_PROXY` | `1`, `true`, `yes` or `on`: take the client's address from `X-Forwarded-For`. Only behind a proxy you control. See [the client's address](https://frontmcp.dev/reference/sdk/create-fetch-handler#the-clients-address). | Ran | | `FRONTMCP_TRUSTED_PROXY_DEPTH` | Which `X-Forwarded-For` entry to use, counting from the right. Default `1`. | Ran | | `FRONTMCP_PUBLIC_URL` | The server's public base URL, for the URLs FrontMCP builds itself: the OAuth resource and metadata, and the issuer (`iss`) of the tokens it issues, unless `local.issuer` names one. Without it, FrontMCP uses the request's `Host`, or `X-Forwarded-Proto` and `X-Forwarded-Host` with `FRONTMCP_TRUST_PROXY`, on the Node server as through `createFetchHandler()`. The tokens FrontMCP issues name that address as `aud` and are accepted only there, so set it behind a proxy. Changed in 1.8.4: it sets `iss` too, so tokens issued before the upgrade are refused once. | Ran | | `FRONTMCP_PUBLIC_HOST` | The host in the issuer FrontMCP names when neither `local.issuer` nor `FRONTMCP_PUBLIC_URL` is set: `http://:`, as in [`local` auth](https://frontmcp.dev/reference/auth/local). It doesn't change `aud`. Changed in 1.8.5: it no longer wins over `FRONTMCP_PUBLIC_URL`, and without either the Node server's issuer is the request's address, not `http://localhost:`. | Ran | | `FRONTMCP_STDIO` | *1/true*: importing the `@FrontMcp` class serves over stdio instead of HTTP. See [`FrontMcpInstance`](https://frontmcp.dev/reference/sdk/frontmcp-instance#what-frontmcp-does-on-import). | Ran | | `FRONTMCP_SERVERLESS` | *1/true*: build a request handler instead of listening, for `getServerlessHandlerAsync()`. | Ran | | `FRONTMCP_WORKER` | *1/true*, with `FRONTMCP_SERVERLESS`: the handler is a web-standard `fetch` handler instead of an Express one. | Ran | | `FRONTMCP_SCHEMA_EXTRACT` | *1/true*: record the configuration and start nothing. Tools that inspect a server use it, and so does this site's Playground. | Ran | | `FRONTMCP_DAEMON_SOCKET` | A path: `FrontMcpInstance.bootstrap()` serves on this Unix socket instead of a port. | Ran | | `FRONTMCP_CORS_ORIGINS`, `FRONTMCP_CORS_CREDENTIALS`, `FRONTMCP_CORS_MAX_AGE` | CORS for the HTTP server, when `@FrontMcp` leaves `http.cors` unset: the allowed origins as a JSON array, `true` to allow credentials, and the preflight's `Access-Control-Max-Age` in seconds. Since 1.9.0, for a deployment's `server.http.cors`. | Ran | | `FRONTMCP_AFFINITY_COOKIE`, `FRONTMCP_AFFINITY_COOKIE_DOMAIN`, `FRONTMCP_AFFINITY_COOKIE_SAMESITE` | The name, `Domain` and `SameSite` of the load-balancer affinity cookie a distributed server sets. Since 1.9.0, for a deployment's `server.cookies`. | Read | #### MCP Registry | Variable | What it does | Checked | | --- | --- | --- | | `MCP_REGISTRY_PUBLIC_KEY` | Default public key for `@FrontMcp({ registry })`'s domain proof, read on each request. `registry.publicKey` wins; `registry.publicKeyEnv` names another variable. An invalid value logs once and serves `404`. The browser build doesn't read it. | Read | | `MCP_REGISTRY_PRIVATE_KEY` | Ed25519 hex seed or PEM for `frontmcp registry publish` and `status`. Used after `--key-file`, before `~/.frontmcp/keys/mcp-registry.key`. Keep it on the publishing machine, not the server. | Read | | `ACTIONS_ID_TOKEN_REQUEST_URL`, `ACTIONS_ID_TOKEN_REQUEST_TOKEN` | Supplied by GitHub Actions for `--auth github-oidc`, in a job with `id-token: write`. | Read | `FRONTMCP_PUBLIC_URL` also pins the server card's endpoint and the catalog's card URL. The card is cacheable for an hour only with that URL set; otherwise it uses the request's origin and sends `no-store`. See [MCP Registry](https://frontmcp.dev/reference/deployment/mcp-registry#documents-served). #### Response headers FrontMCP's HTTP server and `createFetchHandler()` add security headers to every response. `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY` are on by default, `X-Powered-By` is removed, and the rest are off until you set them. Each variable has an option in [`http.securityHeaders`](https://frontmcp.dev/reference/sdk/frontmcp#http), which wins over it. In a variable, `off`, `false` or `none` turns a header off; in `http.securityHeaders`, `false` does. (Before 1.8.7 FrontMCP sent neither default header, and sent `X-Powered-By: Express`.) | Variable | What it does | Checked | | --- | --- | --- | | `FRONTMCP_HSTS` | The `Strict-Transport-Security` value, like `max-age=63072000; includeSubDomains`. No default. | Ran | | `FRONTMCP_CONTENT_TYPE_OPTIONS` | The `X-Content-Type-Options` value. Default `nosniff`. | Ran | | `FRONTMCP_FRAME_OPTIONS` | The `X-Frame-Options` value. Default `DENY`. | Ran | | `FRONTMCP_HEADERS_CUSTOM` | More headers, as a JSON object of strings: `{"x-desk":"1"}`. | Ran | | `FRONTMCP_CSP_ENABLED` | *1/true*: send a `Content-Security-Policy` header, built from the two variables below. | Ran | | `FRONTMCP_CSP_DIRECTIVES` | The policy, directives separated by `;`: `default-src 'none'; frame-ancestors 'none'`. | Ran | | `FRONTMCP_CSP_REPORT_URI`, `FRONTMCP_CSP_REPORT_ONLY` | A `report-uri` for the policy, and *1/true* to send it as `Content-Security-Policy-Report-Only`. | Ran | #### Environment and production | Variable | What it does | Checked | | --- | --- | --- | | `NODE_ENV` | `production` hides internal error messages from clients, requires `MCP_SESSION_SECRET` for clients that open a session, and refuses a short `JWT_SECRET`. It's also `this.runtimeContext.env`, and `availableWhen: { env }` matches it. | Ran | | `FRONTMCP_DEPLOYMENT_MODE` | `serverless` or `distributed`: sets `runtimeContext.deployment`. A `distributed` server also listens on all addresses unless told otherwise, and sends its machine id in an `X-FrontMCP-Machine-Id` header on its responses, `/healthz` and 2026-07-28 requests included, through `createFetchHandler()` too. (Before 1.9.0, only on the MCP responses of clients with a session.) | Ran | | `FRONTMCP_PROVIDER` | Sets `runtimeContext.provider` when detection can't tell, like `fly` or `docker`. | Ran | | `FRONTMCP_BUILD_TARGET` | Sets `runtimeContext.target`, like `node` or `cli`, for a server that wasn't started from a `frontmcp build` bundle, which sets its own. | Ran | | `VERCEL`, `AWS_LAMBDA_FUNCTION_NAME`, `CF_PAGES`, `NETLIFY`, `AZURE_FUNCTIONS_ENVIRONMENT`, `K_SERVICE`, `FLY_APP_NAME`, `RENDER`, `RAILWAY_ENVIRONMENT`, `EDGE_RUNTIME`, `VERCEL_ENV` | Set by hosting platforms; FrontMCP reads them to detect the provider, the deployment and the runtime. | Ran | | `DEBUG` | *1/true*: print a few extra diagnostics to the console, such as tool UI rendering errors, as development does. | Read | [Environment awareness](https://frontmcp.dev/reference/server/environment#how-frontmcp-detects-the-environment) explains the detection in full. #### Secrets Set these in every deployment. Outside production, FrontMCP makes up a stand-in and logs a warning: a key derived from the machine for sessions, and a random key per process for the others, which doesn't survive a restart or match another instance's. | Variable | What it does | Checked | | --- | --- | --- | | `MCP_SESSION_SECRET` | Encrypts session IDs and signs session data. Set it in production: without it, a client on a protocol version before 2026-07-28 can't open a session (`initialize` fails with `500` `{"error":"server_misconfigured","code":"SESSION_SECRET_REQUIRED",…}`, on FrontMCP's Node server as on every other entry point), public servers included. Anonymous and static-key 2026-07-28 requests have no session and work without it. Changed in 1.8.5: before, every MCP request failed without it. A session ID minted under another secret gets `404` `invalid session id` (changed in 1.8.6: a server with Redis served it). Before 1.8.7 the Node server answered a plain `Internal Server Error` and logged the code. See [production secrets](https://frontmcp.dev/reference/sdk/create-fetch-handler#production-secrets). | Ran | | `JWT_SECRET` | Signs the tokens FrontMCP issues when it's the authorization server (`local` auth). At least 32 bytes; in production, missing or shorter fails startup. Also the fallback for `VAULT_SECRET`. | Ran | | `VAULT_SECRET` | Encrypts stored credentials, and signs the `requestState` of a 2026-07-28 request that [asks the user](https://frontmcp.dev/learn/asking-the-user). Falls back to `JWT_SECRET`. Give every instance the same one: with neither set, each instance signs with its own random key, and since 1.8.6 a production server that sets `redis`, or `transport.persistence` as an object, logs a warning at startup. | Ran | | `MCP_ELICITATION_SECRET` | Encrypts questions waiting for the user's answer in the elicitation store. Falls back to `MCP_SESSION_SECRET`, then `MCP_SERVER_SECRET`. | Read | | `MCP_SERVER_SECRET` | The last fallback for `MCP_ELICITATION_SECRET`. | Read | #### Storage | Variable | What it does | Checked | | --- | --- | --- | | `REDIS_URL`, `REDIS_HOST` | Where you haven't configured storage (background tasks, questions waiting for an answer, [guard](https://frontmcp.dev/reference/sdk/guard) counters whose `storage` has no `type`), FrontMCP uses Redis when either is set, and memory otherwise. Sessions don't: they use the `redis` option. | Read | | `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` | The same, for Upstash. Checked before Redis. | Read | | `KV_REST_API_URL`, `KV_REST_API_TOKEN` | The same, for Vercel KV; also what `redis: { provider: "vercel-kv" }` uses when it has no `url` and `token`. | Read | | `FRONTMCP_SQLITE_PATH` | The SQLite database file. It replaces `sqlite.path`, and turns SQLite storage on for a server that has no `sqlite` option. It needs `better-sqlite3`: without it the server stops at startup with `Cannot find module 'better-sqlite3'`. | Ran | | `MACHINE_ID`, `MACHINE_ID_PATH` | The machine ID, which stands in for `MCP_SESSION_SECRET` outside production, and the file it's kept in (`.frontmcp/machine-id`). With `FRONTMCP_DEPLOYMENT_MODE=distributed`, `HOSTNAME` is used instead. | Read | #### Logs and metrics | Variable | What it does | Checked | | --- | --- | --- | | `FRONTMCP_LOG_LEVEL` | The server's log level when `logging.level` isn't set: `debug`, `verbose`, `info`, `warn`, `error` or `off`, in any case. Anything else is `info`. See [choosing a level](https://frontmcp.dev/reference/server/logging#choosing-a-level). New in 1.9.3. | Ran | | `FRONTMCP_LOG_DIR`, `FRONTMCP_HOME`, `FRONTMCP_APP_NAME`, `FRONTMCP_LOGS_MAX` | Where a stdio server writes its log files, their name, and how many are kept. See [where the lines go](https://frontmcp.dev/reference/server/logging#where-the-lines-go). | Ran | | `NO_COLOR`, `FORCE_COLOR` | Turn colors in console logs off, or on when the output isn't a terminal. | Read | | `FRONTMCP_PERF` | *1/true*: log how long each part of startup took, as `[PERF]` lines. | Read | | `FRONTMCP_METRICS_TOKEN` | The token that protects `/metrics` when `metrics.auth` is `"token"`. `metrics.tokenEnv` names a different variable. See [Observability and telemetry](https://frontmcp.dev/reference/server/observability). | Read | #### Set by the CLI `frontmcp` sets these for the processes it starts; you don't set them yourself. `FRONTMCP_CONFIG` is the exception: it tells the CLI where [`frontmcp.config`](https://frontmcp.dev/reference/server/config-files#finding-the-file) is. | Variable | Set by | What for | | --- | --- | --- | | `FRONTMCP_CLI_VERBOSE` | Servers built with `--target cli`, run with `--verbose` | Show logs on the console as well as in the log file. | | `FRONTMCP_DEV_BOOTSTRAP_SENTINEL` | `frontmcp dev --stdio` | The server prints a line when it has started. | | `FRONTMCP_DEV_STDIO_FD` | [`frontmcp dev --stdio`](https://frontmcp.dev/reference/cli#frontmcp-dev) | `3`: the server talks to the CLI over the child process's IPC channel instead of standard input and output. | | `FRONTMCP_TEST_AUTH_MODE`, `FRONTMCP_TEST_AUTH_TYPE` | `@frontmcp/testing`, from [`test.use({ auth })`](https://frontmcp.dev/reference/testing/fixtures#testuseoptions) | The auth `mode` and `type` a test declares. Nothing in FrontMCP reads them; your entry file can. | | `FRONTMCP_RUN_TASK_ID` | Servers built with `--target cli`, for a background task run in its own process | Which task that process runs. | | `FRONTMCP_PROJECT_COMMAND` | [Project commands](https://frontmcp.dev/reference/server/config-files#fields) | The command's arguments, as JSON. | Variables you name yourself are read too: an agent's `apiKey: { env: "ANTHROPIC_API_KEY" }`, `loader.tokenEnvVar` for [`App.esm()`](https://frontmcp.dev/reference/sdk/app#apps-from-outside-your-code), and `metrics.tokenEnv`. ### `ConfigPlugin` `ConfigPlugin` is a built-in [plugin](https://frontmcp.dev/reference/sdk/plugin) for your own settings. It reads them when the server starts, from `.env` files, an optional YAML file and the environment, checks them against a Zod schema, and gives every tool, resource, agent, job and channel `this.config` to read them with. ```ts main.ts import { ConfigPlugin, FrontMcp, z } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; const settings = z.object({ desk: z .object({ webUrl: z.string().url().default("https://desk.example.com"), pageSize: z.coerce.number().default(20), }) .prefault({}), }); @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [ConfigPlugin.init({ schema: settings, loadYaml: true })], }) export default class Server {} ``` Put it in `@FrontMcp({ plugins })` for every app, or in one `@App({ plugins })`. `ConfigPlugin.init()` without an argument uses the defaults (before 1.8.6 it failed with `Cannot read properties of undefined (reading 'providers')`, so `init({})` was needed), and since 1.9.4 so does the bare `ConfigPlugin` class, which before registered nothing. #### `ConfigPlugin.init(options)` | Option | Type | Default | Description | | --- | --- | --- | --- | | `schema` | Zod object | none | The shape of your settings. With it, settings are nested, typed and checked at startup. Without it, they're [flat](https://frontmcp.dev/reference/server/config-files#flat-mode): every environment variable, by name. | | `loadEnv` | `boolean` | `true` | Read `.env` and `.env.local`. With a `schema`, `false` also ignores the real environment, leaving the YAML file and the defaults. Without one, the real environment is always read. | | `envPath` | `string` | `".env"` | The `.env` file, relative to `basePath`. | | `localEnvPath` | `string` | `".env.local"` | A second file whose values win over `envPath`'s. Keep it out of version control. | | `loadYaml` | `boolean` | `false` | Read `configPath`. Only with a `schema`. | | `configPath` | `string` | `"config.yml"` | The YAML file, relative to `basePath`. FrontMCP tries the name as given, then with `.yml`, then `.yaml`, so `"config"` and `"config.yml"` both find `config.yaml`. | | `basePath` | `string` | `process.cwd()` | The folder the files are in. Use your project's root, not the folder you happen to start from. | | `populateProcessEnv` | `boolean` | `true` | Copy the `.env` files' values into `process.env`, for keys that aren't already set. | | `strict` | `boolean` | `true` | Settings that don't match the schema stop the server. With `false`, the server logs a warning and starts, and `this.config` serves the settings as read, without the schema's defaults. See [`Configuration validation failed`](https://frontmcp.dev/reference/server/config-files#configuration-validation-failed--expected-number-received-string). (Changed in 1.9.3: before, it had no effect.) | #### With a schema FrontMCP starts from an empty object and fills it, each source over the one before: 1. The YAML file, when `loadYaml` is `true`. Its numbers, booleans and lists keep their types. 2. `.env`, then `.env.local`, then the real environment. Only variables whose name matches a path in the schema are used. 3. The schema checks the result and fills in its defaults. If it fails, the server doesn't start: [`Configuration validation failed`](https://frontmcp.dev/reference/server/config-files#configuration-validation-failed--expected-number-received-string). With `strict: false`, it starts with the settings from steps 1 and 2 as they are. A path's variable name is the path in capitals, with each `.` replaced by `_`. Words inside a name aren't split: `desk.pageSize` is `DESK_PAGESIZE`, not `DESK_PAGE_SIZE`. Environment variables are always strings, so a number or boolean read from one needs a schema that converts it: `z.coerce.number()`, `z.stringbool()`. In Zod 4, `.default({})` on a nested object returns `{}` as is, without the defaults inside it. Use `.prefault({})`, as above, to have the defaults filled in. #### Flat mode Without a `schema`, settings are every variable of the real environment plus the `.env` files, by name: `this.config.get("DESK_WEB_URL")`. In this mode a `.env` file wins over the real environment, the opposite of schema mode, and `loadYaml` is ignored. #### `this.config` `this.config` is the `ConfigService`: in tools, resources, agents, jobs and channels, but not prompts. Paths are dotted, like `"desk.pageSize"`, or a variable's name in flat mode. | Method | Returns | Description | | --- | --- | --- | | `get(path, defaultValue?)` | the value | The value at `path`, or `defaultValue` when there's none. | | `getOrThrow(path)`, `getRequired(path)` | the value | The value, or throws `ConfigMissingError`: [`Required configuration "…" is not defined`](https://frontmcp.dev/reference/server/config-files#required-configuration--is-not-defined). | | `getNumber(path, defaultValue?)` | `number` | Converts a string with `Number()`. `defaultValue`, or `NaN`, when it's missing or not a number. | | `getBoolean(path, defaultValue?)` | `boolean` | `true` for `true`, `"true"`, `"1"`, `"yes"` or `"on"` (any case); `defaultValue`, or `false`, when it's missing. | | `has(path)` | `boolean` | Whether `path` has a value. | | `getAll()`, `getParsed()` | object | Every setting. | `getConfig(this)` returns the same service, and `tryGetConfig(this)` returns `undefined` when the plugin isn't installed. Changed in 1.9.7: both helpers now accept execution contexts under strict TypeScript; before, passing `this` failed with `TS2345`. #### Other exports | Export | Description | | --- | --- | | `loadConfig(schema, options?)` | What `ConfigPlugin` does with a schema, as a function: takes the same `basePath`, `envPath`, `localEnvPath`, `configPath`, `loadEnv`, `loadYaml` and `populateProcessEnv`, and returns the checked settings. For code that runs outside a call, like a [provider's factory](https://frontmcp.dev/reference/server/config-files#reading-settings-in-a-provider). | | `createContextResolver(config, { entityType, entityName })` | For settings per agent, plugin or adapter: `get(key)` tries `..`, then `.`, then ``, and throws `ConfigNotFoundError` when none has a value; `tryGet(key)` returns `undefined`. A `-` in the name becomes `_`: `agents.triage_agent.apiKey`. Needs a schema. | | `loadEnvFiles(basePath?, envPath?, localEnvPath?)`, `parseEnvContent(text)`, `populateProcessEnv(values, override?)` | Read and parse `.env` files, and copy values into `process.env`. | | `pathToEnvKey(path)`, `extractSchemaPaths(schema)`, `mapEnvToNestedConfig(env, paths)` | The mapping between paths and variable names. | | `ConfigValidationError`, `ConfigMissingError` | The errors, with `zodError` and `key`. | #### `.env` files `ConfigPlugin`, `frontmcp dev` and `frontmcp test` read the same format: ```bash .env # A comment DESK_WEB_URL=https://desk.example.com DESK_PAGESIZE=50 WELCOME="Hi!\nHow can we help?" ``` One `NAME=value` per line, with no spaces around `=`. Quotes around a value are removed, and in double quotes `\n` and `\t` become a new line and a tab. Lines starting with `export`, or with spaces around `=`, are skipped without a warning. #### YAML files ```yaml config.yml desk: webUrl: https://staging.desk.example.com pageSize: 50 regions: [eu, us] ``` A missing file is skipped. A file that isn't valid YAML stops the server with a `YAMLException`. YAML is read only with a `schema`; a real environment variable, like `DESK_PAGESIZE`, still wins over the file. #### Caveats - Settings are read once, when the server starts. A changed file or variable takes a restart. - In a browser there are no files: FrontMCP's browser build skips `.env` and YAML and reads the schema's defaults and `process.env` where there is one (since 1.9.1; before, `ConfigPlugin` stopped the server with `path.resolve() is not available in browser environments`). So the Playground's examples read the environment and the defaults. Its server starts before any test runs, so the tests that set variables start a server of their own. YAML and `.env` loading were checked in Node. - `ConfigPlugin` logs `[config] Context property 'config' already exists on ExecutionContextBase. Skipping.` at startup. It's harmless: `this.config` works. - A prompt has no `this.config`. Use `this.get(ConfigService)`, with `ConfigService` imported from `@frontmcp/sdk`. ### `frontmcp.config` The `frontmcp` CLI reads `frontmcp.config.ts` for its own commands. The server doesn't read it, and nothing in it reaches `@FrontMcp` except what the CLI passes as [environment variables](https://frontmcp.dev/reference/server/config-files#which-setting-wins): `frontmcp dev` when it starts the server, and `frontmcp build` in the bundles it makes. ```ts frontmcp.config.ts import { defineConfig } from "frontmcp"; export default defineConfig({ name: "help-desk", entry: "src/main.ts", deployments: [{ target: "node" }, { target: "vercel" }], transport: { default: "http", http: { port: 4100, path: "/mcp" } }, env: { shared: { LOG_LEVEL: "info" }, dev: { DESK_WEB_URL: "http://localhost:5173" }, test: { NODE_ENV: "test" }, }, test: { timeoutMs: 60_000, runInBand: true }, }); ``` `defineConfig()` returns its argument; it's only there for type checking. #### Finding the file 1. The `--config ` option, on the commands that read it: `dev`, `test`, `inspector`, `eject-mcp-config` and `build`, for every target. (Before 1.8.7 `build` ignored it, and `FRONTMCP_CONFIG` too.) 2. The `FRONTMCP_CONFIG` environment variable. 3. The nearest folder, from the current one up at most 10 levels, with a `frontmcp.config.ts`, `.js`, `.json`, `.mjs` or `.cjs`, tried in that order. 4. `frontmcp build` without any file uses `package.json`: its `name` without the scope, `version`, `main` as the entry, and one `node` deployment. A `.json` file may have a `"$schema"` field, but the 1.9.3 `frontmcp` package includes no JSON Schema file to point it at. #### Fields Every level is strict: a field the CLI doesn't know fails with [`Invalid frontmcp.config`](https://frontmcp.dev/reference/server/config-files#invalid-frontmcpconfig). The last column says which command reads each field, read from the CLI's published code; the `node` build's `server`, `env` and `env.ship` were also run. | Field | Type | Read by | | --- | --- | --- | | `name` | `string`, letters, digits, `.`, `_` and `-` | **Required.** `build`, and `eject-mcp-config` as the server's key. | | `version` | `string` | `build`. | | `entry` | `string` | `dev` and `build`, unless `--entry` is given. | | `nodeVersion` | `string` | Node.js requirement for `build`'s `node`, `cli` and `mcpb` targets. Defaults to the CLI's `engines.node`, `>=24.0.0`. | | `deployments` | `DeploymentTarget[]`, at least one | `build`: without `--target`, it builds every one. Each has a `target` (`node`, `distributed`, `cli`, `vercel`, `lambda`, `cloudflare`, `browser`, `sdk` or `mcpb`), an optional `outDir`, `env`, and fields per target: `server` (`http`, `csp`, `headers`, `cookies`), `ha` for `distributed`, `wrangler` for `cloudflare`, `cli`, `js` and `sea` for `cli`, the bundle's metadata for `mcpb` ([below](https://frontmcp.dev/reference/server/config-files#the-cli-and-mcpb-deployments)). `server` and `env` go to the server: see [the caveats](https://frontmcp.dev/reference/server/config-files#caveats-1). | | `build` | `{ esbuild?, dependencies?, storage?, network? }` | `build`: shared options for `cli`, `node` and `mcpb`, winning key by key over deployment options. Self-contained bundles ignore `external`. Changed in 1.9.5: before, `cli` and `node` ignored `esbuild` and `dependencies`. | | `setup` | `{ steps }` | Installation questions; also mapped to MCPB user settings and environment variables. Changed in 1.9.5: accepted alongside `deployments`. | | `transport` | `{ default?, http?: { port?, path?, host? }, stdio?: { command?, args? } }` | `dev` (port and path), `build` (`http.path`, for the `node`, `vercel`, `lambda`, `distributed` and `cloudflare` targets; an `entryPath` in the decorator still wins), `inspector` (what to connect to) and `eject-mcp-config` (the URL). | | `env` | `{ shared?, dev?, test?, ship? }`, each a map of strings | `dev` and `inspector` add `shared` and `dev`, `test` adds `shared` and `test`, and `start`, `socket` and the `env` of `eject-mcp-config`'s stdio snippets add `shared` and `ship`. See below. | | `clients` | per client: `claude-code`, `claude-desktop`, `cursor`, `windsurf`, `vscode` | `frontmcp eject-mcp-config `, which prints a snippet for the client's settings. | | `test` | `{ timeoutMs?, runInBand?, coverage?, testMatch?, esmPackages? }` | `frontmcp test`, unless the matching option is given. `esmPackages` adds other ES-only packages to the ones Jest always transforms, `jose`, `@noble/hashes` and `@noble/ciphers`, instead of skipping them: [when a test file needs it](https://frontmcp.dev/reference/testing/interceptors#must-use-import-to-load-es-module-node_modulespackage). | | `cli` | `{ commands }` | Your own `frontmcp ` commands: each has an `entry` script, and optional `description`, `arguments`, `options` and `hidden`. Read from the current folder only. | | `skills` | `{ provider?, bundle?, install?, exportTarget? }` | [`frontmcp skills install`](https://frontmcp.dev/reference/cli#skills) with no skill named installs `install`, else `bundle` (`"none"` installs nothing); `provider` and `exportTarget` are the defaults for `skills install` and `skills export`, where an option on the command line wins. Since 1.9.0. | | `registry` | `{ url?, packages?, registryUrl? }` | Publishing defaults for [`frontmcp registry`](https://frontmcp.dev/reference/server/config-files#registry). The name and description come from the entry's `@FrontMcp({ registry })`. | #### `registry` New in 1.10.0. This block describes where the server runs and where to publish it; it does not turn on the server's [registry documents](https://frontmcp.dev/reference/deployment/mcp-registry#frontmcp-registry-). | Field | Type | Default | Description | | --- | --- | --- | --- | | `url` | `string` | none | Public Streamable HTTP endpoint listed in `server.json`'s `remotes[0]`. `publish --url` wins. | | `packages` | `"npm"[]` | none | `["npm"]` lists `package.json`'s name and version as an npm package using stdio. The published package needs `mcpName` matching the registry name. | | `registryUrl` | `string` | `"https://registry.modelcontextprotocol.io"` | Registry base URL. `--registry` wins. The command requires HTTPS except HTTP on `localhost`, `127.0.0.1` or `[::1]`. | ```ts frontmcp.config.ts import { defineConfig } from "frontmcp"; export default defineConfig({ name: "help-desk", registry: { url: "https://example.com/mcp", packages: ["npm"] }, }); ``` Changed in 1.10.0: before, `registry` could be used in `cli.commands`. It is now a reserved project-command verb. Rename a project command with that name. [Publishing a listing](https://frontmcp.dev/reference/deployment/mcp-registry#publishing-a-listing) explains validation and authentication; a dry run still calls the remote Registry, so it can't run in the Playground. #### The `cli` and `mcpb` deployments A deployment's fields beyond `target`, `outDir` and `env` depend on its target. Those of `cli` and `mcpb` shape the program and the archive; `frontmcp build` reads them, and nothing reads some of them: | Target | Field | Read by the build | | --- | --- | --- | | `cli` | `cli.description`, `cli.outputDefault`, `cli.authRequired` | Yes: the program's `--help` line, the default of `--output`, and the sign-in commands. See [the `cli` options](https://frontmcp.dev/reference/cli#the-cli-options-and-signing-in). | | `cli` | `cli.excludeTools`, `cli.oauth { serverUrl, clientId, defaultScope, portRange }` | Yes: omit tool commands and supply login defaults. Changed in 1.9.5: before, they were ignored. | | `cli` | `js`, `sea` | No. They're accepted and change nothing; `--js` on the command line picks the JavaScript bundle. | | `mcpb` | `displayName`, `longDescription`, `author`, `license`, `homepage`, `repository`, `documentation`, `support`, `icon`, `keywords`, `privacyPolicies`, `compatibility` | Yes: the archive's `manifest.json`, with `package.json` as the fallback. See [Bundle metadata and user settings](https://frontmcp.dev/reference/cli#bundle-metadata-and-user-settings). | | `mcpb` | `userConfig` | Into `user_config` and `mcp_config.env`. Optional `env` sets the variable name; otherwise UPPER_SNAKE case is used. User answers win over deployment `env`; `FRONTMCP_STDIO` is protected. | | `mcpb` | `sea { enabled, mergeFrom }`, `deterministic` | Yes, as `--sea`, `--merge-from` and `--no-deterministic`. | | `mcpb` | `includeNodeModules` | Deprecated and unused; `true` warns. Use `build.dependencies.nativeAddons` for native packages. | Changed in 1.9.5: top-level `setup: { steps }` is accepted alongside `deployments`; `cli.excludeTools` and `cli.oauth` apply to CLI deployments. The CLI package ships `frontmcp.schema.json`, so editors can use `./node_modules/frontmcp/frontmcp.schema.json` or the hosted schema at `https://frontmcp.dev/schemas/frontmcp.config.json`. #### `env` and the port in `frontmcp dev` `frontmcp dev` builds the server's environment in this order, each over the one before: 1. `env.shared`, then `env.dev`. 2. The real environment, with `.env` and `.env.local` added for variables it doesn't already have. So your shell and your `.env` files win over `frontmcp.config`. 3. `PORT`: `--port`, else `transport.http.port`, else the `PORT` already set, else `3000`. A `transport.http.port` in the file wins over `PORT` in your shell. With `transport.http.path`, `FRONTMCP_HTTP_ENTRY_PATH` too. `frontmcp test` does the same with `env.test`, without the port. `frontmcp inspector` differs: `env.shared` and `env.dev` win over your shell. #### Caveats - **What a build carries.** Since 1.9.0, each bundle `frontmcp build` makes ([the `node` target](https://frontmcp.dev/reference/cli#the-node-target) and the others) sets its deployment's settings as environment variables when it starts, only where the environment doesn't have them already, so a variable set where it runs wins, and an option in `@FrontMcp` wins over both: `server.http.port` as `PORT` and `server.http.socketPath` as `FRONTMCP_DAEMON_SOCKET` (for the `node` and `distributed` targets, which listen themselves), `server.http.entryPath` as `FRONTMCP_HTTP_ENTRY_PATH` (it wins over `transport.http.path`), `server.http.cors` as the `FRONTMCP_CORS_*` variables, `server.csp` and `server.headers` as the [response-header variables](https://frontmcp.dev/reference/server/config-files#response-headers), `server.cookies` as the `FRONTMCP_AFFINITY_COOKIE*` variables, `ha` as `FRONTMCP_HA_*`, and `env` as itself. A `node` bundle with `server: { http: { port: 4776, entryPath: "/rpc" } }` and `env: { DESK_NAME: "north" }`, started with neither variable set, serves MCP at `http://localhost:4776/rpc`, and its tools read `DESK_NAME`. Before 1.9.0 only `server.csp` and `server.headers` were read, and not by the `node` build. - `frontmcp dev` passes `server.csp` and `server.headers` to the server too. - `frontmcp build`, like `dev`, runs from the folder of a `frontmcp.config` it finds in a parent folder, so a relative `entry` works from a subfolder. (Before 1.9.0 it stopped with `Entry override not found: ./src/main.ts`.) - A bundle sets [`runtimeContext.target`](https://frontmcp.dev/reference/server/environment#target-is-unknown) to its target, and `FRONTMCP_DEPLOYMENT_MODE` for the serverless and distributed targets. (Before 1.9.0, `target` stayed `"unknown"`.) - The `frontmcp` [process-manager commands](https://frontmcp.dev/reference/cli#process-manager) `start` and `socket`, and so the services `service` installs, read `env.shared` and `env.ship` from the `frontmcp.config` that governs the entry they start, and pass them to the server under the real environment. They read nothing else in it. - A `.ts` config in a CommonJS project is compiled with esbuild, so it may import other local files; packages stay imports, and must be installed. --- ## Usage ### Reading your own settings Give `ConfigPlugin` a schema with a default for each setting, and read them with `this.config`. Here a tool builds a link to a ticket from the help desk's web address: Initial call configuration: ```js { tool: "ticket_link", arguments: { id: "T-1" } } ``` ```ts main.ts active import { App, ConfigPlugin, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; const settings = z.object({ desk: z .object({ webUrl: z.string().url().default("https://desk.example.com"), pageSize: z.coerce.number().default(20), }) .prefault({}), }); @Tool({ name: "ticket_link", description: "Link to a ticket in the help desk's web app", inputSchema: { id: z.string() } }) class TicketLink extends ToolContext { async execute({ id }: { id: string }) { return { url: `${this.config.get("desk.webUrl")}/tickets/${id}`, pageSize: this.config.getNumber("desk.pageSize") }; } } @App({ id: "help-desk", name: "Help Desk", tools: [TicketLink] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [ConfigPlugin.init({ schema: settings })], }) export default class Server {} ``` ```ts settings.test.ts import { test, expect } from "@frontmcp/testing"; test("the schema's defaults apply when nothing is set", async ({ mcp }) => { const result = await mcp.tools.call("ticket_link", { id: "T-1" }); expect(result.json()).toEqual({ url: "https://desk.example.com/tickets/T-1", pageSize: 20 }); }); ``` ### Overriding a setting from the environment Each path in the schema can be set by an environment variable: `desk.webUrl` by `DESK_WEBURL`, `desk.pageSize` by `DESK_PAGESIZE`. The variables are read when a server starts, so the first test sets them and then starts a server of its own with [`createDirect()`](https://frontmcp.dev/reference/sdk/create). The Playground's server started before any test ran, so the Call tab shows the defaults, and the second test shows it keeps them: Initial call configuration: ```js { tool: "ticket_link", arguments: { id: "T-1" } } ``` ```ts main.ts active import { App, ConfigPlugin, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; const settings = z.object({ desk: z .object({ webUrl: z.string().url().default("https://desk.example.com"), pageSize: z.coerce.number().default(20), }) .prefault({}), }); @Tool({ name: "ticket_link", description: "Link to a ticket in the help desk's web app", inputSchema: { id: z.string() } }) class TicketLink extends ToolContext { async execute({ id }: { id: string }) { return { url: `${this.config.get("desk.webUrl")}/tickets/${id}`, pageSize: this.config.getNumber("desk.pageSize") }; } } @App({ id: "help-desk", name: "Help Desk", tools: [TicketLink] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [ConfigPlugin.init({ schema: settings })], }; @FrontMcp(config) export default class Server {} ``` ```ts env.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; test("environment variables win over the defaults", async () => { process.env.DESK_WEBURL = "https://staging.desk.example.com"; process.env.DESK_PAGESIZE = "50"; try { const server = await FrontMcpInstance.createDirect(config); // reads them now const result = await server.callTool("ticket_link", { id: "T-1" }); await server.dispose(); expect(result.structuredContent).toEqual({ url: "https://staging.desk.example.com/tickets/T-1", pageSize: 50 }); } finally { delete process.env.DESK_WEBURL; delete process.env.DESK_PAGESIZE; } }); test("a server that's already running keeps what it read at startup", async ({ mcp }) => { process.env.DESK_PAGESIZE = "50"; try { expect((await mcp.tools.call("ticket_link", { id: "T-1" })).json().pageSize).toBe(20); } finally { delete process.env.DESK_PAGESIZE; } }); ``` `DESK_PAGESIZE` arrives as the string `"50"`; `z.coerce.number()` makes it a number. In a deployment, set the variables in the environment, or in a `.env` file next to where the server starts. ### Keeping defaults in a YAML file For settings that are long, nested or shared by a team, keep them in a YAML file in version control, and override single values with environment variables where the server runs: ```yaml config.yml desk: webUrl: https://desk.example.com pageSize: 50 ``` ```ts main.ts import { ConfigPlugin, FrontMcp, z } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; const settings = z.object({ desk: z.object({ webUrl: z.string().url(), pageSize: z.coerce.number().default(20) }), }); @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [ConfigPlugin.init({ schema: settings, loadYaml: true, basePath: process.cwd() })], }) export default class Server {} ``` Started with `DESK_WEBURL=https://staging.desk.example.com`, the server has `desk.webUrl` from the environment and `desk.pageSize: 50` from the file. The Playground has no files, so this one was checked in Node. ### Reading plain environment variables Without a schema, `this.config` reads any environment variable by name, with a default for when it's not set. Use it for a few values that don't need checking. They're read when the server starts too, so the test sets them before it starts its own: Initial call configuration: ```js { tool: "support_contact", arguments: {} } ``` ```ts main.ts active import { App, ConfigPlugin, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk"; @Tool({ name: "support_contact", description: "How customers can reach support", inputSchema: {} }) class SupportContact extends ToolContext { async execute() { return { email: this.config.get("SUPPORT_EMAIL", "help@desk.example.com"), phoneSupport: this.config.getBoolean("SUPPORT_PHONE_ENABLED"), }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SupportContact] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [ConfigPlugin.init({})] }; @FrontMcp(config) export default class Server {} ``` ```ts flat.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { config } from "./main"; test("reads variables by name", async () => { process.env.SUPPORT_EMAIL = "support@desk.example.com"; process.env.SUPPORT_PHONE_ENABLED = "yes"; try { const server = await FrontMcpInstance.createDirect(config); const result = await server.callTool("support_contact", {}); await server.dispose(); expect(result.structuredContent).toEqual({ email: "support@desk.example.com", phoneSupport: true }); } finally { delete process.env.SUPPORT_EMAIL; delete process.env.SUPPORT_PHONE_ENABLED; } }); ``` With nothing set, the Call tab shows the default email and `phoneSupport: false`. ### Reading settings in a provider Code that runs outside a call, like a [provider's](https://frontmcp.dev/reference/sdk/provider) factory, has no `this.config`. `loadConfig()` reads the same sources and returns the checked settings, and a factory can share them as a typed provider. The factory runs when the server starts: Initial call configuration: ```js { tool: "search_tickets", arguments: { query: "log" } } ``` ```ts help-desk.app.ts active import { App, Tool, ToolContext, loadConfig, z } from "@frontmcp/sdk"; const settings = z.object({ desk: z.object({ pageSize: z.coerce.number().default(20) }).prefault({}), }); export abstract class DeskSettings { abstract desk: { pageSize: number }; } @Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } }) export class SearchTickets extends ToolContext { async execute({ query }: { query: string }) { return { query, limit: this.get(DeskSettings).desk.pageSize, tickets: [] }; } } @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets], providers: [{ provide: DeskSettings, name: "DeskSettings", inject: () => [], useFactory: () => loadConfig(settings) }], }) export class HelpDeskApp {} ``` ```ts provider.test.ts import { test, expect } from "@frontmcp/testing"; import { FrontMcpInstance } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; test("the provider holds the checked settings", async () => { process.env.DESK_PAGESIZE = "5"; try { const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] }); const result = await server.callTool("search_tickets", { query: "log" }); await server.dispose(); expect(result.structuredContent).toEqual({ query: "log", limit: 5, tickets: [] }); } finally { delete process.env.DESK_PAGESIZE; } }); ``` ### Setting up the CLI for a project A `frontmcp.config.ts` at the project's root lets `frontmcp dev`, `test` and `build` run without options, and keeps development-only variables out of the code: ```ts frontmcp.config.ts import { defineConfig } from "frontmcp"; export default defineConfig({ name: "help-desk", entry: "src/main.ts", deployments: [{ target: "node" }], transport: { default: "http", http: { port: 4100, path: "/mcp" } }, env: { dev: { DESK_WEBURL: "http://localhost:5173" }, }, clients: { "claude-code": { transport: "http", url: "http://127.0.0.1:4100/mcp" }, }, }); ``` `frontmcp dev` now starts `src/main.ts` with `PORT=4100`, `FRONTMCP_HTTP_ENTRY_PATH=/mcp` and `DESK_WEBURL` set, unless your shell or `.env` sets `DESK_WEBURL` already. Keep `http.port` and `http.entryPath` out of `@FrontMcp`, or they win over what the CLI passes. The CLI isn't part of the Playground; the loading, the file order and the error messages on this page were checked with the CLI's config loader in Node. --- ## Troubleshooting ### `Configuration validation failed: … expected number, received string` The server fails to start with `ConfigValidationError`, and the message lists each setting that didn't fit the schema, like `- desk.pageSize: Invalid input: expected number, received string`. Environment variables are strings, and the schema says `z.number()`: Initial call configuration: ```js { tool: "page_size", arguments: {} } ``` ```ts main.ts active import { App, ConfigPlugin, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; // 🚩 An environment variable can't be a number export const settings = z.object({ desk: z.object({ pageSize: z.number().default(20) }).prefault({}) }); @Tool({ name: "page_size", description: "How many tickets a search returns", inputSchema: {} }) class PageSize extends ToolContext { async execute() { return { pageSize: this.config.get("desk.pageSize") }; } } @App({ id: "help-desk", name: "Help Desk", tools: [PageSize] }) class HelpDeskApp {} export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [ConfigPlugin.init({ schema: settings })] }; @FrontMcp(config) export default class Server {} ``` ```ts number.test.ts import { test, expect } from "@frontmcp/testing"; import { ConfigPlugin, FrontMcpInstance } from "@frontmcp/sdk"; import { config, settings } from "./main"; test("DESK_PAGESIZE=50 stops the server", async () => { process.env.DESK_PAGESIZE = "50"; try { await expect(FrontMcpInstance.createDirect(config)).rejects.toThrow( "desk.pageSize: Invalid input: expected number, received string", ); } finally { delete process.env.DESK_PAGESIZE; } }); test("with strict: false, the server starts and serves the string as read", async () => { process.env.DESK_PAGESIZE = "50"; try { const server = await FrontMcpInstance.createDirect({ ...config, plugins: [ConfigPlugin.init({ schema: settings, strict: false })] }); const result = await server.callTool("page_size", {}); await server.dispose(); expect(result.structuredContent).toEqual({ pageSize: "50" }); } finally { delete process.env.DESK_PAGESIZE; } }); ``` With nothing set, the default `20` is a number and the server starts, so this shows up only where the variable is set. Use `z.coerce.number()`, and `z.stringbool()` for booleans. A setting with no default and no value fails the same way, as `expected string, received undefined`. `ConfigPlugin.init({ schema, strict: false })` lets the server start anyway, as the second test shows: it logs `[config] Configuration validation failed: …; strict is false, so the settings are used as read`, and `this.config` has the settings as they were read, the string `"50"` here, and none of the schema's defaults. Code that relies on the schema's types or defaults gets what the schema would have refused, so fix the schema rather than turn `strict` off. ### `Required configuration "…" is not defined` `getOrThrow()` found no value, and threw `ConfigMissingError`. In a tool it's a `TOOL_EXECUTION_ERROR`, and in production the client sees only `Internal FrontMCP error`: Initial call configuration: ```js { tool: "ticket_link", arguments: { id: "T-1" } } ``` ```ts main.ts active import { App, ConfigPlugin, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk"; @Tool({ name: "ticket_link", description: "Link to a ticket in the help desk's web app", inputSchema: { id: z.string() } }) class TicketLink extends ToolContext { async execute({ id }: { id: string }) { return { url: `${this.config.getOrThrow("DESK_WEB_URL")}/tickets/${id}` }; // 🚩 not set } } @App({ id: "help-desk", name: "Help Desk", tools: [TicketLink] }) class HelpDeskApp {} @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [ConfigPlugin.init({})] }) export default class Server {} ``` Set the variable, or give the setting a default. A required setting is better caught at startup than in a call: put it in a schema without a default, and the server won't start without it. ### `Provider "ConfigService" is not available` The server has no `ConfigPlugin`. Add it with `plugins: [ConfigPlugin.init({ ... })]`, or `plugins: [ConfigPlugin]` for the defaults. Before 1.9.4 the bare class registered nothing, so a server that lists it gets this error until it's updated. [Context classes](https://frontmcp.dev/reference/sdk/contexts#provider-configservice-is-not-available) shows the error. ### A value in `.env` is ignored - **The file isn't where FrontMCP looks.** `.env` is found relative to `basePath`, which defaults to the folder the process started in. Start the server from the project's root, or set `basePath`. - **The line isn't `NAME=value`.** `export NAME=value` and `NAME = value` are skipped. - **With a schema, the name doesn't match a path.** `desk.pageSize` is read from `DESK_PAGESIZE` only. `DESK_PAGE_SIZE` is ignored. - **The real environment has the same variable.** With a schema, it wins over `.env`. (Without one, `.env` wins.) - **It's one of FrontMCP's own variables.** See the next entry. ### `PORT` in `.env` is ignored FrontMCP reads `PORT` when it reads the `@FrontMcp` options, as the decorated class is imported. `ConfigPlugin` copies `.env` into `process.env` later, when the server starts, so the server listens on `3000` though `process.env.PORT` is set afterwards. Variables FrontMCP reads later, like `FRONTMCP_BIND_ADDRESS`, do take the file's value. Load the file before the server starts instead: `frontmcp dev` does, and so does `node --env-file=.env dist/main.js`. ### `PORT` has no effect Either `@FrontMcp({ http: { port } })` sets a port, which wins, or `frontmcp.config`'s `transport.http.port` does, which `frontmcp dev` puts before `PORT`. Remove the one you don't want, or pass `frontmcp dev --port`. ### `Invalid frontmcp.config` The CLI lists every field that didn't fit: ```text Invalid frontmcp.config: - name: Must be alphanumeric with .-_ only - deployments.0.target: Invalid discriminator value. Expected 'node' | 'distributed' | 'cli' | 'vercel' | 'lambda' | 'cloudflare' | 'browser' | 'sdk' | 'mcpb' - : Unrecognized key: "extra" ``` An empty path, as in the last line, means the top level. Every object in the file is strict, so a misspelled field is an error rather than ignored. A `--config` path that doesn't exist fails with `Config file not found`. ### A setting in `frontmcp.config` does nothing - **The server runs from source.** Only `frontmcp dev` (for `transport`, `env.shared`, `env.dev`, `server.csp` and `server.headers`) and the bundles of `frontmcp build` (for a deployment's `server`, `ha` and `env`) pass settings on; `tsx src/main.ts` or your own runner gets none of them. - **The environment already has the variable.** A bundle sets its defaults only where nothing set them first. - **`@FrontMcp` sets the option.** An explicit `http.port`, `http.entryPath` or `http.cors` wins over what the CLI passes. Changed in 1.9.0: before, the CLI accepted `deployments[].server.http` and `server.cookies`, `deployments[].ha`, `deployments[].env`, `env.ship` and `skills`, and read none of them; nor `server.csp` and `server.headers` in a `node` build. See [the caveats](https://frontmcp.dev/reference/server/config-files#caveats-1) for which commands read each setting. --- # Logging > The server's own log — levels, where lines go, custom transports, request tags, and the line that matches an error ID. Source: https://frontmcp.dev/reference/server/logging [Checked against FrontMCP 1.10.11](https://frontmcp.dev/versions#how-pages-are-checked) on October 26, 2026 A FrontMCP server keeps a log of its own: what it started, each request it handled, and each call that failed, with the error ID the client was given. `this.logger` in your tools adds to the same log. `@FrontMcp({ logging })` sets how much is written and where it goes: to the console by default, and to any destination you add with a `@LogTransport` class. Nothing in this log reaches the client; messages meant for the client go through [`this.notify()`](https://frontmcp.dev/reference/sdk/notify). ```ts @FrontMcp({ info, apps, logging: { level, enableConsole, prefix, transports } }) @LogTransport({ name }) class MyTransport extends LogTransportInterface { log(record: LogRecord) { /* ... */ } } ``` --- ## Reference ### `@FrontMcp({ logging })` ```ts main.ts import "reflect-metadata"; import { FrontMcp, LogLevel } from "@frontmcp/sdk"; import { HelpDeskApp } from "./help-desk.app"; import { JsonLinesTransport } from "./json-lines.transport"; @FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], logging: { level: process.env.NODE_ENV === "production" ? LogLevel.Warn : LogLevel.Info, enableConsole: false, transports: [JsonLinesTransport], }, }) export default class Server {} ``` [See more examples below.](https://frontmcp.dev/reference/server/logging#usage) #### Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `level` | `LogLevel` | `FRONTMCP_LOG_LEVEL`, else `LogLevel.Warn` in a browser, `LogLevel.Info` in Node | The lowest level that's written. It applies to every destination, and it's fixed when the server starts. Without it, the `FRONTMCP_LOG_LEVEL` environment variable names the level: `debug`, `verbose`, `info`, `warn`, `error` or `off`. Changed in 1.9.5: browsers and `create()` without `logging` default to `warn`; per-request details use `verbose`. (`FRONTMCP_LOG_LEVEL` is new in 1.9.3.) | | `enableConsole` | `boolean` | `true` | Write to the console. Set `false` when your own transport replaces it. | | `prefix` | `string` | none | A name put in front of every logger's name, with a `:`: `help-desk-eu:tool:close_ticket`. The root logger's lines carry it alone. See [tagging every line](https://frontmcp.dev/reference/server/logging#tagging-every-line-with-loggingprefix). | | `transports` | `@LogTransport` classes | `[]` | More destinations. Each one receives every line that passes `level`. | #### `LogLevel` | Level | Value | Method | What FrontMCP writes at it | | --- | --- | --- | --- | | `LogLevel.Debug` | `0` | `debug()` | Request headers, [with credentials redacted](https://frontmcp.dev/reference/server/logging#seeing-request-headers), and transport decisions. Everything is written. | | `LogLevel.Verbose` | `1` | `verbose()` | Every stage of every flow, registry setup. **Hides `debug()` lines**, because `Verbose` is the higher number. | | `LogLevel.Info` | `2` | `info()` | Startup and HTTP access logs. The default for Node servers. | | `LogLevel.Warn` | `3` | `warn()` | Public errors (`PublicMcpError`), deployment warnings. | | `LogLevel.Error` | `4` | `error()` | Failures that aren't public, with the original error. | | `LogLevel.Off` | `100` | none | Nothing. | `level` takes the enum, not a string: `level: "info"` stops the server with [`Invalid option: expected one of 0|1|2|3|4|100`](https://frontmcp.dev/reference/sdk/frontmcp#invalid-option-expected-one-of-01234100). ### `@LogTransport(metadata)` A transport is a class with the `@LogTransport` decorator that extends `LogTransportInterface` and implements `log(record)`. List it in `logging.transports`. ```ts json-lines.transport.ts import { LogRecord, LogTransport, LogTransportInterface } from "@frontmcp/sdk"; @LogTransport({ name: "JsonLines", description: "One JSON object per line on stdout" }) export class JsonLinesTransport extends LogTransportInterface { log(record: LogRecord) { process.stdout.write( JSON.stringify({ time: record.timestamp, level: record.levelName, logger: record.prefix, message: record.message }) + "\n", ); } } ``` `LogTransport` is also exported as `FrontMcpLogTransport`. #### Metadata | Field | Type | Description | | --- | --- | --- | | `name` | `string` | **Required.** The transport's name. | | `id` | `string` | Optional identifier. | | `description` | `string` | What the transport does. | #### `log(record)` FrontMCP calls `log()` once per line that passes `level`, in the order the transports are listed, then the console. It doesn't wait for a returned promise. | `record` field | Type | Description | | --- | --- | --- | | `level` | `LogLevel` | The line's level, as a number. | | `levelName` | `string` | The same, in lowercase: `"debug"`, `"verbose"`, `"info"`, `"warn"` or `"error"`. | | `message` | `string` | The first argument the logger was called with, converted with `String()`. An object as the first argument becomes `"[object Object]"`: put objects after the message. | | `args` | `unknown[]` | Every other argument, as it was passed: objects, `Error`s, anything. | | `timestamp` | `Date` | When the line was written. | | `prefix` | `string` | The name of the logger that wrote it, like `"tool:close_ticket"`, after `logging.prefix` and a `:` when that's set. `""` for the root logger without `logging.prefix`. | #### Constructor FrontMCP creates one instance of each transport per server, and passes the logging options to its constructor: `{ level, enableConsole, prefix? }`. Two handlers or two servers in one process get separate instances. #### Caveats - **Don't throw from `log()`.** FrontMCP catches the error, prints `[Logger] Transport error: ` with `console.error`, and goes on to the next transport, for every line. See [the troubleshooting entry](https://frontmcp.dev/reference/server/logging#logger-transport-error-). - **Keep `log()` synchronous**, or catch inside it. FrontMCP doesn't wait for a promise, so an `async log()` that rejects is an unhandled rejection, which ends a Node process by default. - Keep `log()` fast: it runs inside the request that logged. Buffer lines and send them in batches if the destination is over the network. - Over stdio, stdout carries the protocol. [A transport must not write to it](https://frontmcp.dev/reference/server/logging#a-stdio-client-cant-parse-the-servers-output). ### The loggers Every context class has a logger, and they all write to this log: | Logger | Prefix of its lines | Where | | --- | --- | --- | | `this.logger` | `tool:`, `resource:`, `prompt:` | Every [context class](https://frontmcp.dev/reference/sdk/contexts#thislogger-and-thiscontextlogger). | | `this.contextLogger` | `[:]`, the first eight characters of each | Every context class except `PromptContext`. The console shows it as `[[1f0c2a9e:5d1b4c7e]]`. | | `logger.child(name)` | `name` | A logger with its own name. It **replaces** the logger's name: `this.logger.child("mailer")` writes `mailer`, not `tool:close_ticket.mailer`. | With [`logging.prefix`](https://frontmcp.dev/reference/server/logging#tagging-every-line-with-loggingprefix), every one of these names gets the prefix and a `:` in front: `help-desk-eu:tool:close_ticket`, `help-desk-eu:mailer`. Each has `debug()`, `verbose()`, `info()`, `warn()` and `error()`, which take a message and any number of other values (they become `record.args`), and `child()`. ### What FrontMCP writes At `LogLevel.Info`, a server writes about eight lines while it starts, like `Initializing FrontMCP "help-desk v1.0.0"...`, `Help Desk: 3 tools` and `Scope ready — 1 app, 3 tools`, and three for each tool call: | Logger | Line | | --- | --- | | `HttpRequestFlow` | `[] ▶ POST /`, with the request's details as an object | | `HttpRequestFlow` | `[] ◀ POST / completed in 3ms` | | `call-tool-request-handler` | `tools/call: close_ticket` | The stages in between, like `findTool: tool "close_ticket" found` and `finalize: sending response`, write at `LogLevel.Verbose`. Changed in 1.9.5: before, `Info` wrote those lines too, six for each tool call. A call that fails adds one line from `call-tool-request-handler`, with an object after the message that holds `toolName`, `errorId` and `code`, and, in development, `stack`. The `errorId` is the one the client got in `_meta.errorId`, so a user's report can be found in the log. [Finding the line for an error ID](https://frontmcp.dev/reference/server/logging#finding-the-line-for-an-error-id) shows it. | The tool failed with | Level | Message | | --- | --- | --- | | A `PublicMcpError` | `warn` | `Public error: ` | | Anything else, like a plain `Error` | `error` | `Internal error: Tool "" execution failed: `, then `Original error:` and the original stack | Distributed-storage warnings are suppressed in a browser and print at most once per process elsewhere. Changed in 1.9.5: before, each scope could warn. ### Where the lines go | The server runs with | The console transport writes to | Also | | --- | --- | --- | | FrontMCP's HTTP server (`@FrontMcp`, [`bootstrap()`](https://frontmcp.dev/reference/sdk/frontmcp-instance)) | stdout for `debug`, `verbose` and `info` (`console.debug` and `console.info`), stderr for `warn` and `error` | A few lines skip the logger and go straight to the console, even with `enableConsole: false` or `LogLevel.Off`: `MCP HTTP (Express) on 127.0.0.1:3000` when the server starts listening. Session start and transport teardown messages use `verbose`. | | stdio (`FRONTMCP_STDIO=1`, [`runStdio()`](https://frontmcp.dev/reference/sdk/frontmcp-instance)) | stderr, for every level. stdout carries only MCP messages, and even `console.log` is sent to stderr. | A log file per start, `~/.frontmcp/logs/frontmcp-