# Shaping Tool Results

> What a tool call sends back, how FrontMCP turns your return value into content and structuredContent, and how to return results and errors a model can act on.

Source: https://frontmcp.dev/learn/shaping-tool-results

When a tool finishes, its result goes into the model's context. The model reads it to decide what to tell the user and what to call next. So a result is as much a part of a tool's interface as its input schema. It should hold the data the model needs, in a shape programs can rely on, with ids that lead to the next step. And when a call fails, the result should say what to do about it.

**You will learn**
- What a `tools/call` result contains, and what FrontMCP does with your return value
- How to declare a result's shape with `outputSchema`, and what FrontMCP checks against it
- How to return what the model needs, with ids it can pass to the next tool
- How to fail in a way the model can recover from
- How to return images

## What comes back from `tools/call`

Here is a tool that returns an object. Open the **Wire** tab and expand the `tools/call` exchange:

```ts get-ticket.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open", assignee: undefined, updated: new Date("2026-09-20T09:30:00Z") };
  }
}
```

The result has two parts that carry the same data:

```json
{
  "content": [
    { "type": "text", "text": "{\"id\":\"T-1\",\"title\":\"Cannot log in\",\"status\":\"open\",\"updated\":\"2026-09-20T09:30:00.000Z\"}" }
  ],
  "structuredContent": { "id": "T-1", "title": "Cannot log in", "status": "open", "updated": "2026-09-20T09:30:00.000Z" }
}
```

- **`content`** is a list of blocks: text, images and other media. Most clients put these in front of the model. FrontMCP writes your object into one text block, as JSON.
- **`structuredContent`** is the same data as a JSON object, for clients and programs that read structured results directly instead of parsing text.

FrontMCP makes the value JSON-safe on the way: the `Date` arrived as an ISO string, and `assignee`, which was `undefined`, was left out.

That works well for objects. Plain values are less clear. Switch between these tabs and look at each result:

<Examples title="What FrontMCP does with a plain value">

#### Example: A number
```ts count.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "count_open_tickets", description: "Count open support tickets.", inputSchema: {} })
export class CountOpenTickets extends ToolContext {
  async execute() {
    return 2;
  }
}
```

#### Example: A string
```ts status.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "ticket_status",
  description: "Get the status of a support ticket.",
  inputSchema: { id: z.string().regex(/^T-\d+$/) },
})
export class TicketStatus extends ToolContext {
  async execute({ id }: { id: string }) {
    return `Ticket ${id} is open`;
  }
}
```

#### Example: A list
```ts list.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "list_open_tickets", description: "List open support tickets.", inputSchema: {} })
export class ListOpenTickets extends ToolContext {
  async execute() {
    return [
      { id: "T-1", title: "Cannot log in" },
      { id: "T-3", title: "Login link expired" },
    ];
  }
}
```

`structuredContent` has to be an object, so FrontMCP wraps anything else in one: `2` becomes `{ "value": 2 }`, the sentence becomes `{ "value": "Ticket T-1 is open" }`, and the list becomes `{ "value": [...] }`. The text block holds the same wrapper, so even the text isn't the plain sentence you returned.

`{ "value": 2 }` is correct, but it doesn't say what the 2 counts. Return objects with named fields instead: `{ open: 2 }`, `{ id, status }`, `{ tickets: [...] }`. The field names tell the model what each value means, and the object leaves room to add a field later without changing the result's shape.

