# Reusable Prompts

> What a prompt is and who picks it, how to declare required and optional arguments, return several messages, and put ticket data into a prompt.

Source: https://frontmcp.dev/learn/reusable-prompts

A **prompt** is a message template that the user picks, usually from a slash menu in their client. It's the third kind of MCP capability: the model decides when to call a tool, the application decides which resources to read, and the user decides when to run a prompt. Prompts are how you ship the instructions that work best with your server, so nobody has to write them from memory.

**You will learn**
- What a prompt is, and how it differs from tools and resources
- How to declare a `@Prompt` with required and optional arguments
- How to put a ticket's data into a prompt as an embedded resource
- How to return several messages, including an example exchange
- How clients show prompts to the people using them

## A workflow the user starts

Every support agent triages tickets a little differently. One asks the model for a priority, another for a team, a third forgets to ask for a reply at all. When there's a way that works, put it on the server as a prompt:

```ts triage.prompt.ts
import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk";

@Prompt({
  name: "triage",
  title: "Triage a ticket",
  description: "Suggest a priority, an owning team and a first reply for a support ticket",
})
export class Triage extends PromptContext {
  async execute(): Promise<GetPromptResult> {
    return {
      messages: [
        {
          role: "user",
          content: {
            type: "text",
            text: "Triage the support ticket I paste next. Suggest a priority (low, normal, high or urgent), the team that should own it, and a two-sentence first reply to the customer.",
          },
        },
      ],
    };
  }
}
```

Open the **Call** tab. The page sent `prompts/get`, and what came back is a list of **messages**, not an answer. Getting a prompt doesn't run the model. The client puts these messages into the conversation as if the user had typed them, and the model replies from there, calling your tools if it needs to.

A `@Prompt` has:

1. **`name`**, what clients use to get it. Like tool names, use `snake_case`.
2. **`title`** and **`description`**, what the user sees in the client's menu. Unlike a tool's description, these are written mostly for people.
3. **`arguments`**, the values the user fills in. This one has none yet, so it leaves the option out.
4. **The class**, which extends `PromptContext` and returns `{ messages }` from `execute()`. Each message has a `role` (`"user"` or `"assistant"`) and one piece of `content`.

