# this.respond

> End a tool call early with a result you built yourself, answer a call from a hook, and what respond() does in resources, prompts, jobs and agents.

Source: https://frontmcp.dev/reference/sdk/respond

`this.respond()` ends a tool call on the spot with a result you pass. It's lower level than `return`: the value you pass *is* the `tools/call` result, sent as it is, so it has to be a complete result, with `content`. FrontMCP doesn't build the text block, fill in `structuredContent` or check `outputSchema` for you. Use it when you need a result of an exact shape. In a hook, the flow's `ctx.respond()` answers a call without running the tool at all, which is how a cache works. In resources, prompts, jobs and agents, `respond(value)` ends `execute()` as `return value` would.

```ts
this.respond(result)
```

---

## Reference

### `this.respond(result)`

Call `this.respond()` inside a tool's `execute()`. Nothing after it runs.

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

@Tool({ name: "list_tickets", description: "List a customer's open tickets", inputSchema: { customer: z.string() } })
export class ListTickets extends ToolContext {
  async execute({ customer }: { customer: string }) {
    const tickets = await openTicketsOf(customer);
    if (tickets.length === 0) {
      this.respond({ content: [{ type: "text", text: `${customer} has no open tickets.` }] });
    }
    return { customer, tickets };
  }
}
```

[See more examples below.](#usage)

#### Parameters

| Parameter | Type | Description |
| --- | --- | --- |
| `result` | `CallToolResult` | The whole result the client gets: `content` (required by MCP), and optionally `structuredContent`, `isError` and `_meta`. TypeScript accepts any value here, so nothing stops you from passing a plain object by mistake: see [the result has no `content`](#the-result-has-no-content). |

#### Returns

`never`. It throws to end `execute()`, like [`this.fail()`](https://frontmcp.dev/reference/sdk/fail).

#### What the client receives

`result`, exactly as you passed it. Under MCP 2026-07-28 FrontMCP adds `resultType: "complete"` and its `serverInfo` in `_meta`, as it does to every result; nothing else changes. Compare that with returning the same object:

| In `execute()` | The client gets |
| --- | --- |
| `return { id: "T-1" }` | `content: [{ type: "text", text: '{"id":"T-1"}' }]` and `structuredContent: { id: "T-1" }`, checked against `outputSchema` |
| `this.respond({ id: "T-1" })` | `{ id: "T-1" }` as the whole result: no `content`, no `structuredContent`, not checked |
| `this.respond({ content: [...], structuredContent: {...} })` | That result, as it is. `structuredContent` isn't checked against `outputSchema` either. |

`Did("execute")` hooks still run after `this.respond()`, and see `result` as the tool's output.

#### Where it works

| In | What `respond(value)` does | Use instead |
| --- | --- | --- |
| A tool's `execute()` | Ends the call. `value` is the whole result. | `return`, for everything except hand-built results |
| A hook, as the flow's `ctx.respond(value)` | Ends the call. The tool doesn't run, and `Did("execute")` hooks are skipped. `value` is the whole result; one without `content` gets `content: []`. | |
| A hook, as `ctx.state.toolContext.respond(value)` | Ends the call like `ctx.respond(value)`, but sends `value` exactly as it is, without even an empty `content`. | `ctx.respond()` |
| A resource's `execute()` | Ends the read, and `value` is sent as a returned value would be: `{ contents }` as it is, anything else as a text block, an object as JSON. (Changed in 1.9.3: before, the read failed with `Resource output not found`.) | Either |
| A prompt's `execute()` | Ends the request, and `value` is sent as a returned value would be. (Changed in 1.9.3: before, the request failed with `Prompt output not found`.) | Either |
| A job's `execute()` | Ends the run, and `value` becomes its `result`, checked against `outputSchema` as a returned value is. (Changed in 1.9.2: before, `value` replaced the whole `execute_job` response.) | Either |
| An agent's `execute()` | Ends the call, and `value` is sent as a returned value would be, with `content` and `structuredContent`. (Changed in 1.8.5: before, `value` was the whole result, with no `content`.) | Either |

#### Caveats

- **`this.respond()` works by throwing**, like `this.fail()`. A `try`/`catch` around it catches it, as a `FlowControl` with `type: "respond"`, and `execute()` carries on. See [the call carries on](#the-call-carries-on-after-thisrespond).
- `outputSchema` is only checked for returned values. A result you pass to `this.respond()` reaches the client even when its `structuredContent` doesn't match.
- Prefer `return`. It builds `content` and `structuredContent` from one value, and checks it. Reach for `this.respond()` only when the result needs a shape `return` can't give it, like several content blocks.

---

## Usage

### Sending a result you built yourself

A returned object becomes one JSON text block and `structuredContent`. To send several text blocks, one per ticket here, build the result yourself:

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

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

@Tool({ name: "list_tickets", description: "List a customer's open tickets", inputSchema: { customer: z.string() } })
export class ListTickets extends ToolContext {
  async execute({ customer }: { customer: string }) {
    const open = tickets.filter((t) => t.customer === customer);
    this.respond({
      content: open.map((t) => ({ type: "text" as const, text: `${t.id}: ${t.title}` })),
      structuredContent: { customer, tickets: open },
    });
  }
}
```

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