| `execute()` returns | `structuredContent` | Text block in `content` |
| --- | --- | --- |
| An object | the object | the object as JSON |
| A string, number or boolean | `{ "value": ... }` | `{"value": ...}` as JSON |
| An array | `{ "value": [...] }` | `{"value": [...]}` as JSON |
| An object with a `content` array of blocks | none | your blocks, as they are ([see images](#returning-images)) |

> **Pitfall: NaN fails the whole call**
JSON has no `NaN` or `Infinity`, so FrontMCP refuses to send them. Averages over an empty list are the usual way to get one:

```ts stats.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";

const firstReplyMinutes: number[] = []; // no tickets answered yet today

@Tool({ name: "ticket_stats", description: "Today's support numbers.", inputSchema: {} })
export class TicketStats extends ToolContext {
  async execute() {
    const total = firstReplyMinutes.reduce((a, b) => a + b, 0);
    return { answered: firstReplyMinutes.length, avgFirstReplyMinutes: total / firstReplyMinutes.length };
  }
}
```

`0 / 0` is `NaN`, so the call fails with `INVALID_OUTPUT`, and in production the message is only "Output validation failed. Please contact support." Return `null` for "no value yet", which JSON can carry, or leave the field out.

## Declaring the shape with `outputSchema`

`outputSchema` tells clients the shape of a result before they call the tool. Write it with Zod, like `inputSchema`, but as one `z.object()`:

```ts search.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

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

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title.",
  inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles") },
  outputSchema: z.object({
    tickets: z.array(z.object({ id: z.string(), title: z.string(), status: z.enum(["open", "closed"]) })),
    total: z.number().int().describe("How many tickets matched"),
  }),
  annotations: { readOnlyHint: true },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    const matches = tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase()));
    return { tickets: matches, total: matches.length };
  }
}
```

The Capabilities card now ends with **Returns `{ tickets, total }`**, and `tools/list` in the Wire tab carries the full JSON Schema of the result next to `inputSchema`. Clients and programs that use your server know which fields to expect before they call, and can rely on `total` being a number.

FrontMCP also checks every result against `outputSchema`. When a result matches, FrontMCP sends the parsed result, so fields that aren't in the schema are dropped, whether or not the tool has a widget. When it doesn't match, the call fails. (Changed in 1.8.7: a tool with a widget used to keep its extra fields.) The **Tests** tab checks both cases:

```ts get-ticket.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { rows } from "./db";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
  outputSchema: z.object({ id: z.string(), title: z.string(), status: z.enum(["open", "closed"]) }),
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    // Returns the whole row and relies on outputSchema to trim it.
    return rows.find((r) => r.id === id)!;
  }
}
```

```ts db.ts
export const rows = [
  { id: "T-1", title: "Cannot log in", status: "open", internal_notes: "Legacy plan. Don't offer a refund." },
  { id: "T-2", title: "Invoice total is wrong", status: "pending", internal_notes: "Finance is checking. Don't promise a date." },
];
```

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

test("T-1 matches outputSchema, so `internal_notes` is dropped", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result.raw.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" });
});

test("T-2's status isn't in outputSchema, so the call fails with INVALID_OUTPUT", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-2" });
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("INVALID_OUTPUT");
  expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at status)");
});