The [`@Prompt` reference](https://frontmcp.dev/reference/sdk/prompt) lists every option.

So the three kinds of capability differ in who decides to use them:

| | Tool | Resource | Prompt |
| --- | --- | --- | --- |
| Who decides | The model | The application, often because the user attached it | The user |
| Identified by | A name | A URI | A name |
| Used with | `tools/call` | `resources/read` | `prompts/get` |
| Gives back | A result for the model | Content for the conversation | Messages to start the conversation with |

> **Note**
A prompt that's one user message can return just its text. FrontMCP wraps a string in a `user` message, so this sends the same `messages` as the example above:

```ts triage.prompt.ts
import { Prompt, PromptContext } from "@frontmcp/sdk";

@Prompt({
  name: "triage",
  title: "Triage a ticket",
  description: "Suggest a priority, an owning team and a first reply for a support ticket",
})
export class Triage extends PromptContext {
  async execute() {
    return "Triage the support ticket I paste next. Suggest a priority (low, normal, high or urgent), the team that should own it, and a two-sentence first reply to the customer.";
  }
}
```

The examples in this lesson return the full shape, and declare `execute()` as returning `Promise<GetPromptResult>`. That's optional, but it makes TypeScript check each message's `role` and `content` as you write them.

## Arguments

The prompt above makes the user paste the ticket in a second message. It's easier to ask for the ticket id up front:

```ts triage.prompt.ts
import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk";

@Prompt({
  name: "triage_ticket",
  title: "Triage a ticket",
  description: "Suggest a priority, an owning team and a first reply for one support ticket",
  arguments: [
    { name: "id", description: "Ticket id, like T-1", required: true },
    { name: "language", description: "Language for the reply to the customer. Defaults to English." },
  ],
})
export class TriageTicket extends PromptContext {
  async execute({ id, language }: { id: string; language?: string }): Promise<GetPromptResult> {
    return {
      messages: [
        {
          role: "user",
          content: {
            type: "text",
            text: `Look up ticket ${id} with get_ticket, then triage it. Suggest a priority (low, normal, high or urgent), the team that should own it, and a two-sentence first reply in ${language ?? "English"}.`,
          },
        },
      ],
    };
  }
}
```

Each argument has a `name`, a `description` for the person filling it in, and `required`:

- **Required arguments** must be sent. Clients ask for them before they send `prompts/get`, and FrontMCP rejects a request without them.
- **Optional arguments** can be left out. A missing one is `undefined` in `execute()`, so give it a default, like `language ?? "English"`.

Here is the same prompt, requested without an `id`:

```ts triage.prompt.ts
import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk";

@Prompt({
  name: "triage_ticket",
  title: "Triage a ticket",
  description: "Suggest a priority, an owning team and a first reply for one support ticket",
  arguments: [
    { name: "id", description: "Ticket id, like T-1", required: true },
    { name: "language", description: "Language for the reply to the customer. Defaults to English." },
  ],
})
export class TriageTicket extends PromptContext {
  async execute({ id, language }: { id: string; language?: string }): Promise<GetPromptResult> {
    return {
      messages: [
        {
          role: "user",
          content: {
            type: "text",
            text: `Look up ticket ${id} with get_ticket, then triage it. Suggest a priority (low, normal, high or urgent), the team that should own it, and a two-sentence first reply in ${language ?? "English"}.`,
          },
        },
      ],
    };
  }
}
```

The request fails with a JSON-RPC error, code `-32602` (invalid params) and the message `Missing required argument: id`, and `execute()` never runs. That's the only check FrontMCP makes. Prompt arguments are plain strings in MCP, with no schema like a tool's `inputSchema`, so an id like `banana` gets through to your code. If a value matters, check it yourself.

## Putting the ticket in the prompt

"Look up ticket T-1 with get_ticket" leaves a step to the model: it has to call the tool before it can start. Since the prompt runs on your server, it can look the ticket up itself and send the data along. The clearest way to send data is as an **embedded resource**: a message whose content is a resource, with a URI, instead of text:

```ts triage.prompt.ts active
import { Prompt, PromptContext, PublicMcpError, type GetPromptResult } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";

@Prompt({
  name: "triage_ticket",
  title: "Triage a ticket",
  description: "Suggest a priority, an owning team and a first reply for one support ticket",
  arguments: [
    { name: "id", description: "Ticket id, like T-1", required: true },
    { name: "language", description: "Language for the reply to the customer. Defaults to English." },
  ],
})
export class TriageTicket extends PromptContext {
  async execute({ id, language }: { id: string; language?: string }): Promise<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://${ticket.id}`, mimeType: "application/json", text: JSON.stringify(ticket) },
          },
        },
        {
          role: "user",
          content: {
            type: "text",
            text: `Triage the ticket above. Suggest a priority (low, normal, high or urgent), the team that should own it, and a two-sentence first reply in ${language ?? "English"}.`,
          },
        },
      ],
    };
  }
}
```

```ts ticket-store.ts
import { Provider, ProviderScope } from "@frontmcp/sdk";

@Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL })
export class TicketStore {
  private tickets = [
    { id: "T-1", title: "Cannot log in", status: "open", customer: "Acme Corp" },
    { id: "T-2", title: "Invoice total is wrong", status: "closed", customer: "Globex" },
    { id: "T-3", title: "Login link expired", status: "open", customer: "Acme Corp" },
  ];
  get(id: string) {
    return this.tickets.find((t) => t.id === id);
  }
}
```

```ts ticket.resource.ts
import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";

@ResourceTemplate({
  name: "ticket",
  uriTemplate: "tickets://{id}",
  mimeType: "application/json",
  description: "One support ticket",
})
export class TicketResource extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    const ticket = this.get(TicketStore).get(id);
    if (!ticket) throw new ResourceNotFoundError(uri);
    return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] };
  }
}
```

```ts triage.test.ts
import { test, expect } from "@frontmcp/testing";

test("the ticket arrives as an embedded resource", async ({ mcp }) => {
  const prompt = await mcp.prompts.get("triage_ticket", { id: "T-2" });
  expect(prompt).toHaveMessages(2);
  expect(prompt.messages[0].content).toMatchObject({
    type: "resource",
    resource: { uri: "tickets://T-2", mimeType: "application/json" },
  });
});

test("it's the same data as reading tickets://T-2", async ({ mcp }) => {
  const prompt = await mcp.prompts.get("triage_ticket", { id: "T-2" });
  const read = await mcp.resources.read("tickets://T-2");
  expect(JSON.parse(prompt.messages[0].content.resource.text)).toEqual(read.json());
});