test("the result is exactly what execute() built", async ({ mcp }) => {
  const result = await mcp.tools.call("list_tickets", { customer: "acme" });
  expect(result.raw.content).toEqual([
    { type: "text", text: "T-1: Cannot log in" },
    { type: "text", text: "T-3: Login link expired" },
  ]);
  expect(result.raw.structuredContent).toEqual({ customer: "acme", tickets: [expect.objectContaining({ id: "T-1" }), expect.objectContaining({ id: "T-3" })] });
});
```

`execute()` doesn't need a `return` after `this.respond()`: nothing after it runs.

### Answering a call from a hook

A `Will("execute")` hook can answer a call itself, with the flow's `ctx.respond()`, and the tool never runs. Here a plugin answers repeated calls to read-only tools from memory. [Hooking into Calls](https://frontmcp.dev/learn/hooking-into-calls#changing-the-result) builds the same cache step by step, in "Answering without running the tool". For a real cache, with expiry, Redis and per-caller keys, use the [Cache plugin](https://frontmcp.dev/reference/plugins/cache).

```ts cache.plugin.ts active
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";

type CallCtx = FlowCtxOf<"tools:call-tool">;
const readOnly = (ctx: CallCtx) => ctx.state.tool?.metadata.annotations?.readOnlyHint === true;

@Plugin({ name: "cache", description: "Answers repeated read-only calls from memory" })
export class CachePlugin extends DynamicPlugin<object> {
  private results = new Map<string, Record<string, unknown>>();

  private key(ctx: CallCtx) {
    return `${ctx.state.tool?.metadata.name}:${JSON.stringify(ctx.state.toolContext?.input)}`;
  }

  @ToolHook.Will("execute", { filter: readOnly })
  async answerFromCache(ctx: CallCtx) {
    const cached = this.results.get(this.key(ctx));
    if (cached) ctx.respond({ content: [{ type: "text", text: JSON.stringify(cached) }], structuredContent: cached });
  }

  @ToolHook.Did("execute", { filter: readOnly })
  async store(ctx: CallCtx) {
    this.results.set(this.key(ctx), ctx.state.toolContext?.output as Record<string, unknown>);
  }
}
```

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

let loads = 0;

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id",
  inputSchema: { id: z.string() },
  annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    loads += 1;
    return { id, title: "Cannot log in", load: loads };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket } from "./get-ticket.tool";
import { CachePlugin } from "./cache.plugin";

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [CachePlugin] })
export class HelpDeskApp {}
```

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