test("nothing from T-2's row is sent", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-2" });
  expect(result.raw).not.toHaveProperty("structuredContent");
  expect(result.text()).not.toContain("Finance");
});
```

Someone added a `pending` status to the database, and nobody updated `outputSchema`. T-1 still comes back trimmed, but T-2 doesn't match, so the call fails with `INVALID_OUTPUT`, and nothing from the row is sent: not the status the schema says can't happen, and not the internal note. The message names the first field that didn't match. In production it says only "Output validation failed. Please contact support."

> **Pitfall: Letting outputSchema trim your results**
Returning a whole row and letting `outputSchema` drop the extra fields works until one value doesn't match. Then the model gets an error instead of the ticket, for every ticket with that value, and in production it can't tell why. Build the result yourself, field by field, as in the next section, and test that results match. `@Tool` also checks `execute()`'s return type against `outputSchema` when TypeScript compiles, and would reject this example, because `db.ts` types `status` as any string. But types describe what you expect: a database can still hand back a status nobody added to them.

## Return what the model needs

The easiest thing to return is whatever the database gave you. Here is a typical row:

```ts get-ticket.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { db } from "./db";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return db.tickets.find((row) => row.ticket_no === id);
  }
}
```

```ts db.ts
export const db = {
  tickets: [
    {
      id: 5501,
      ticket_no: "T-1",
      subject: "Cannot log in",
      status_code: 1,
      cust_id: 881,
      assignee_id: 12,
      body_html: "<p>I get <b>Error 403</b> when I sign in from the office.</p>",
      internal_notes: "Legacy plan. Don't offer a refund.",
      created_ms: 1789808400000,
      updated_ms: 1789896600000,
      _version: 7,
    },
  ],
  customers: [{ id: 881, name: "Dana Levi", email: "dana@acme.com", password_hash: "$2b$10$Qe..." }],
  statuses: { 1: "open", 2: "waiting on customer", 3: "closed" } as Record<number, string>,
};
```

The model has to work to use this, and some of it is dangerous:

- **Codes instead of meanings.** `status_code: 1` means nothing without the `statuses` table, which the model can't see.
- **Two ids.** `id: 5501` and `ticket_no: "T-1"` both look like the ticket's id. Only one of them works with `get_ticket`.
- **Formats a model reads badly.** `created_ms` is a number of milliseconds; `body_html` spends tokens on markup.
- **Things the model shouldn't see.** `internal_notes` is for agents, and a model may quote it to the customer.

Map the row to the result you want the model to read:

```ts get-ticket.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { db } from "./db";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
  outputSchema: z.object({
    id: z.string(),
    title: z.string(),
    status: z.enum(["open", "waiting on customer", "closed"]),
    customer: z.string(),
    description: z.string(),
    created: z.string(),
    updated: z.string(),
  }),
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const row = db.tickets.find((r) => r.ticket_no === id)!;
    const customer = db.customers.find((c) => c.id === row.cust_id)!;
    return {
      id: row.ticket_no,
      title: row.subject,
      status: db.statuses[row.status_code] as "open" | "waiting on customer" | "closed",
      customer: customer.name,
      description: row.body_html.replace(/<[^>]+>/g, ""),
      created: new Date(row.created_ms).toISOString(),
      updated: new Date(row.updated_ms).toISOString(),
    };
  }
}
```

```ts db.ts
export const db = {
  tickets: [
    {
      id: 5501,
      ticket_no: "T-1",
      subject: "Cannot log in",
      status_code: 1,
      cust_id: 881,
      assignee_id: 12,
      body_html: "<p>I get <b>Error 403</b> when I sign in from the office.</p>",
      internal_notes: "Legacy plan. Don't offer a refund.",
      created_ms: 1789808400000,
      updated_ms: 1789896600000,
      _version: 7,
    },
  ],
  customers: [{ id: 881, name: "Dana Levi", email: "dana@acme.com", password_hash: "$2b$10$Qe..." }],
  statuses: { 1: "open", 2: "waiting on customer", 3: "closed" } as Record<number, string>,
};
```

The result is shorter and says more. Some rules that got it there:

- **Use the words the user and the model use.** `status: "open"`, not `status_code: 1`, and `customer: "Dana Levi"`, not `cust_id: 881`.
- **Give each thing one id, in the format your other tools accept.** Here that's `T-1`, which is also what `get_ticket` takes.
- **Prefer readable formats.** ISO dates, plain text instead of HTML, and units in field names (`firstReplyMinutes`) when a number has one.
- **Pick fields on purpose.** Listing each field means a new database column, like a password hash, can't end up in a result without someone deciding it should.

## Ids the model can pass to the next tool

Models often chain tools: search for something, then act on what they found. That only works if a result carries an id that the next tool accepts, in exactly the format it accepts. Here `search_tickets` returns short summaries, and says which tool gives the rest:

```ts tickets.tools.ts active
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { tickets } from "./tickets";

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title. Returns up to 5 short matches; use get_ticket with an id for the full ticket.",
  inputSchema: { query: z.string().min(3).describe("Words to look for in ticket titles") },
  outputSchema: z.object({
    tickets: z.array(z.object({ id: z.string(), title: z.string(), status: z.string() })),
    total: z.number().int().describe("How many tickets matched, including any not returned"),
  }),
  annotations: { readOnlyHint: true },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    const q = query.toLowerCase();
    const matches = tickets.filter((t) => t.title.toLowerCase().includes(q));
    return {
      tickets: matches.slice(0, 5).map(({ id, title, status }) => ({ id, title, status })),
      total: matches.length,
    };
  }
}

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id, with its full description.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
  annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}. Find tickets with search_tickets.`));
    return ticket;
  }
}
```

