Tutorial: Help Desk Server
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.
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.
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The server offers:
- four tools the model can call:
search_tickets,get_ticket,add_replyandclose_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 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.
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) };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The page searched for log and found two tickets. Here's what the two files do:
ticket-store.tsdeclares 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.tsdeclaressearch_tickets. Itsnameis what the model calls, itsdescriptionsays what comes back, and itsinputSchema, 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 asstructuredContent. 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 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:
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);
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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;
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
this.fail()ends the call and sends the error as a result withisError: true.PublicMcpErrormarks 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. ticketIdandnotFound()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 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:
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);
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
- 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 withT-2. - Now call
get_ticketwithT-1. The reply is there, because both tools use the sameTicketStore.
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():
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" };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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 covers elicitation in depth.
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:
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) }] };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
uriTemplate: "tickets://{id}"matches anytickets://URI, and FrontMCP passes theidpart toexecute().execute()returnscontents: the URI that was read, its MIME type, and the text. The text is the same JSON thatget_ticketreturns.- Reading a resource has no
isErrorresult the way a tool call does. For a ticket that doesn't exist,execute()throwsResourceNotFoundError, and the client gets a JSON-RPC error instead of a result. In the Call tab, pickresources/read · tickets://{id}and readT-9to 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 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:
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.`,
},
},
],
};
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
argumentsare the blanks the user fills in.idis required.toneis optional, andexecute()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 readingtickets://T-3would. 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 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.tsdeclares an@App: the provider, tools, resource and prompt that belong together.main.tsdeclares the@FrontMcpserver: 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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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");
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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()andtoBeError()check whether a tool call worked, andresult.json()reads the result's data.result.raw._meta?.codeis where theTICKET_NOT_FOUNDcode 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()andmcp.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 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, Schemas Are Contracts and Shaping Tool Results
- Resources: Exposing Data with Resources
- Prompts: Reusable Prompts
- Apps, servers and providers: Grouping Capabilities into Apps
- Asking the user: Asking the User
- Tests: Testing Your Server
- Deciding what to build in the first place: Thinking in MCP
If you'd like more practice, here are some ideas to try in the last Playground, roughly from easiest to hardest.
- Let the model narrow a search to open or closed tickets, with an optional
statusargument onsearch_tickets. Look at how the new argument shows up in the Capabilities tab. - A user asking about "Ada's login problem" gets nothing if the model searches for
Ada. Makesearch_ticketsmatch the customer's name as well as the title, and update its description to say so. - Add a
reopen_tickettool. Decide which annotations it needs, and whether reopening needs the user to confirm. Then add a test for it. - Let
close_ticketask 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.