# 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