```ts tickets.ts
export const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open", description: "Error 403 when signing in from the office." },
  { id: "T-2", title: "Invoice total is wrong", status: "closed", description: "Charged twice for September." },
  { id: "T-3", title: "Login link expired", status: "open", description: "The emailed link says it has expired." },
];
```

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

test("an id from search_tickets works with get_ticket", async ({ mcp }) => {
  const found = await mcp.tools.call("search_tickets", { query: "log" });
  const first = found.json().tickets[0];

  const ticket = await mcp.tools.call("get_ticket", { id: first.id });
  expect(ticket).toBeSuccessful();
  expect(ticket.json().title).toBe(first.title);
});

test("search results are summaries, without descriptions", async ({ mcp }) => {
  const found = await mcp.tools.call("search_tickets", { query: "log" });
  expect(found.json().tickets[0]).not.toHaveProperty("description");
  expect(found.json().total).toBe(2);
});
```

The **Tests** tab does what a model would: search, take the first id, and get the ticket. A test like this catches the mistakes that make models stumble, like search returning a database number while `get_ticket` wants `T-1`.

Two more details help the model find its way:

- **Search returns summaries; get returns details.** A search that returns every field of every match fills the model's context with text it didn't ask for.
- **Say when a list is cut short.** `total` tells the model there were more matches than it got, so it can ask for a narrower search instead of assuming it saw everything.

## Errors the model can act on

A call can fail in three ways, and each needs different handling:

1. **The arguments are wrong.** FrontMCP catches this before `execute()` runs and returns `INVALID_INPUT` with each problem. See [Schemas Are Contracts](https://frontmcp.dev/learn/schemas-are-contracts).
2. **The arguments are fine, but the request can't be done.** The ticket doesn't exist, or it's already closed. Fail with `this.fail(new PublicMcpError(message, code))`.
3. **Something broke.** The database is down. Let the error throw; FrontMCP returns it as `TOOL_EXECUTION_ERROR` and hides the details in production.

For the second kind, write the message for the model. Say what went wrong, with the value it sent, and what to do next:

```ts close-ticket.tool.ts active
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@Tool({
  name: "close_ticket",
  description: "Close an open support ticket.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) {
      this.fail(new PublicMcpError(`There's no ticket ${id}. Find the right id with search_tickets.`, "TICKET_NOT_FOUND"));
    }
    if (ticket.status === "closed") {
      this.fail(new PublicMcpError(`Ticket ${id} is already closed. Nothing to do.`, "ALREADY_CLOSED"));
    }
    ticket.status = "closed";
    return { id, status: ticket.status };
  }
}
```

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

test("a missing ticket fails with TICKET_NOT_FOUND", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-9" });
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("TICKET_NOT_FOUND");
  expect(result.text()).toBe("There's no ticket T-9. Find the right id with search_tickets.");
});

test("closing a closed ticket fails with ALREADY_CLOSED", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-2" });
  expect(result.raw._meta?.code).toBe("ALREADY_CLOSED");
});

test("closing an open ticket works", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({ id: "T-1", status: "closed" });
});
```

`this.fail()` ends the call with an `isError: true` result. `PublicMcpError` marks the message as safe to show, so the model reads it word for word, in development and in production. The second argument is a code: FrontMCP puts it in the result's `_meta.code`, where programs and tests can check it without parsing the message. Leave it out and the code is `PUBLIC_ERROR`.

Not every "nothing" is an error. A search with no matches should return `{ tickets: [], total: 0 }`: that's a real answer, and the model can say so or try other words. Fail when the model asked for something specific that can't be done, like closing a ticket that doesn't exist.