test("the second call is answered by the hook", async ({ mcp }) => {
  const first = await mcp.tools.call("get_ticket", { id: "T-1" });
  const second = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(first.json()).toEqual({ id: "T-1", title: "Cannot log in", load: 1 });
  expect(second.json()).toEqual({ id: "T-1", title: "Cannot log in", load: 1 });
});
```

`ctx.respond()` ends the whole call: the tool, the `Did("execute")` hooks and the result formatting are skipped, so pass a finished result, with `content`. Neither `ctx.respond()` nor `ctx.state.toolContext.respond()` builds one from a plain value: see [a hook's answer is empty](#a-hooks-answer-reaches-the-client-empty). The [hook decorators](https://frontmcp.dev/reference/sdk/hooks) page lists the stages you can answer from.

### Ending early everywhere else

Outside tools, `respond(value)` ends `execute()` as `return value` would: FrontMCP builds and checks the result from `value` as it does from a returned value. Each tab shows it next to `return`.

<Examples title="respond() outside tools">

#### Example: Resource
```ts ticket.resource.ts active
import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk";

@ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json" })
export class Ticket extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    if (id === "T-0") {
      this.respond({ contents: [{ uri, mimeType: "application/json", text: "{}" }] }); // ✅ since 1.9.3
    }
    return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ id, title: "Cannot log in" }) }] }; // ✅
  }
}
```

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

test("respond() ends the read with its value", async ({ mcp }) => {
  expect((await mcp.resources.read("tickets://T-0")).text()).toBe("{}");
});

test("return works", async ({ mcp }) => {
  expect(JSON.parse((await mcp.resources.read("tickets://T-1")).text()!)).toEqual({ id: "T-1", title: "Cannot log in" });
});
```

#### Example: Prompt
```ts summarize.prompt.ts active
import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk";

@Prompt({
  name: "summarize_ticket",
  description: "Summarize a ticket for a handover",
  arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }],
})
export class SummarizeTicket extends PromptContext {
  async execute({ id }: Record<string, string>): Promise<GetPromptResult> {
    const message = (text: string): GetPromptResult => ({ messages: [{ role: "user", content: { type: "text", text } }] });
    if (id === "T-0") this.respond(message("There's no ticket T-0.")); // ✅ since 1.9.3
    return message(`Summarize ticket ${id} for the next agent on shift.`); // ✅
  }
}
```

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

test("respond() ends the request with its value", async ({ mcp }) => {
  const result = await mcp.prompts.get("summarize_ticket", { id: "T-0" });
  expect(result.messages[0].content.text).toBe("There's no ticket T-0.");
});

test("return works", async ({ mcp }) => {
  const result = await mcp.prompts.get("summarize_ticket", { id: "T-1" });
  expect(result.messages[0].content.text).toBe("Summarize ticket T-1 for the next agent on shift.");
});
```

#### Example: Job
```ts count-open.job.ts active
import { Job, JobContext, z } from "@frontmcp/sdk";

const open: Record<string, number> = { acme: 2, globex: 1 };

@Job({
  name: "count-open-tickets",
  description: "Count a customer's open tickets",
  inputSchema: { customer: z.string() },
  outputSchema: { customer: z.string(), open: z.number() },
})
export class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    if (!(customer in open)) this.respond({ customer, open: 0, note: "not a customer yet" }); // ✅ since 1.9.2
    return { customer, open: open[customer] }; // ✅
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CountOpenTickets } from "./count-open.job";

@App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets] })
export class HelpDeskApp {}
```

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

test("respond() ends the run, and its value is the run's result", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "initech" } });
  expect(result.json()).toMatchObject({ runId: expect.any(String), state: "completed" });
  expect(result.json().result).toEqual({ customer: "initech", open: 0 }); // checked against outputSchema: `note` is dropped
});

test("✅ return gives a run record", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "acme" } });
  expect(result.json()).toMatchObject({ state: "completed", result: { customer: "acme", open: 2 } });
});
```