test("an unknown ticket is an error the user can read", async ({ mcp }) => {
  const prompt = await mcp.prompts.get("triage_ticket", { id: "T-9" });
  expect(prompt).toBeError(-32602);
  expect(prompt.error.message).toBe("There's no ticket T-9.");
});
```

A few things changed:

1. **The prompt gets the ticket from a provider.** `PromptContext` has `this.get()`, like tools and resources, so the prompt and the `tickets://{id}` template read the same `TicketStore`.
2. **The first message is the ticket itself**, as `{ type: "resource", resource: { uri, mimeType, text } }`. The URI says where the data came from, and it's the same URI the [ticket template](https://frontmcp.dev/learn/exposing-data-with-resources#a-family-of-resources-with-a-template) serves. Clients can show it as an attached ticket instead of a wall of JSON the user seems to have typed, and the model can tell data apart from instructions.
3. **The instructions come second**, as text, and refer to "the ticket above".
4. **An unknown id fails.** `throw new PublicMcpError(...)` turns into a JSON-RPC error, code `-32602`, whose message is yours, so the client can tell the user that `T-9` doesn't exist. Open the **Tests** tab to see all three.

> **Pitfall: Throw PublicMcpError, not a plain Error**
`PublicMcpError` is for messages the user should read. A plain `Error` means the server failed:

```ts triage.prompt.ts
import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk";

const tickets = [{ id: "T-1", title: "Cannot log in" }];

@Prompt({ name: "triage_ticket", arguments: [{ name: "id", required: true }] })
export class TriageTicket extends PromptContext {
  async execute({ id }: { id: string }): Promise<GetPromptResult> {
    const ticket = tickets.find((t) => t.id === id);
    // 🚩 A server failure, as far as the client can tell
    if (!ticket) throw new Error(`There's no ticket ${id}.`);
    return { messages: [{ role: "user", content: { type: "text", text: `Triage ticket ${ticket.id}: ${ticket.title}` } }] };
  }
}
```

The client gets code `-32603`, an internal error, and `Prompt execution failed: There's no ticket T-9.` That's in development, which is how the Playground runs. With `NODE_ENV=production`, FrontMCP hides the message and sends `Internal FrontMCP error. Please contact support with error ID: …` instead, so the user never learns what was wrong.

## Several messages

A prompt can return more than one message, and they don't all have to be from the user. A message with `role: "assistant"` is a turn the model appears to have already taken. The most useful thing to do with that is show the model an example of the answer you want:

```ts triage.prompt.ts active
import { Prompt, PromptContext, PublicMcpError, type GetPromptResult } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";

const instructions =
  "Triage support tickets. Answer with a priority (low, normal, high or urgent), the team that should own the ticket, and a two-sentence reply to the customer.";

const exampleTicket = { id: "T-0", title: "Password reset email never arrives", status: "open", customer: "Initech" };

const exampleAnswer = [
  "Priority: high",
  "Team: Accounts",
  "Reply: Sorry the reset email hasn't reached you. We've sent a fresh link, and if it isn't in your inbox within ten minutes, please check your spam folder.",
].join("\n");

@Prompt({
  name: "triage_ticket",
  title: "Triage a ticket",
  description: "Suggest a priority, an owning team and a first reply for one support ticket",
  arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }],
})
export class TriageTicket extends PromptContext {
  async execute({ id }: { id: string }): Promise<GetPromptResult> {
    const ticket = this.get(TicketStore).get(id);
    if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`);
    return {
      messages: [
        { role: "user", content: { type: "text", text: `${instructions}\n\n${JSON.stringify(exampleTicket)}` } },
        { role: "assistant", content: { type: "text", text: exampleAnswer } },
        {
          role: "user",
          content: {
            type: "resource",
            resource: { uri: `tickets://${ticket.id}`, mimeType: "application/json", text: JSON.stringify(ticket) },
          },
        },
      ],
    };
  }
}
```

```ts ticket-store.ts
import { Provider, ProviderScope } from "@frontmcp/sdk";

@Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL })
export class TicketStore {
  private tickets = [
    { id: "T-1", title: "Cannot log in", status: "open", customer: "Acme Corp" },
    { id: "T-2", title: "Invoice total is wrong", status: "closed", customer: "Globex" },
    { id: "T-3", title: "Login link expired", status: "open", customer: "Acme Corp" },
  ];
  get(id: string) {
    return this.tickets.find((t) => t.id === id);
  }
}
```

The client adds all three messages to the conversation in order: the instructions with a made-up ticket, a model turn that answers it in exactly the format you want, and then the real ticket. The model continues the pattern. An example like this pins down the format far better than describing it, and it costs you one extra message.

## How clients show prompts

Clients discover prompts with `prompts/list`, and most show them as slash commands or in a menu of actions. When the user picks one, the client asks for its arguments, then sends `prompts/get` with the answers and puts the messages it gets back into the conversation.

What the user sees comes from your metadata:

- **`title`** is the label in the menu. Without one, clients fall back to `name`.
- **`description`** says what the prompt will do, so the user can pick the right one.
- **Each argument's `description`** is the hint next to its input, and `required` decides whether the user can skip it.

The Playground works the same way. On the Capabilities tab, a prompt card lists its arguments, with `?` after optional ones, and the Call tab turns them into a form.

**Deep dive: What does the client actually receive?**
For the `triage_ticket` prompt with an `id` and a `language`, `prompts/list` returns:

```json
{
  "prompts": [
    {
      "name": "triage_ticket",
      "title": "Triage a ticket",
      "description": "Suggest a priority, an owning team and a first reply for one support ticket",
      "arguments": [
        { "name": "id", "description": "Ticket id, like T-1", "required": true },
        { "name": "language", "description": "Language for the reply to the customer. Defaults to English." }
      ]
    }
  ]
}
```

An argument you didn't mark `required` is sent without the field, which MCP reads as optional. The client then sends `prompts/get` with `{ "name": "triage_ticket", "arguments": { "id": "T-2" } }`, and gets back the `messages` exactly as `execute()` returned them. If you don't return a `description` of your own, FrontMCP adds the prompt's. Open the Wire tab of any example above to see the full exchange.

## Recap

- A prompt is a message template the user picks. Getting it returns messages for the conversation; it doesn't run the model.
- A `@Prompt` has a `name`, a `title` and `description` for people, `arguments`, and a class that extends `PromptContext` and returns `{ messages }`.
- Required arguments must be sent, or `prompts/get` fails with `-32602`. Optional ones are `undefined` when left out. FrontMCP checks nothing else about them.
- Put data in a prompt as an embedded resource (`type: "resource"`) with a URI, and get it from the same provider your resources use.
- Messages can alternate between `user` and `assistant`. An example exchange shows the model the answer format you want.
- Throw `PublicMcpError` for problems the user should see. A plain `Error` is a server failure, and production hides its message.

## Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press **Check**.

### Challenge: Add an optional tone
The `draft_reply` prompt always asks for a friendly reply. Some customers need a formal one. Add an optional `tone` argument, with a description, that can be `friendly` or `formal`, and use `friendly` when it's left out.

```ts draft-reply.prompt.ts
import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk";

@Prompt({
  name: "draft_reply",
  title: "Draft a reply",
  description: "Draft a reply to the customer on a ticket",
  arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }],
})
export class DraftReply extends PromptContext {
  async execute({ id }: { id: string }): Promise<GetPromptResult> {
    return {
      messages: [
        {
          role: "user",
          content: { type: "text", text: `Look up ticket ${id} with get_ticket and draft a friendly reply to the customer.` },
        },
      ],
    };
  }
}
```

```ts draft-reply.prompt.ts solution
import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk";

@Prompt({
  name: "draft_reply",
  title: "Draft a reply",
  description: "Draft a reply to the customer on a ticket",
  arguments: [
    { name: "id", description: "Ticket id, like T-1", required: true },
    { name: "tone", description: "friendly or formal. Defaults to friendly." },
  ],
})
export class DraftReply extends PromptContext {
  async execute({ id, tone }: { id: string; tone?: string }): Promise<GetPromptResult> {
    return {
      messages: [
        {
          role: "user",
          content: { type: "text", text: `Look up ticket ${id} with get_ticket and draft a ${tone ?? "friendly"} reply to the customer.` },
        },
      ],
    };
  }
}
```

```ts draft-reply.test.ts hidden
import { test, expect } from "@frontmcp/testing";

const textOf = (prompt: { messages: any[] }) => prompt.messages.map((m) => m.content.text ?? "").join("\n");