> **Pitfall: A plain Error hides the message**
Throwing a `PublicMcpError` instead of passing it to `this.fail()` gives the same result ([see `this.fail`](https://frontmcp.dev/reference/sdk/fail#failing-vs-throwing)), which helps in code that can't call `this.fail()`, like a provider. What matters is the class. FrontMCP treats any other error as something that broke:

```ts close-ticket.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "close_ticket",
  description: "Close an open support ticket.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (id !== "T-1") throw new Error(`There's no ticket ${id}.`);
    return { id, status: "closed" };
  }
}
```

The code is `TOOL_EXECUTION_ERROR`, and the message starts with `Tool "close_ticket" execution failed:` and ends with a stack trace. The Playground runs in development mode, so you still see your text. In production the model gets "Internal FrontMCP error. Please contact support with error ID: …" instead. Use `PublicMcpError` for every error the model should read.

## Returning images

Results aren't limited to text. A block in `content` can be an image, as base64 data with a MIME type. Help desk customers often attach screenshots, and a model that can see images can read one:

<Examples title="Returning an image">

#### Example: Image only
Set `outputSchema: "image"` and return one image block.

```ts screenshot.tool.ts active
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { screenshots } from "./attachments";

@Tool({
  name: "get_screenshot",
  description: "Get the screenshot a customer attached to a ticket.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
  outputSchema: "image",
})
export class GetScreenshot extends ToolContext {
  async execute({ id }: { id: string }) {
    const file = screenshots[id];
    if (!file) this.fail(new PublicMcpError(`Ticket ${id} has no screenshot.`, "NO_SCREENSHOT"));
    return { type: "image" as const, data: file.data, mimeType: file.mimeType };
  }
}
```

```ts attachments.ts hidden
export const screenshots: Record<string, { name: string; mimeType: string; data: string }> = {
  "T-1": {
    name: "login-error.png",
    mimeType: "image/png",
    data: "iVBORw0KGgoAAAANSUhEUgAAAHgAAABICAMAAAAKwMFNAAAAElBMVEX////s7vFaYGnWRUE0btzIzNIk3o6XAAAAgUlEQVR42u3Z0QqAMAiF4aXt/V+5i26DRolO/Q90/WGTA2NDgjJawqoic95fD5jl8oGPoAAD+8LDOMC7wmoQ4GV49RTrwCyXC0xl1oenQb7B/0fNB79cBgpOHARHbTXNRXOVb64eE9NcNFcx+DQIcE74aX341cBUJnBbmOcf4NTwBVNjKhVy30tUAAAAAElFTkSuQmCC",
  },
};
```

#### Example: Text and image
Return a `content` array yourself, and FrontMCP sends it as it is.

```ts screenshot.tool.ts active
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { screenshots } from "./attachments";

