# 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

In this tutorial you'll build a small MCP server for a help desk. 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 {}
```

```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" };
  }
}
```

```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) }] };
  }
}
```

```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<string, string>): Promise<GetPromptResult> {
    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 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.

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

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

```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;
  }
}
```

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

```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()`:

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

```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) }] };
  }
}
```

```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" };
  }
}
```

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

```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<string, string>): Promise<GetPromptResult> {
    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.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) }] };
  }
}
```

```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" };
  }
}
```

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

```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 {}
```

```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" };
  }
}
```

```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) }] };
  }
}
```

```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<string, string>): Promise<GetPromptResult> {
    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 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<string, string>): Promise<GetPromptResult> {
    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.