test("`tone` is listed, with a description, and is optional", async ({ mcp }) => {
  const prompt = (await mcp.prompts.list()).find((p: any) => p.name === "draft_reply");
  const tone = prompt?.arguments?.find((a: any) => a.name === "tone");
  expect(tone).toBeDefined();
  expect(tone?.description?.length ?? 0).toBeGreaterThan(5);
  expect(tone?.required ?? false).toBe(false);
});

test("without `tone`, it asks for a friendly reply", async ({ mcp }) => {
  const prompt = await mcp.prompts.get("draft_reply", { id: "T-1" });
  expect(prompt).not.toBeError();
  expect(textOf(prompt)).toContain("friendly");
});

test("`tone: formal` asks for a formal reply", async ({ mcp }) => {
  const prompt = await mcp.prompts.get("draft_reply", { id: "T-1", tone: "formal" });
  expect(textOf(prompt)).toContain("formal");
  expect(textOf(prompt)).not.toContain("friendly");
});
```

**Hint:**
Add a second entry to `arguments` without `required: true`. In `execute()`, it arrives as `undefined` when the user leaves it out.

**Solution:**
`tone` has no `required`, so clients let the user skip it, and `prompts/list` doesn't mark it required. `tone ?? "friendly"` supplies the default. The prompt passes the value through as text, which is fine here: the model reads it, and nothing breaks if a user types something else.

### Challenge: Show the model an example
The triage answers come back in a different format every time. Turn this prompt into three messages: the instructions with the example ticket (from the user), the example answer (from the assistant), and then the real ticket (from the user), as text like `Ticket T-3: Login link expired`.

```ts triage.prompt.ts
import { Prompt, PromptContext, PublicMcpError, type GetPromptResult } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in" },
  { id: "T-2", title: "Invoice total is wrong" },
  { id: "T-3", title: "Login link expired" },
];

const instructions = "Triage support tickets. Answer with a priority (low, normal, high or urgent) and the team that should own the ticket.";
const exampleTicket = "Ticket T-0: Password reset email never arrives";
const exampleAnswer = "Priority: high\nTeam: Accounts";

@Prompt({
  name: "triage_ticket",
  title: "Triage a ticket",
  description: "Suggest a priority and an owning team for one support ticket",
  arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }],
})
export class TriageTicket extends PromptContext {
  async execute({ id }: { id: string }): Promise<GetPromptResult> {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`);
    return {
      messages: [
        { role: "user", content: { type: "text", text: `${instructions}\n\nTicket ${ticket.id}: ${ticket.title}` } },
      ],
    };
  }
}
```

```ts triage.prompt.ts solution
import { Prompt, PromptContext, PublicMcpError, type GetPromptResult } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in" },
  { id: "T-2", title: "Invoice total is wrong" },
  { id: "T-3", title: "Login link expired" },
];

const instructions = "Triage support tickets. Answer with a priority (low, normal, high or urgent) and the team that should own the ticket.";
const exampleTicket = "Ticket T-0: Password reset email never arrives";
const exampleAnswer = "Priority: high\nTeam: Accounts";

@Prompt({
  name: "triage_ticket",
  title: "Triage a ticket",
  description: "Suggest a priority and an owning team for one support ticket",
  arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }],
})
export class TriageTicket extends PromptContext {
  async execute({ id }: { id: string }): Promise<GetPromptResult> {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`);
    return {
      messages: [
        { role: "user", content: { type: "text", text: `${instructions}\n\n${exampleTicket}` } },
        { role: "assistant", content: { type: "text", text: exampleAnswer } },
        { role: "user", content: { type: "text", text: `Ticket ${ticket.id}: ${ticket.title}` } },
      ],
    };
  }
}
```

```ts triage.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("it returns three messages: user, assistant, user", async ({ mcp }) => {
  const prompt = await mcp.prompts.get("triage_ticket", { id: "T-3" });
  expect(prompt).toHaveMessages(3);
  expect(prompt.messages.map((m: any) => m.role)).toEqual(["user", "assistant", "user"]);
});

test("the first message has the instructions and the example ticket", async ({ mcp }) => {
  const [first] = (await mcp.prompts.get("triage_ticket", { id: "T-3" })).messages;
  expect(first.content.text).toContain("Triage support tickets");
  expect(first.content.text).toContain("Password reset email never arrives");
});

