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/callresult 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:
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") };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The result has two parts that carry the same data:
{
"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" }
}
contentis 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.structuredContentis 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:
What FrontMCP does with a plain value
Example 1 of 3
A number
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;
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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) |
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():
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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)!;
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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."
Return what the model needs
The easiest thing to return is whatever the database gave you. Here is a typical row:
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);
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The model has to work to use this, and some of it is dangerous:
- Codes instead of meanings.
status_code: 1means nothing without thestatusestable, which the model can't see. - Two ids.
id: 5501andticket_no: "T-1"both look like the ticket's id. Only one of them works withget_ticket. - Formats a model reads badly.
created_msis a number of milliseconds;body_htmlspends tokens on markup. - Things the model shouldn't see.
internal_notesis for agents, and a model may quote it to the customer.
Map the row to the result you want the model to read:
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(),
};
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The result is shorter and says more. Some rules that got it there:
- Use the words the user and the model use.
status: "open", notstatus_code: 1, andcustomer: "Dana Levi", notcust_id: 881. - Give each thing one id, in the format your other tools accept. Here that's
T-1, which is also whatget_tickettakes. - 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:
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;
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
totaltells 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:
- The arguments are wrong. FrontMCP catches this before
execute()runs and returnsINVALID_INPUTwith each problem. See Schemas Are Contracts. - 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)). - Something broke. The database is down. Let the error throw; FrontMCP returns it as
TOOL_EXECUTION_ERRORand 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:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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:
Returning an image
Example 1 of 2
Image only
Set outputSchema: "image" and return one image block.
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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/callresult hascontent(blocks the model reads) andstructuredContent(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. outputSchemaadvertises the shape of a result intools/list, and FrontMCP checks every result against it: extra fields are dropped, and a result that doesn't match fails withINVALID_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 plainErroris hidden in production. - Return images as
{ type: "image", data, mimeType }withoutputSchema: "image", or build thecontentarray yourself.
Try some challenges
Challenge 1 of 3
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.
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)!;
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.