@Tool({
  name: "get_screenshot",
  description: "Get the screenshot a customer attached to a ticket, with its file name.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class GetScreenshot extends ToolContext {
  async execute({ id }: { id: string }) {
    const file = screenshots[id];
    if (!file) this.fail(new PublicMcpError(`Ticket ${id} has no screenshot.`, "NO_SCREENSHOT"));
    return {
      content: [
        { type: "text" as const, text: `${file.name}, attached to ${id} by the customer.` },
        { type: "image" as const, data: file.data, mimeType: file.mimeType },
      ],
    };
  }
}
```

```ts attachments.ts hidden
export const screenshots: Record<string, { name: string; mimeType: string; data: string }> = {
  "T-1": {
    name: "login-error.png",
    mimeType: "image/png",
    data: "iVBORw0KGgoAAAANSUhEUgAAAHgAAABICAMAAAAKwMFNAAAAElBMVEX////s7vFaYGnWRUE0btzIzNIk3o6XAAAAgUlEQVR42u3Z0QqAMAiF4aXt/V+5i26DRolO/Q90/WGTA2NDgjJawqoic95fD5jl8oGPoAAD+8LDOMC7wmoQ4GV49RTrwCyXC0xl1oenQb7B/0fNB79cBgpOHARHbTXNRXOVb64eE9NcNFcx+DQIcE74aX341cBUJnBbmOcf4NTwBVNjKhVy30tUAAAAAElFTkSuQmCC",
  },
};
```

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

test("the result has a caption and an image", async ({ mcp }) => {
  const result = await mcp.tools.call("get_screenshot", { id: "T-1" });
  expect(result).toHaveTextContent("login-error.png");
  expect(result).toHaveImageContent();
  expect(result.raw).not.toHaveProperty("structuredContent");
});
```

The Call tab shows the image. Either way there's no `structuredContent`: an image isn't JSON data, so FrontMCP only fills in `content`. When you return a `content` array yourself, FrontMCP doesn't add anything to it, so it's also the way to control exactly what the model reads.

## Recap

- A `tools/call` result has `content` (blocks the model reads) and `structuredContent` (the same data as a JSON object).
- Objects arrive as they are. Strings, numbers and arrays are wrapped as `{ "value": ... }`, so return objects with named fields.
- `outputSchema` advertises the shape of a result in `tools/list`, and FrontMCP checks every result against it: extra fields are dropped, and a result that doesn't match fails with `INVALID_OUTPUT`.
- Map database rows to results: meanings instead of codes, one id per thing, readable formats, and only fields you chose.
- Return ids in exactly the format your other tools accept, and test the chain.
- Fail with `this.fail(new PublicMcpError(message, code))`, with a message that says what to do next. A plain `Error` is hidden in production.
- Return images as `{ type: "image", data, mimeType }` with `outputSchema: "image"`, or build the `content` array yourself.

## Try some challenges

### Challenge: Trim a ticket for the model
`get_ticket` returns the whole database row. Return only `id` (like `T-1`), `title`, `status` (as a word) and `customer` (the name), and declare that shape with `outputSchema`.

```ts get-ticket.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { db } from "./db";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return db.tickets.find((row) => row.ticket_no === id)!;
  }
}
```

```ts db.ts
export const db = {
  tickets: [
    { id: 5501, ticket_no: "T-1", subject: "Cannot log in", status_code: 1, cust_id: 881, internal_notes: "Legacy plan. Don't offer a refund." },
  ],
  customers: [{ id: 881, name: "Dana Levi", password_hash: "$2b$10$Qe..." }],
  statuses: { 1: "open", 2: "closed" } as Record<number, "open" | "closed">,
};
```

```ts get-ticket.tool.ts solution
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { db } from "./db";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
  outputSchema: z.object({
    id: z.string(),
    title: z.string(),
    status: z.enum(["open", "closed"]),
    customer: z.string(),
  }),
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const row = db.tickets.find((r) => r.ticket_no === id)!;
    const customer = db.customers.find((c) => c.id === row.cust_id)!;
    return { id: row.ticket_no, title: row.subject, status: db.statuses[row.status_code], customer: customer.name };
  }
}
```

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

test("the result is id, title, status and customer", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result).toBeSuccessful();
  expect(result.raw.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open", customer: "Dana Levi" });
});

test("internal notes never reach the model", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result.text()).not.toContain("refund");
});