#### Example: Agent
```ts triage.agent.ts active
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { model } from "./model.example";

@Agent({
  name: "triage",
  description: "Suggest a priority for a new support ticket. Pass what the customer wrote.",
  systemInstructions: "You triage tickets for a help desk. Answer high, normal or low, and say why in one sentence.",
  inputSchema: { query: z.string().describe("What the customer wrote") },
  llm: { adapter: model },
})
export class Triage extends AgentContext {
  async execute(input: { query: string }) {
    if (input.query.trim() === "") this.respond({ response: "Nothing to triage." }); // ✅ since 1.8.5
    return super.execute(input); // ✅
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { Triage } from "./triage.agent";

@App({ id: "help-desk", name: "Help Desk", agents: [Triage] })
export class HelpDeskApp {}
```

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion() {
    return { content: "Normal: nothing is blocked.", finishReason: "stop" };
  },
};
```

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

test("respond() ends the call with a formatted result", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { query: "" });
  expect(result.json()).toEqual({ response: "Nothing to triage." });
  expect(result.raw.structuredContent).toEqual({ response: "Nothing to triage." });
});

test("✅ a returned value is formatted", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { query: "The invoice total is wrong." });
  expect(result.json()).toEqual({ response: "Normal: nothing is blocked." });
});
```

In an agent, `this.respond()` before `super.execute()` ends the call without asking the model, and the client gets a normal result, as it would from `return { response: "Nothing to triage." }`.

---

## Troubleshooting

### The result has no `content`

`this.respond()` was given the tool's output, like `this.respond({ id, status })`, and sent it as the whole result. MCP clients read `content`, so the model sees nothing, and `structuredContent` is missing too:

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

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    this.respond({ id, status: "closed" }); // 🚩 the whole result, not the output
  }
}
```

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

test("🚩 the value is the result, with no content", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result.raw).toMatchObject({ id: "T-1", status: "closed" });
  expect(result.raw.content).toBeUndefined();
  expect(result.raw.structuredContent).toBeUndefined();
});
```

Replace `this.respond(value)` with `return value`, and FrontMCP builds `content` and `structuredContent` from it. If you do need `this.respond()`, pass a complete result: `{ content: [...], structuredContent: value }`.

### A hook's answer reaches the client empty

The hook passed the tool's output to `ctx.respond()`, as a cache that stored `toolContext.output` would. The flow's `ctx.respond()` only adds an empty `content` to it, and `ctx.state.toolContext.respond()` sends it as it is:

```ts pinned.plugin.ts active
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";

type CallCtx = FlowCtxOf<"tools:call-tool">;

@Plugin({ name: "pinned", description: "Answers get_ticket for pinned tickets from memory" })
export class PinnedPlugin extends DynamicPlugin<object> {
  private pinned = new Map<string, any>([["T-1", { id: "T-1", title: "Cannot log in", status: "open" }]]);

  @ToolHook.Will("execute")
  async answer(ctx: CallCtx) {
    const input = ctx.state.toolContext?.input as { id: string; mode?: string };
    const ticket = this.pinned.get(input.id);
    if (!ticket) return;
    if (input.mode === "toolContext") ctx.state.toolContext?.respond(ticket); // 🚩 sent as it is
    else ctx.respond(ticket); // 🚩 gets an empty content
  }
}
```

```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(), mode: z.string().optional() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "(loaded)", status: "open" };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket } from "./get-ticket.tool";
import { PinnedPlugin } from "./pinned.plugin";

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [PinnedPlugin] })
export class HelpDeskApp {}
```

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

test("🚩 ctx.respond(value): content is empty", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result.raw).toMatchObject({ content: [], id: "T-1", title: "Cannot log in" });
  expect(result.raw.structuredContent).toBeUndefined();
});

test("🚩 toolContext.respond(value): no content at all", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1", mode: "toolContext" });
  expect(result.raw).toMatchObject({ id: "T-1", title: "Cannot log in" });
  expect(result.raw.content).toBeUndefined();
});
```

TypeScript refuses a plain object in `ctx.respond()`, which wants a result with `content`, but not a value typed `any`, like this `Map<string, any>` or anything from `JSON.parse()`. Build the result from the value: `ctx.respond({ content: [{ type: "text", text: JSON.stringify(value) }], structuredContent: value })`, as the cache in [Answering a call from a hook](#answering-a-call-from-a-hook) does.

### A text result arrives as `{"0":"C","1":"l",…}`

`this.respond("Closed T-1.")` sends the string as the whole result. A result must be an object, so the string is spread into one, a key per character:

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

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    this.respond(`Closed ${id}.`); // 🚩 a string isn't a result
  }
}
```

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

