Thinking in MCP
When you connect a product to AI, the obvious first move is to take your API and turn each endpoint into a tool. That works, but models often use the result badly, because they don't think in endpoints. They think in the requests people make. This page designs an MCP server the other way round, starting from what people will ask the assistant. The example is a help desk, and you'll end with a working server.
You will learn
- How to start a design from the requests people will make
- How to tell whether a job is a tool, a resource or a prompt
- How to shape names, inputs and results for a model
- How to decide which actions need the user's confirmation
- How to group capabilities into apps
Start with what people will ask
Say you make help desk software. It has a web app, a database of tickets, and an API with dozens of endpoints. Support teams want their AI assistant to work with it.
Don't start from the API. Start from the requests. Ask a few support agents what they'd want to say to the assistant, or read your team's chat for things people already ask each other. You might collect these:
- "What's going on with Ada's login problem?"
- "Are there any open tickets about invoices?"
- "Tell Linus we've sent him a new sign-in link."
- "T-1 is fixed. Close it."
- With a ticket attached to the conversation: "Why did this customer get a refund?"
- Picked from the client's prompt menu: "Draft a reply to T-3."
Then look at the data the requests are about. Here's one ticket:
{
"id": "T-1",
"title": "Cannot log in",
"customer": "Ada",
"status": "open",
"message": "I reset my password twice and the login page still says it's wrong.",
"replies": []
}These requests are your mockup. Everything that follows is about making each of them easy for a model to carry out.
Step 1: Break the requests into jobs
For each request, write down what the assistant has to do, and what it needs to know to do it:
| Request | Jobs | What it needs |
|---|---|---|
| "What's going on with Ada's login problem?" | find the ticket, read it | a customer's name, then the whole ticket |
| "Are there any open tickets about invoices?" | find tickets | a word from the title, a status |
| "Tell Linus we've sent him a new sign-in link." | find the ticket, reply | a customer's name, then the id and the reply |
| "T-1 is fixed. Close it." | close a ticket | the id |
| (ticket attached) "Why did this customer get a refund?" | read a ticket | the whole ticket |
| (from the menu) "Draft a reply to T-3." | read a ticket, draft a reply | the whole ticket |
Then merge the duplicates. Six requests come down to five jobs: find tickets, read a ticket, reply, close a ticket and draft a reply. All five work on the same data: tickets.
Two things stand out:
- Most requests start by finding a ticket, and people don't know ids. They say "Ada's login problem" or "Linus". So finding has to work with what people say: words from the title, and the customer's name, with an optional status for request 2.
- The list is short, and it isn't your API. Your product may have endpoints for tags, assignments, exports and reports. If nobody asks the assistant for them, leave them out for now. Every capability you add is one more thing a model has to read and choose between.
Step 2: Decide who starts each job
MCP has three kinds of capability. What separates them is who decides to use each one:
- A tool is chosen by the model. It reads the tool's name and description, and calls it when the conversation needs it.
- A resource is read by the client app, usually because the user attached it to the conversation, the way you attach a file. It's data with a URI.
- A prompt is picked by the user, usually from a slash menu, to start a workflow with arguments they fill in.
So for each job, ask who decides that it should happen:
| Job | Who decides | Kind |
|---|---|---|
| Find tickets | The model, while it answers a question | Tool |
| Read a ticket | The model, when it needs the details. The user, when they attach one. | Tool and resource |
| Reply | The model, when the user asks for it | Tool |
| Close a ticket | The model, when the user asks for it | Tool |
| Draft a reply | The user, who wants the same steps every time | Prompt |
Reading a ticket is both, which is common for the main thing your product holds: the model needs a way to fetch it, and the user needs a way to hand it over. The two can share the code that loads a ticket.
Here's a first sketch with one capability of each kind, all reading the same tickets from a provider. The page read tickets://T-2, the ticket attached in request 5. Open the Capabilities tab to see the sketch the way a client does, and use the Call tab's Request menu to try the tool and the prompt.
import { type GetPromptResult, Prompt, PromptContext, PublicMcpError, ResourceContext, ResourceNotFoundError, ResourceTemplate, Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";
// The model decides it needs a ticket: a tool.
@Tool({
name: "get_ticket",
description: "Get one ticket by id: the customer, the status, their message and every reply so far.",
inputSchema: { id: z.string().describe("Ticket id, like T-1") },
})
export class GetTicket extends ToolContext {
async execute({ id }: { id: string }) {
const ticket = this.get(TicketStore).get(id);
if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
return ticket;
}
}
// The user attaches a ticket: a resource.
@ResourceTemplate({
name: "ticket",
uriTemplate: "tickets://{id}",
mimeType: "application/json",
description: "A support ticket with the customer's message and every reply",
})
export class TicketResource extends ResourceContext<{ id: string }> {
async execute(uri: string, { id }: { id: string }) {
const ticket = this.get(TicketStore).get(id);
if (!ticket) throw new ResourceNotFoundError(uri);
return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] };
}
}
// The user picks a workflow: a prompt.
@Prompt({
name: "draft_reply",
description: "Draft a reply to a ticket, for you to review before it's sent",
arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }],
})
export class DraftReply extends PromptContext {
async execute({ id }: Record<string, string>): Promise<GetPromptResult> {
const ticket = this.get(TicketStore).get(id);
if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`);
return {
messages: [
{
role: "user",
content: { type: "resource", resource: { uri: `tickets://${id}`, mimeType: "application/json", text: JSON.stringify(ticket) } },
},
{
role: "user",
content: { type: "text", text: `Draft a reply to ${ticket.customer} about this ticket. Show me the draft and wait for my approval.` },
},
],
};
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The three capabilities read the same TicketStore, so they can never disagree about a ticket. The store is a provider with ProviderScope.GLOBAL: FrontMCP creates one for the whole server and hands it to anything that asks with this.get(). Every Playground on this page uses this same store file, so from here on it's hidden. It still runs.
Step 3: Design names, inputs and results for the model
With the jobs and their kinds decided, design each tool for its reader: a model that sees only names, descriptions and schemas.
It's tempting to skip this and mirror the API with a single tool that takes an action and every field any action might need. Here's that design. The page asked it to reply to T-3:
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";
type Input = { action: "search" | "get" | "reply" | "close"; id?: string; query?: string; text?: string };
@Tool({
name: "tickets",
description: "Manage tickets.",
inputSchema: {
action: z.enum(["search", "get", "reply", "close"]),
id: z.string().optional(),
query: z.string().optional(),
text: z.string().optional(),
},
})
export class Tickets extends ToolContext {
async execute({ action, id, query, text }: Input) {
const store = this.get(TicketStore);
if (action === "search") return { tickets: store.search(query ?? "") };
if (!id) this.fail(new PublicMcpError(`"${action}" needs an id.`));
if (action === "get") return store.get(id);
if (action === "reply") {
if (!text) this.fail(new PublicMcpError(`"reply" needs text.`));
return store.addReply(id, text);
}
return store.close(id);
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The call failed because it had no text, and the model had no way to know it needed one. Open the Capabilities tab and read the card as a model would:
- Every field but
actionis optional, and nothing says which fields go with which action. The schema can't express "replyneedsidandtext", so the model finds out by failing, as this call just did. - One vague name and description cover four jobs. When a user says "close it", the model has to guess that
ticketsis the tool, then guess the arguments. - It can't be annotated honestly. The same tool reads and closes tickets, so it can't be marked read-only, and a careful client has to treat every search like a change.
Give each job its own tool instead, and design each one for the moment the model will use it:
- Name the tool after its job: a verb and a noun in
snake_case, likesearch_tickets. The name is the first thing a model matches against the user's words. - Ask only for what the model can know at that point: the user's words, or an id from an earlier result.
search_ticketstakes words and matches them against titles and customer names, because step 1 showed that's how people ask.get_tickettakes the id that search returned. Make inputs required unless there's a sensible default, like the optionalstatus, and put every rule in the schema. - Return what the next step needs. Search returns each match's id, title, customer and status: enough to pick one and to pass its id on. The whole ticket comes from
get_ticket. - Make errors say what to do next. "There's no ticket T-9. Use search_tickets to find the right id." gives the model a way forward.
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";
const ticketId = z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1");
function notFound(id: string) {
return new PublicMcpError(`There's no ticket ${id}. Use search_tickets to find the right id.`, "TICKET_NOT_FOUND");
}
@Tool({
name: "search_tickets",
description: "Search support tickets by a word from the title or the customer's name. Returns each match's id, title, customer and status.",
inputSchema: {
query: z.string().min(2).describe('A word from the title, or a customer name, like "invoice" or "Ada"'),
status: z.enum(["open", "closed"]).optional().describe("Only tickets with this status. Leave out for both."),
},
})
export class SearchTickets extends ToolContext {
async execute({ query, status }: { query: string; status?: "open" | "closed" }) {
return { tickets: this.get(TicketStore).search(query, status) };
}
}
@Tool({
name: "get_ticket",
description: "Get one ticket by id: the customer, the status, their message and every reply so far.",
inputSchema: { id: ticketId },
})
export class GetTicket extends ToolContext {
async execute({ id }: { id: string }) {
const ticket = this.get(TicketStore).get(id);
if (!ticket) this.fail(notFound(id));
return ticket;
}
}
@Tool({
name: "add_reply",
description: "Add a reply to an open ticket. The customer reads it, so write it to them. Returns the updated ticket.",
inputSchema: {
id: ticketId,
text: z.string().min(1).max(2000).describe("The reply, as the customer will read it"),
},
})
export class AddReply extends ToolContext {
async execute({ id, text }: { id: string; text: string }) {
const store = this.get(TicketStore);
const ticket = store.get(id);
if (!ticket) this.fail(notFound(id));
if (ticket.status === "closed") {
this.fail(new PublicMcpError(`${id} is closed. Replies can only go on open tickets.`, "TICKET_CLOSED"));
}
return store.addReply(id, text);
}
}
@Tool({
name: "close_ticket",
description: "Close a ticket whose problem is solved. The customer is told it's resolved.",
inputSchema: { id: ticketId },
})
export class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
const store = this.get(TicketStore);
if (!store.get(id)) this.fail(notFound(id));
store.close(id);
return { id, status: "closed" };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The page searched for Ada, the way request 1 would, and found T-1. Now walk the other requests through the Call tab. Request 2 is search_tickets with invoice and status open: there are none, because the invoice ticket is closed. Request 3 is a search for Linus, then add_reply on the id it returns. Each card in the Capabilities tab now says what its tool does and asks only for what it needs.
For more on each of these, see Your First Tool, Schemas Are Contracts, Shaping Tool Results and Writing Descriptions Agents Understand.
Step 4: Decide what needs the user's confirmation
Request 4 now works a little too well. Anything that makes the model call close_ticket, whether it misread the user or mixed up two tickets, closes a ticket on the spot. Go through the tools and ask: what happens if the model calls this when the user didn't mean it?
| Tool | If it's called by mistake | So |
|---|---|---|
search_tickets, get_ticket | Nothing changes. | readOnlyHint: true. No confirmation. |
add_reply | A customer gets a reply nobody meant to send. It adds to the ticket and doesn't overwrite anything, and a follow-up can correct it. | destructiveHint: false. No confirmation. |
close_ticket | A customer who still needs help is told they're done. | destructiveHint: true, and ask the user first. |
You have two ways to act on the answers, and they do different jobs:
- Annotations describe a tool's effects to clients, which may use them to decide how carefully to confirm a call. Give every tool annotations. They're hints: FrontMCP sends them but doesn't enforce them.
this.elicit()asks the user a question from inside your tool, and your code acts on the answer. The check lives in your server, so it holds whatever the client does with hints.
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";
const ticketId = z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1");
function notFound(id: string) {
return new PublicMcpError(`There's no ticket ${id}. Use search_tickets to find the right id.`, "TICKET_NOT_FOUND");
}
@Tool({
name: "search_tickets",
description: "Search support tickets by a word from the title or the customer's name. Returns each match's id, title, customer and status.",
inputSchema: {
query: z.string().min(2).describe('A word from the title, or a customer name, like "invoice" or "Ada"'),
status: z.enum(["open", "closed"]).optional().describe("Only tickets with this status. Leave out for both."),
},
annotations: { readOnlyHint: true },
})
export class SearchTickets extends ToolContext {
async execute({ query, status }: { query: string; status?: "open" | "closed" }) {
return { tickets: this.get(TicketStore).search(query, status) };
}
}
@Tool({
name: "get_ticket",
description: "Get one ticket by id: the customer, the status, their message and every reply so far.",
inputSchema: { id: ticketId },
annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
async execute({ id }: { id: string }) {
const ticket = this.get(TicketStore).get(id);
if (!ticket) this.fail(notFound(id));
return ticket;
}
}
@Tool({
name: "add_reply",
description: "Add a reply to an open ticket. The customer reads it, so write it to them. Returns the updated ticket.",
inputSchema: {
id: ticketId,
text: z.string().min(1).max(2000).describe("The reply, as the customer will read it"),
},
annotations: { destructiveHint: false },
})
export class AddReply extends ToolContext {
async execute({ id, text }: { id: string; text: string }) {
const store = this.get(TicketStore);
const ticket = store.get(id);
if (!ticket) this.fail(notFound(id));
if (ticket.status === "closed") {
this.fail(new PublicMcpError(`${id} is closed. Replies can only go on open tickets.`, "TICKET_CLOSED"));
}
return store.addReply(id, text);
}
}
@Tool({
name: "close_ticket",
description: "Close a ticket whose problem is solved. The customer is told it's resolved, so the user is asked to confirm first.",
inputSchema: { id: ticketId },
annotations: { destructiveHint: true, idempotentHint: true },
})
export class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
const store = this.get(TicketStore);
const ticket = store.get(id);
if (!ticket) this.fail(notFound(id));
if (ticket.status === "closed") return { id, status: "closed", note: `${id} was already closed.` };
const answer = await this.elicit(
`Close ${id} "${ticket.title}"? ${ticket.customer} will be told it's resolved.`,
z.object({ confirmed: z.boolean().describe("Yes, close this ticket") }),
);
if (answer.status !== "accept" || !answer.content?.confirmed) {
return { id, status: "open", note: "The user didn't confirm, so the ticket is still open." };
}
store.close(id);
return { id, status: "closed" };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The page called close_ticket again, and this time the user gets a form instead of a closed ticket. Tick confirmed and press Accept to close it, or Decline to keep it open. The tool checks the ticket before it asks, so nobody is asked to close a ticket that doesn't exist or is already closed, and it writes only after the answer comes back, because execute() runs again from the top when the answer arrives. The Capabilities tab now shows the annotations as chips: read-only on the two reads, destructive and idempotent on close_ticket.
Keep confirmations for the actions that need them. A user asked to confirm every reply soon starts pressing Accept without reading, and then the one question that matters gets the same treatment. Replies are covered another way: the draft_reply prompt from step 2 tells the model to wait for the user's approval before it sends anything. Asking the User covers elicitation in depth.
Step 5: Group capabilities into apps
An @App groups capabilities that belong together: they work on the same data, share the same providers, and would share settings such as authentication. Everything so far is about tickets, so it's one app, and the app owns the TicketStore.
Then a new request shows up: "Is there a help article about sign-in links I can send Linus?" That's a new job with different data, articles, probably looked after by another team. It becomes a second app, and the @FrontMcp server lists both. The resource and the prompt from step 2 get files of their own, and tools.ts is hidden because it hasn't changed since step 4:
import "reflect-metadata";
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
import { KnowledgeBaseApp } from "./knowledge-base.app";
@FrontMcp({
info: { name: "Help Desk", version: "1.0.0" },
apps: [HelpDeskApp, KnowledgeBaseApp],
elicitation: { enabled: true },
})
export default class HelpDeskServer {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Open the Capabilities tab. A client sees one list with the tools from both apps. Apps are for you, not the model: they keep each area's code, data and settings together, and the server combines them. Tool names still have to make sense side by side, which is one more reason for specific names like search_tickets and search_articles over a plain search.
Now open the Tests tab. Each test is one of the requests you started with, carried out the way a model would carry it out. Your sample requests make a good first test suite: when they pass, the server does what people will ask of it. Grouping Capabilities into Apps covers apps in depth, and Testing Your Server covers tests.
Recap
- Start from the requests people will make, not from your API. They're your mockup.
- Break the requests into jobs and the data they need, and leave out what nobody asks for.
- Decide who starts each job: the model (a tool), the client app or user attaching data (a resource), or the user picking a workflow (a prompt). One job can be both a tool and a resource.
- Give each job its own tool, named after the job, asking only for what the model can know, and returning what the next step needs.
- Annotate every tool, and ask the user with
this.elicit()before the changes that are costly to get wrong. - Group capabilities that share data and settings into an app, and let the server combine apps.
Where to go from here
To build this server one step at a time, with each idea explained as it comes up, follow the Help Desk tutorial. To learn each kind of capability properly, start the Describing Capabilities chapter.