test("the assistant message is the example answer", async ({ mcp }) => {
  const [, second] = (await mcp.prompts.get("triage_ticket", { id: "T-3" })).messages;
  expect(second.content.text).toBe("Priority: high\nTeam: Accounts");
});

test("the last message is the real ticket", async ({ mcp }) => {
  const [, , third] = (await mcp.prompts.get("triage_ticket", { id: "T-3" })).messages;
  expect(third.content.text).toContain("Login link expired");
  expect(third.content.text).not.toContain("Password reset");
});
```

**Hint:**
`messages` is an array. Each entry has its own `role` and `content`. `exampleTicket` and `exampleAnswer` are already defined; they just aren't used yet.

**Solution:**
The model sees a finished example exchange before it gets the real ticket, so it answers in the same two lines. The instructions still say what to do; the example shows what the answer looks like.

### Challenge: Attach the ticket instead of pasting it
This prompt pastes the ticket's JSON into the instructions. Send it as an embedded resource instead, with the URI `tickets://<id>` and the `application/json` type, followed by the instructions as a separate text message.

```ts summarize.prompt.ts
import { Prompt, PromptContext, PublicMcpError, type GetPromptResult } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";

@Prompt({
  name: "summarize_ticket",
  title: "Summarize a ticket",
  description: "Summarize a ticket for a teammate who's taking it over",
  arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }],
})
export class SummarizeTicket extends PromptContext {
  async execute({ id }: { id: string }): Promise<GetPromptResult> {
    const ticket = this.get(TicketStore).get(id);
    if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`);
    return {
      messages: [
        {
          role: "user",
          content: {
            type: "text",
            text: `Here is a ticket: ${JSON.stringify(ticket)}\n\nSummarize it in three bullet points for a teammate who's taking it over.`,
          },
        },
      ],
    };
  }
}
```

```ts summarize.prompt.ts solution
import { Prompt, PromptContext, PublicMcpError, type GetPromptResult } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";

@Prompt({
  name: "summarize_ticket",
  title: "Summarize a ticket",
  description: "Summarize a ticket for a teammate who's taking it over",
  arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }],
})
export class SummarizeTicket extends PromptContext {
  async execute({ id }: { id: string }): Promise<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://${ticket.id}`, mimeType: "application/json", text: JSON.stringify(ticket) },
          },
        },
        {
          role: "user",
          content: { type: "text", text: "Summarize the ticket above in three bullet points for a teammate who's taking it over." },
        },
      ],
    };
  }
}
```

```ts ticket-store.ts
import { Provider, ProviderScope } from "@frontmcp/sdk";

@Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL })
export class TicketStore {
  private tickets = [
    { id: "T-1", title: "Cannot log in", status: "open", customer: "Acme Corp" },
    { id: "T-2", title: "Invoice total is wrong", status: "closed", customer: "Globex" },
  ];
  get(id: string) {
    return this.tickets.find((t) => t.id === id);
  }
}
```

```ts summarize.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("the first message is the ticket, as a resource at `tickets://T-2`", async ({ mcp }) => {
  const prompt = await mcp.prompts.get("summarize_ticket", { id: "T-2" });
  expect(prompt.messages[0]?.content).toMatchObject({
    type: "resource",
    resource: { uri: "tickets://T-2", mimeType: "application/json" },
  });
  expect(JSON.parse(prompt.messages[0].content.resource.text)).toMatchObject({ id: "T-2", title: "Invoice total is wrong" });
});

test("the instructions follow as text, without the JSON", async ({ mcp }) => {
  const prompt = await mcp.prompts.get("summarize_ticket", { id: "T-2" });
  expect(prompt).toHaveMessages(2);
  expect(prompt.messages[1].content.type).toBe("text");
  expect(prompt.messages[1].content.text).toContain("Summarize");
  expect(prompt.messages[1].content.text).not.toContain("Invoice total is wrong");
});

test("an unknown ticket is still an error", async ({ mcp }) => {
  expect(await mcp.prompts.get("summarize_ticket", { id: "T-9" })).toBeError();
});
```

**Hint:**
An embedded resource is `{ type: "resource", resource: { uri, mimeType, text } }` as a message's `content`. Each message has one piece of content, so the ticket and the instructions become two messages.

**Solution:**
The ticket now travels as data with an address, `tickets://T-2`, and the instructions are only instructions. A client can show the ticket as an attachment, and the model doesn't have to find where the JSON starts and ends.