test("🚩 the string is spread into the result", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result.raw).toMatchObject({ 0: "C", 1: "l", 2: "o" });
  expect(result.raw.content).toBeUndefined();
});
```

Respond with a complete result, `{ content: [{ type: "text", text: "Closed T-1." }] }`, or return an object and let FrontMCP build the result.

### `Prompt execution failed: Prompt output not found`

A prompt's `execute()` finished without returning anything: a code path has no `return`. Return the messages on every path, or end early with `this.respond()`, as in the [Prompt tab above](#ending-early-everywhere-else). (Before 1.9.3, calling `this.respond()` in a prompt failed this way too.)

### `Resource "…" read failed: Resource output not found`

The same for a resource: `execute()` finished without a `return`. Return `{ contents: [...] }`, or end early with `this.respond()`. (Before 1.9.3, calling `this.respond()` in a resource failed this way too.)

### The call carries on after `this.respond()`

`this.respond()` throws to end `execute()`, and a `try`/`catch` around it catches that. The call then continues after the `catch`, and whatever `execute()` returns is the result:

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

@Tool({ name: "reopen_ticket", description: "Reopen a closed ticket", inputSchema: { id: z.string() } })
export class ReopenTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    try {
      if (id === "T-1") this.respond({ content: [{ type: "text", text: `${id} is already open.` }] });
      return { id, reopened: true };
    } catch {
      // 🚩 this also catches respond()
      return { id, reopened: false };
    }
  }
}
```

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

test("🚩 the catch swallowed respond()", async ({ mcp }) => {
  const result = await mcp.tools.call("reopen_ticket", { id: "T-1" });
  expect(result.json()).toEqual({ id: "T-1", reopened: false });
});
```

Call `this.respond()` outside the `try`, or rethrow what you didn't expect in the `catch`. [`this.fail()`](https://frontmcp.dev/reference/sdk/fail#a-tool-call-succeeds-after-thisfail) has the same catch.

### The client got a result that doesn't match `outputSchema`

`outputSchema` is checked for returned values only. A result passed to `this.respond()`, or to `ctx.respond()` in a hook, is sent as it is, and a `Did("execute")` hook sees it as the tool's output:

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

@Tool({
  name: "close_ticket",
  description: "Close a support ticket",
  inputSchema: { id: z.string(), how: z.enum(["respond", "return"]) },
  outputSchema: { id: z.string(), status: z.enum(["open", "closed"]) },
})
export class CloseTicket extends ToolContext {
  async execute({ id, how }: { id: string; how: "respond" | "return" }) {
    const result = { id, status: "done" as "closed" }; // 🚩 not a status the schema allows
    if (how === "respond") {
      this.respond({ content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result });
    }
    return result;
  }
}
```

```ts audit.plugin.ts
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";

export const audited: unknown[] = [];

@Plugin({ name: "audit", description: "Records every tool's output" })
export class AuditPlugin extends DynamicPlugin<object> {
  @ToolHook.Did("execute")
  async record(ctx: FlowCtxOf<"tools:call-tool">) {
    audited.push(ctx.state.toolContext?.output);
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { AuditPlugin } from "./audit.plugin";
import { CloseTicket } from "./close-ticket.tool";

@App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket], plugins: [AuditPlugin] })
export class HelpDesk {}
```

```ts output.test.ts
import { test, expect } from "@frontmcp/testing";
import { audited } from "./audit.plugin";

test("🚩 respond(): sent unchecked, and the hook sees the whole result", async ({ mcp }) => {
  audited.length = 0;
  const result = await mcp.tools.call("close_ticket", { id: "T-1", how: "respond" });
  expect(result.raw.structuredContent).toEqual({ id: "T-1", status: "done" });
  expect(audited[0]).toMatchObject({ content: expect.any(Array), structuredContent: { id: "T-1", status: "done" } });
});

test("✅ return: checked against outputSchema", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1", how: "return" });
  expect(result).toBeError("INVALID_OUTPUT");
});
```

Return the value to have it checked. A hook that reads `toolContext.output` gets the whole result after `this.respond()`, and the output value after a `return`: check for `content` if it has to handle both.