test("`outputSchema` declares the four fields", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "get_ticket");
  expect(Object.keys(tool?.outputSchema?.properties ?? {}).sort()).toEqual(["customer", "id", "status", "title"]);
});
```

**Hint:**
Look up the customer by `cust_id` and the status word in `db.statuses`, then return a new object with just the four fields.

**Solution:**
The result is built field by field, so nothing reaches the model unless you listed it, and `outputSchema` tells clients the shape. Relying on `outputSchema` alone would also have trimmed this row, but only while every field matched: the day a status the schema doesn't know shows up, the call fails instead.

### Challenge: Return ids the next tool accepts
`search_tickets` returns the database's numeric ids, but `get_ticket` wants ids like `T-1`, so a model can't get from one to the other. Make each search result carry an `id` that `get_ticket` accepts.

```ts tickets.tools.ts active
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { rows } from "./db";

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title. Use get_ticket with an id for the full ticket.",
  inputSchema: { query: z.string().min(3) },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    const matches = rows.filter((r) => r.subject.toLowerCase().includes(query.toLowerCase()));
    return { tickets: matches.map((r) => ({ id: r.id, title: r.subject })) };
  }
}

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const row = rows.find((r) => r.ticket_no === id);
    if (!row) this.fail(new PublicMcpError(`There's no ticket ${id}.`, "TICKET_NOT_FOUND"));
    return { id: row.ticket_no, title: row.subject };
  }
}
```

```ts db.ts
export const rows = [
  { id: 5501, ticket_no: "T-1", subject: "Cannot log in" },
  { id: 5502, ticket_no: "T-2", subject: "Invoice total is wrong" },
  { id: 5503, ticket_no: "T-3", subject: "Login link expired" },
];
```

```ts tickets.tools.ts solution
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { rows } from "./db";

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title. Use get_ticket with an id for the full ticket.",
  inputSchema: { query: z.string().min(3) },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    const matches = rows.filter((r) => r.subject.toLowerCase().includes(query.toLowerCase()));
    return { tickets: matches.map((r) => ({ id: r.ticket_no, title: r.subject })) };
  }
}

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const row = rows.find((r) => r.ticket_no === id);
    if (!row) this.fail(new PublicMcpError(`There's no ticket ${id}.`, "TICKET_NOT_FOUND"));
    return { id: row.ticket_no, title: row.subject };
  }
}
```

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

test("every search result has an id like T-123", async ({ mcp }) => {
  const found = await mcp.tools.call("search_tickets", { query: "log" });
  for (const t of found.json().tickets) expect(String(t.id)).toMatch(/^T-\d+$/);
});

test("each id from search_tickets works with get_ticket", async ({ mcp }) => {
  const found = await mcp.tools.call("search_tickets", { query: "log" });
  expect(found.json().tickets).toHaveLength(2);
  for (const t of found.json().tickets) {
    const ticket = await mcp.tools.call("get_ticket", { id: t.id });
    expect(ticket).toBeSuccessful();
    expect(ticket.json().title).toBe(t.title);
  }
});
```

**Hint:**
The row already has the id `get_ticket` wants. It's just not the one called `id`.

**Solution:**
Search now returns `ticket_no` as `id`, so its results feed straight into `get_ticket`. The hidden check does exactly what a model would, search and then get, which is the easiest way to catch a broken chain before a model does.

### Challenge: Fail so the model can recover
This tool throws a plain `Error` when a ticket doesn't exist, which reaches the model as an internal error. Make a missing ticket fail with the code `TICKET_NOT_FOUND` and a message that starts with `There's no ticket T-9` (for id `T-9`) and points the model to `search_tickets`.

```ts get-ticket.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) throw new Error("Not found");
    return ticket;
  }
}
```

```ts get-ticket.tool.ts solution
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) {
      this.fail(new PublicMcpError(`There's no ticket ${id}. Find tickets by title with search_tickets.`, "TICKET_NOT_FOUND"));
    }
    return ticket;
  }
}
```

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

test("a missing ticket fails with the code TICKET_NOT_FOUND", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-9" });
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("TICKET_NOT_FOUND");
});

test("the message starts with \"There's no ticket T-9\"", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-9" });
  expect(result.text()).toMatch(/^There's no ticket T-9/);
});

test("the message points to search_tickets", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-9" });
  expect(result.text()).toContain("search_tickets");
});

test("an existing ticket still comes back", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result).toBeSuccessful();
  expect(result.json().title).toBe("Cannot log in");
});
```

**Hint:**
Only a `PublicMcpError`'s message is meant for the model. It takes the code as its second argument.

**Solution:**
`this.fail()` sends the `PublicMcpError` as it is: your message word for word, and your code in `_meta.code`. The plain `Error` was wrapped as a `TOOL_EXECUTION_ERROR`, which production replaces with "Internal FrontMCP error". The new message also names the tool to use next, so the model can recover instead of apologizing.
