# this.callTool

> Call another tool of the same server from inside a tool, resource, prompt, agent or job, as the same caller, and handle what it returns or throws.

Source: https://frontmcp.dev/reference/sdk/call-tool

`this.callTool()` runs another tool of your server from inside a call, the way a client's `tools/call` would: the arguments are validated, `authorities` are checked, hooks run, and the result is formatted and checked against `outputSchema`. It runs as the same caller, in the same request, and resolves to the tool's result. Use it to build one tool out of others, to start a job from a tool, or to have one [agent hand a task to another](https://frontmcp.dev/learn/agents-that-call-agents). When the tool fails, `this.callTool()` throws instead of returning an error result.

```ts
const result = await this.callTool(name, args?, opts?)
```

---

## Reference

### `this.callTool(name, args?, opts?)`

Call `this.callTool()` inside the `execute()` of a tool, a resource, a prompt, an agent or a job.

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

@Tool({ name: "close_ticket", description: "Close a ticket and tell the customer", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = await closeTicket(id);
    const sent = await this.callTool("email_customer", { to: ticket.customer, subject: `${id} is closed` });
    return { id, status: "closed", email: sent.structuredContent };
  }
}
```

[See more examples below.](#usage)

#### Parameters

| Parameter | Type | Description |
| --- | --- | --- |
| `name` | `string` | The tool's name, as in `tools/list`. Tools of every app on the server can be called, including [`hidden` and `internal`](#calling-a-tool-clients-cant-call) ones. `"<appId>:<name>"` and `"<appId>.<name>"` work too, and so does the name with `-` for `_`, like `get-ticket`. A client's `tools/call` finds the same forms. (Changed in 1.9.2: before, the dotted form answered `not found`. Changed in 1.9.3: before, a client's `tools/call` answered `TOOL_NOT_FOUND` for it.) |
| `args` | `Record<string, unknown>` | Optional, `{}` by default. The arguments, validated against the tool's `inputSchema` as a client's would be: defaults are filled in and unknown fields dropped. |
| `opts.signal` | `AbortSignal` | Optional. Becomes the called tool's [`this.signal`](https://frontmcp.dev/reference/sdk/contexts#thissignal), so aborting it stops a tool that listens to it. |
| `opts.progressToken` | `string \| number` | Optional. Sent as the inner call's `_meta.progressToken`. Clients on MCP versions before 2026-07-28 need it for the called tool's progress to reach them. Under 2026-07-28 you don't: the called tool's `this.progress()` and `this.notify()` already [go to the client of the outer call](#progress-from-the-called-tool). |

#### Returns

A promise of the tool's `CallToolResult`, the same object a client would get: `content`, and `structuredContent` when the tool returns an object, built as [`@Tool`](https://frontmcp.dev/reference/sdk/tool#returning-results) describes. Read the value from `structuredContent`.

It never resolves to an error result: every failure [throws](#throws).

#### Throws

| When the called tool | `this.callTool()` throws | If you don't catch it, the client gets |
| --- | --- | --- |
| Fails with `this.fail(error)` | A `FlowControl` whose `message` is `""`, with your error as `originalError` | The called tool's error, as if the calling tool had failed with it |
| Throws a `PublicMcpError` | That error | That error: its message and its code |
| Throws any other error | `ToolExecutionError`: `Tool "email_customer" execution failed: <message>`, code `TOOL_EXECUTION_ERROR` | `Tool "close_ticket" execution failed: Tool "email_customer" execution failed: <message>` in development, "Internal FrontMCP error" in production |
| Doesn't exist | `ToolNotFoundError`: `Tool "email_customer" not found`, code `TOOL_NOT_FOUND` | That message and code |
| Gets arguments that don't match its `inputSchema` | `InvalidInputError`: "Invalid tool input", code `INVALID_INPUT`, with `validationErrors` | "Invalid tool input", then the details, code `INVALID_INPUT` |
| Returns a value that doesn't match its `outputSchema` | `InvalidOutputError`: "Tool output validation failed (…)", code `INVALID_OUTPUT` | That message and code, as if the calling tool had returned it. In production the message is hidden. (Changed in 1.8.7: before, it came after `Tool "close_ticket" execution failed:`, with code `TOOL_EXECUTION_ERROR`.) |
| Is refused by its `authorities` | `AuthorityDeniedError`: `Access denied to Tool "desk:admin_only": …`, code `AUTHORITY_DENIED` | That message and code |
| Is over its `rateLimit` | `RateLimitError`: "Rate limit exceeded. Retry after N seconds", code `RATE_LIMIT_EXCEEDED` | That message and code |

`FlowControl` and the [error classes](https://frontmcp.dev/reference/sdk/error-classes) are exported from `@frontmcp/sdk`, except `AuthorityDeniedError`: check its `code` instead. See [Handling a failure](#handling-a-failure-from-the-called-tool).

#### What the called tool sees

- **The same caller.** `this.auth` and `this.context.authInfo` are the calling tool's, so `authorities` are checked against the person who made the outer call.
- **The same request.** `this.context` is the same object: the same `requestId` and trace, and values you [`set()`](https://frontmcp.dev/reference/sdk/context#passing-a-value-from-a-hook-to-the-tool) in the calling tool can be read in the called one.
- **Its own call otherwise.** Its `this.signal`, its input and output, its hooks (a plugin's `Will("execute")` runs for it too), and its [`rateLimit`](#rate-limit-exceeded-from-a-tool-the-client-didnt-call), which counts the inner call like any other. `globalConcurrency` doesn't count inner calls: see [Guard options](https://frontmcp.dev/reference/sdk/guard).

#### Where it works

| In | `this.callTool()` |
| --- | --- |
| Tools, resources | Works. |
| Agents | Works in the agent's own `execute()`, including another agent's `invoke_<name>`, on the `"agent"` surface: a tool whose `availableWhen.surface` leaves out `"agent"` answers `Tool "…" not found`. In a tool listed in `@Agent({ tools })`, only the agent's own tools can be found. See [`@Agent`](https://frontmcp.dev/reference/sdk/agent). |
| Jobs | Works, as the caller who started the run, on the `"job"` surface, so the same goes for a tool whose `surface` leaves out `"job"`. |
| Channels | Works, and the tool it calls sees an anonymous caller (`anon:…`), though the channel's own `this.auth` is empty: see [Context classes](https://frontmcp.dev/reference/sdk/contexts#where-there-is-no-request-context). While `onEvent()` handles a [webhook](https://frontmcp.dev/reference/sdk/channel#sources), on the `"http-trigger"` surface. |
| Prompts | Works, as the prompt's caller. (New in 1.9.2: before, `PromptContext` didn't have it.) |

#### Caveats

- An inner call's input error reaches the model as "Invalid tool input" with the inner tool's details, although the arguments the model sent were fine. When you build the arguments from the model's input, catch the error and fail with a message about what the model sent.
- The called tool doesn't appear anywhere in the client's view: the client only sees the outer call, its notifications and its result.

---

## Usage

### Building a tool out of other tools

`close_ticket` closes the ticket, then calls `email_customer`. The email tool is `internal`, so clients can't call it or see it, but other tools can.

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

const tickets = new Map([
  ["T-1", { id: "T-1", customer: "dana@acme.example", status: "open" }],
  ["T-2", { id: "T-2", customer: "lee@globex.example", status: "open" }],
]);

@Tool({ name: "close_ticket", description: "Close a support ticket and email the customer", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id)!;
    ticket.status = "closed";
    const sent = await this.callTool("email_customer", { to: ticket.customer, subject: `Your ticket ${id} is closed` });
    return { id, status: ticket.status, email: sent.structuredContent };
  }
}

@Tool({
  name: "email_customer",
  description: "Send an email to a customer",
  inputSchema: { to: z.string().email(), subject: z.string() },
  outputSchema: { messageId: z.string(), to: z.string() },
  visibility: "internal",
})
export class EmailCustomer extends ToolContext {
  async execute({ to }: { to: string; subject: string }) {
    return { messageId: "msg-1042", to };
  }
}
```

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

test("close_ticket includes the email tool's result", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result.json()).toEqual({ id: "T-1", status: "closed", email: { messageId: "msg-1042", to: "dana@acme.example" } });
});

test("clients can't call the email tool themselves", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["close_ticket"]);
  const result = await mcp.tools.call("email_customer", { to: "x@y.example", subject: "hi" });
  expect(result.raw._meta?.code).toBe("TOOL_NOT_FOUND");
});
```

`sent` is the email tool's whole `CallToolResult`. Its value is in `structuredContent`, already checked against the email tool's `outputSchema`.

### Handling a failure from the called tool

If you don't catch it, a failure in the called tool ends the outer call with the same error. That's often right. When the outer tool can do something better, catch it. A tool that failed with `this.fail()` arrives as a `FlowControl`, with the error it failed with in `originalError`:

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

const tickets = new Map([
  ["T-1", { id: "T-1", customer: "dana@acme.example", status: "open" }],
  ["T-2", { id: "T-2", customer: "lee@globex.example", status: "open" }],
  ["T-3", { id: "T-3", customer: "kim@initech.example", status: "open" }],
  ["T-4", { id: "T-4", customer: "ada@umbrella.example", status: "open" }],
]);

/** The error the called tool failed with. */
function reason(err: unknown) {
  const error = err instanceof FlowControl ? (err as FlowControl & { originalError?: unknown }).originalError : err;
  return error as { message?: string; code?: string };
}

@Tool({ name: "close_ticket", description: "Close a support ticket and email the customer", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id)!;
    ticket.status = "closed";
    try {
      await this.callTool("email_customer", { to: ticket.customer, subject: `Your ticket ${id} is closed` });
      return { id, status: ticket.status, emailed: true };
    } catch (err) {
      // The ticket is closed either way; tell the model the email didn't go out, and why.
      return { id, status: ticket.status, emailed: false, why: reason(err).message };
    }
  }
}

@Tool({ name: "email_customer", description: "Send an email to a customer", inputSchema: { to: z.string(), subject: z.string() }, visibility: "internal" })
export class EmailCustomer extends ToolContext {
  async execute({ to }: { to: string; subject: string }) {
    if (to.endsWith("@globex.example")) this.fail(new PublicMcpError("globex.example is refusing our mail.", "MAIL_REFUSED"));
    if (to.endsWith("@initech.example")) throw new Error("SMTP timeout");
    if (to.endsWith("@umbrella.example")) throw new PublicMcpError("umbrella.example has no mailbox for ada.", "NO_MAILBOX");
    return { to };
  }
}

@Tool({ name: "close_ticket_strict", description: "Close a ticket; fail if the customer can't be emailed", inputSchema: { id: z.string() } })
export class CloseTicketStrict extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id)!;
    await this.callTool("email_customer", { to: ticket.customer, subject: `Your ticket ${id} is closed` });
    ticket.status = "closed";
    return { id, status: ticket.status };
  }
}
```

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

test("a caught failure: the outer tool decides what to answer", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-2" });
  expect(result.json()).toEqual({ id: "T-2", status: "closed", emailed: false, why: "globex.example is refusing our mail." });
});

test("a thrown public error arrives as it is", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-4" });
  expect(result.json().why).toBe("umbrella.example has no mailbox for ada.");
});

test("a thrown error that isn't public arrives wrapped", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-3" });
  expect(result.json().why).toBe('Tool "email_customer" execution failed: SMTP timeout');
});

test("an uncaught failure: the client gets the called tool's error", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket_strict", { id: "T-2" });
  expect(result).toBeError("MAIL_REFUSED");
  expect(result.text()).toBe("globex.example is refusing our mail.");
});
```

Errors that the called tool throws, rather than passes to `this.fail()`, arrive as they are, or as a `ToolExecutionError` when they aren't public. `reason()` handles both.

### Calling a tool clients can't call

`visibility: "hidden"` leaves a tool out of `tools/list`, and `"internal"` also refuses calls from clients. So does an [`availableWhen`](https://frontmcp.dev/reference/server/environment) whose `surface` leaves out `"mcp"`: MCP clients neither see nor call the tool. From a tool or a resource, `this.callTool()` reaches all three, because it runs the called tool in-process, with no surface. That makes such tools a place for helpers that several tools share but the model shouldn't use directly. An agent's or a job's `this.callTool()` is different: it calls on the `"agent"` or `"job"` surface, so a tool whose `surface` leaves that caller out isn't found. `authorities` still apply, to the same caller:

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

@Tool({ name: "weekly_digest", description: "Summarize this week's tickets", inputSchema: {} })
export class WeeklyDigest extends ToolContext {
  async execute() {
    const counts = await this.callTool("count_tickets", {});
    const escalations = await this.callTool("count_escalations", {});
    return {
      week: "2026-W39",
      ...(counts.structuredContent as { open: number; closed: number }),
      ...(escalations.structuredContent as { escalated: number }),
    };
  }
}

@Tool({ name: "count_tickets", description: "Count tickets by status", inputSchema: {}, visibility: "internal" })
export class CountTickets extends ToolContext {
  async execute() {
    return { open: 12, closed: 31 };
  }
}

@Tool({ name: "count_escalations", description: "Count escalated tickets", inputSchema: {}, availableWhen: { surface: ["agent"] } })
export class CountEscalations extends ToolContext {
  async execute() {
    return { escalated: 2 };
  }
}

@Tool({ name: "purge_closed", description: "Delete closed tickets", inputSchema: {}, visibility: "internal", authorities: "admin" })
export class PurgeClosed extends ToolContext {
  async execute() {
    return { purged: 31 };
  }
}

@Tool({ name: "weekly_cleanup", description: "Purge closed tickets, for admins", inputSchema: {} })
export class WeeklyCleanup extends ToolContext {
  async execute() {
    return (await this.callTool("purge_closed", {})).structuredContent as { purged: number };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { CountEscalations, CountTickets, PurgeClosed, WeeklyCleanup, WeeklyDigest } from "./digest.tools";

@App({ id: "desk", name: "Help Desk", tools: [WeeklyDigest, CountTickets, CountEscalations, PurgeClosed, WeeklyCleanup] })
class Desk {}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [Desk],
  authorities: { claimsMapping: { roles: "roles" }, profiles: { admin: { roles: { any: ["admin"] } } } },
};

@FrontMcp(config)
export default class Server {}
```

```ts internal.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Job, JobContext, z } from "@frontmcp/sdk";
import { CountEscalations } from "./digest.tools";
import { config } from "./main";

test("tools clients can't call, called by another tool", async ({ mcp }) => {
  expect((await mcp.tools.call("weekly_digest", {})).json()).toEqual({ week: "2026-W39", open: 12, closed: 31, escalated: 2 });
});

test("clients neither see nor call them", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((tool) => tool.name);
  expect(names).not.toContain("count_tickets");
  expect(names).not.toContain("count_escalations");
  expect(await mcp.tools.call("count_tickets", {})).toBeError("TOOL_NOT_FOUND");
  expect(await mcp.tools.call("desk.count_tickets", {})).toBeError("TOOL_NOT_FOUND");
  expect(await mcp.tools.call("count_escalations", {})).toBeError("TOOL_NOT_FOUND");
});

test("the called tool's authorities are checked against the same caller", async ({ mcp }) => {
  const result = await mcp.tools.call("weekly_cleanup", {});
  expect(result).toBeError("AUTHORITY_DENIED");
  expect(result.text()).toBe(`Access denied to Tool "desk:purge_closed": profile:admin: roles.any: user has none of 'admin'`);
});

test("called by an admin, it works", async () => {
  // createDirect() runs the server in-process, as the user you name.
  const server = await FrontMcpInstance.createDirect(config);
  const result = await server.callTool("weekly_cleanup", {}, { authContext: { user: { sub: "nour", roles: ["admin"] } } });
  await server.dispose();
  expect(result.structuredContent).toEqual({ purged: 31 });
});

test("🚩 a job calls on the `job` surface, so it can't reach an agent-only tool", async () => {
  @Job({ name: "nightly-digest", inputSchema: {}, outputSchema: { escalated: z.number() } })
  class NightlyDigest extends JobContext {
    async execute() {
      return (await this.callTool("count_escalations", {})).structuredContent as { escalated: number };
    }
  }
  @App({ id: "desk", name: "Help Desk", tools: [CountEscalations], jobs: [NightlyDigest] })
  class Desk {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] });
  await expect(server.callTool("execute_job", { name: "nightly-digest", input: {} })).rejects.toThrow('Tool "count_escalations" not found');
  await server.dispose();
});
```

The Playground's caller is anonymous, with no roles, so `weekly_cleanup` is refused on its way to `purge_closed`. The last test calls it as an admin, and it works.

### Progress from the called tool

Under MCP 2026-07-28, the called tool's progress and log notifications reach the client of the outer call, on the same stream, with the outer call's `progressToken`. The client never learns another tool was involved:

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

@Tool({ name: "export_all", description: "Export every ticket and every customer", inputSchema: {} })
export class ExportAll extends ToolContext {
  async execute() {
    const tickets = await this.callTool("export_tickets", {});
    await this.progress(2, 2, "Exported customers");
    return { files: ["tickets.csv", "customers.csv"], tickets: tickets.structuredContent };
  }
}

@Tool({ name: "export_tickets", description: "Export every ticket", inputSchema: {}, visibility: "internal" })
export class ExportTickets extends ToolContext {
  async execute() {
    await this.progress(1, 2, "Exported tickets");
    return { rows: 43 };
  }
}
```

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

test("both tools' progress reaches the client", async ({ mcp }) => {
  await mcp.tools.call("export_all", {});
  const updates = mcp.notifications.collectProgress().all;
  expect(updates.map((u) => u.message)).toEqual(["Exported tickets", "Exported customers"]);
});
```

### Giving the called tool less time

`opts.signal` becomes the called tool's `this.signal`. Pass `AbortSignal.timeout()` to give an inner call a time budget of its own, shorter than the outer call's, and answer without it when it runs out. It only stops a tool that listens to its signal:

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

@Tool({ name: "get_ticket", description: "Get a support ticket, with its history when it's quick to load", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = { id, title: "Cannot log in" };
    try {
      const history = await this.callTool("load_history", { id }, { signal: AbortSignal.timeout(100) });
      return { ...ticket, history: history.structuredContent };
    } catch {
      return { ...ticket, history: null, note: "The history took too long to load." };
    }
  }
}

@Tool({ name: "load_history", description: "Load a ticket's history from the archive", inputSchema: { id: z.string() }, visibility: "internal" })
export class LoadHistory extends ToolContext {
  async execute({ id }: { id: string }) {
    // Stands in for a slow archive query that stops when its signal is aborted.
    await new Promise<void>((resolve, reject) => {
      const timer = setTimeout(resolve, 2000);
      this.signal?.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(this.signal?.reason);
      });
    });
    return { id, events: 12 };
  }
}
```

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

test("the ticket comes back without the history, after 100 ms", async ({ mcp }) => {
  const started = Date.now();
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result.json()).toEqual({ id: "T-1", title: "Cannot log in", history: null, note: "The history took too long to load." });
  expect(Date.now() - started).toBeLessThan(1000);
});
```

### Starting a job from a tool

Jobs run through the `execute_job` tool, so a tool can start one with `this.callTool()`. With `background: true` the call returns at once with the run's id:

```ts export.ts active
import { Job, JobContext, Tool, ToolContext, z } from "@frontmcp/sdk";

@Job({ name: "export-tickets", description: "Export a customer's tickets", inputSchema: { customer: z.string() }, outputSchema: { rows: z.number() } })
export class ExportTicketsJob extends JobContext {
  async execute({ customer }: { customer: string }) {
    this.log(`Exporting ${customer}`);
    return { rows: 43 };
  }
}

@Tool({ name: "start_export", description: "Start exporting a customer's tickets in the background", inputSchema: { customer: z.string() } })
export class StartExport extends ToolContext {
  async execute({ customer }: { customer: string }) {
    const run = await this.callTool("execute_job", { name: "export-tickets", input: { customer }, background: true });
    const { runId, state } = run.structuredContent as { runId: string; state: string };
    return { customer, runId, state };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { ExportTicketsJob, StartExport } from "./export";

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

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

test("the tool returns the run's id", async ({ mcp }) => {
  const result = await mcp.tools.call("start_export", { customer: "acme" });
  expect(result.json()).toEqual({ customer: "acme", runId: expect.any(String), state: "pending" });
});
```

[Nightly Ticket Report](https://frontmcp.dev/examples/nightly-ticket-report) starts a workflow the same way, with `execute_workflow`.

### Calling a tool from a resource, a prompt or an agent

`ResourceContext`, `PromptContext` and `AgentContext` have the same `this.callTool()`. An agent can call a tool before or after its model loop, or hand the whole task to another agent's `invoke_<name>`, as in [Agents That Call Agents](https://frontmcp.dev/learn/agents-that-call-agents).

<Examples title="Other callers">

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

@Tool({ name: "get_ticket", description: "Get one support ticket by id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}

@ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json" })
export class Ticket extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    const result = await this.callTool("get_ticket", { id });
    return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(result.structuredContent) }] };
  }
}
```

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

test("the resource serves the tool's result", async ({ mcp }) => {
  const text = (await mcp.resources.read("tickets://T-1")).text()!;
  expect(JSON.parse(text)).toEqual({ id: "T-1", title: "Cannot log in", status: "open" });
});
```

#### Example: Prompt
```ts handover.prompt.ts active
import { Prompt, PromptContext, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "get_ticket", description: "Get one support ticket by id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}

@Prompt({ name: "handover_note", description: "Write a handover note for a ticket", arguments: [{ name: "id", required: true }] })
export class HandoverNote extends PromptContext {
  async execute({ id }: Record<string, string>) {
    const ticket = (await this.callTool("get_ticket", { id })).structuredContent as { title: string; status: string };
    return `Write a handover note for ticket ${id}, "${ticket.title}", which is ${ticket.status}.`;
  }
}
```

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

test("the prompt reads the ticket through the tool", async ({ mcp }) => {
  const result = await mcp.prompts.get("handover_note", { id: "T-1" });
  expect(result.messages[0].content.text).toBe('Write a handover note for ticket T-1, "Cannot log in", which is open.');
});
```

#### 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 support ticket",
  systemInstructions: "You triage tickets for a help desk. Answer high, normal or low, and say why in one sentence.",
  inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") },
  llm: { adapter: model },
})
export class Triage extends AgentContext {
  async execute(input: { ticketId: string }) {
    const ticket = await this.callTool("get_ticket", { id: input.ticketId }); // before the loop
    const answer = (await super.execute(input)) as { response: string };
    return { ...answer, ticket: ticket.structuredContent };
  }
}
```

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

@Tool({ name: "get_ticket", description: "Get one support ticket by id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}
```

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

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], 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: "High: the customer can't work.", finishReason: "stop" };
  },
};
```

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

test("the agent adds the ticket it read to its answer", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(result.json()).toEqual({
    response: "High: the customer can't work.",
    ticket: { id: "T-1", title: "Cannot log in", status: "open" },
  });
});
```

---

## Troubleshooting

### `Tool "…" not found`

No tool the caller can reach has that name. `this.callTool()` finds a tool by its name, by `"<appId>:<name>"` or `"<appId>.<name>"`, and with `-` in place of `_`, and so does a client's `tools/call`. A name with another app's id isn't found:

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

@Tool({ name: "get_ticket", description: "Get one support ticket by id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "open" };
  }
}

@Tool({ name: "try_names", description: "Call get_ticket by each form of its name", inputSchema: {} })
export class TryNames extends ToolContext {
  async execute() {
    const found: Record<string, string> = {};
    for (const name of ["get_ticket", "desk:get_ticket", "desk.get_ticket", "get-ticket", "billing:get_ticket"]) {
      try {
        await this.callTool(name, { id: "T-1" });
        found[name] = "found";
      } catch (err) {
        found[name] = (err as Error).message;
      }
    }
    return found;
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket, TryNames } from "./tickets.tools";

@App({ id: "desk", name: "Help Desk", tools: [GetTicket, TryNames] })
export class Desk {}
```

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

test("the name, appId:name, appId.name and the dashed name work", async ({ mcp }) => {
  expect((await mcp.tools.call("try_names", {})).json()).toEqual({
    get_ticket: "found",
    "desk:get_ticket": "found",
    "desk.get_ticket": "found",
    "get-ticket": "found",
    "billing:get_ticket": 'Tool "billing:get_ticket" not found',
  });
});

test("a client's tools/call finds the same names", async ({ mcp }) => {
  for (const name of ["desk:get_ticket", "desk.get_ticket"]) {
    expect((await mcp.tools.call(name, { id: "T-1" })).json()).toEqual({ id: "T-1", status: "open" });
  }
  expect(await mcp.tools.call("billing.get_ticket", { id: "T-1" })).toBeError("TOOL_NOT_FOUND");
});
```

Check the spelling against the tool's `name` option: hidden and internal tools aren't in `tools/list`, but `this.callTool()` finds them by name. A client's call finds a hidden tool, by any of these names, but never an internal one. From a tool that belongs to an agent (in `@Agent({ tools })`), only that agent's own tools can be found: call the other tool from the agent's `execute()` instead.

### The caught error's `message` is empty

The called tool failed with `this.fail()`, and what you caught is the `FlowControl` that carries its error. Read `originalError`, as `reason()` does in [Handling a failure](#handling-a-failure-from-the-called-tool). The other failures arrive as errors of their own, with a `message` and a `code`:

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

@Tool({ name: "get_ticket", description: "Get one support ticket by id", inputSchema: { id: z.string() }, outputSchema: { id: z.string(), status: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (id === "T-9") this.fail(new PublicMcpError(`There's no ticket ${id}.`, "NO_TICKET"));
    if (id === "T-0") return { id, status: 0 } as unknown as { id: string; status: string }; // doesn't match outputSchema
    return { id, status: "open" };
  }
}

@Tool({ name: "relay_ticket", description: "Read a ticket through get_ticket, and pass on whatever fails", inputSchema: { id: z.string() } })
export class RelayTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return (await this.callTool("get_ticket", { id })).structuredContent; // not caught
  }
}

@Tool({ name: "try_failures", description: "Show how each kind of failure arrives", inputSchema: {} })
export class TryFailures extends ToolContext {
  async execute() {
    const attempts: [string, Record<string, unknown>][] = [
      ["get_ticket", { id: "T-9" }], // fails with this.fail()
      ["get_ticket", { id: "T-0" }], // bad output
      ["get_ticket", { ticket: "T-1" }], // bad arguments
      ["get_tikcet", { id: "T-1" }], // no such tool
    ];
    const caught = [];
    for (const [name, args] of attempts) {
      try {
        await this.callTool(name, args);
      } catch (err) {
        const original = err instanceof FlowControl ? (err as FlowControl & { originalError?: PublicMcpError }).originalError : undefined;
        const { message, code } = err as Error & { code?: string };
        caught.push({ isFlowControl: err instanceof FlowControl, message, code: code ?? null, original: original ? { message: original.message, code: original.code } : null });
      }
    }
    return { caught };
  }
}
```

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

test("how each failure arrives", async ({ mcp }) => {
  const { caught } = (await mcp.tools.call("try_failures", {})).json();
  expect(caught).toEqual([
    { isFlowControl: true, message: "", code: null, original: { message: "There's no ticket T-9.", code: "NO_TICKET" } },
    { isFlowControl: false, message: "Tool output validation failed (output does not match outputSchema at status)", code: "INVALID_OUTPUT", original: null },
    { isFlowControl: false, message: "Invalid tool input", code: "INVALID_INPUT", original: null },
    { isFlowControl: false, message: 'Tool "get_tikcet" not found', code: "TOOL_NOT_FOUND", original: null },
  ]);
});

test("uncaught, an invalid output reaches the client as it is", async ({ mcp }) => {
  const result = await mcp.tools.call("relay_ticket", { id: "T-0" });
  expect(result).toBeError("INVALID_OUTPUT");
  expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at status)");
});
```

### `Rate limit exceeded` from a tool the client didn't call

The called tool has a `rateLimit` or `concurrency` limit, and calls through `this.callTool()` count toward it like calls from clients:

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

@Tool({
  name: "lookup_customer",
  description: "Look a customer up in the CRM",
  inputSchema: { id: z.string() },
  rateLimit: { maxRequests: 1, windowMs: 60_000 },
})
export class LookupCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, name: "Acme Corp" };
  }
}

@Tool({ name: "ticket_summary", description: "Summarize a ticket with its customer", inputSchema: { id: z.string() } })
export class TicketSummary extends ToolContext {
  async execute({ id }: { id: string }) {
    const customer = await this.callTool("lookup_customer", { id: "C-7" });
    return { id, customer: customer.structuredContent };
  }
}
```

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

test("the second summary is refused by lookup_customer's limit", async ({ mcp }) => {
  expect(await mcp.tools.call("ticket_summary", { id: "T-1" })).toBeSuccessful();
  const second = await mcp.tools.call("ticket_summary", { id: "T-2" });
  expect(second).toBeError("RATE_LIMIT_EXCEEDED");
  expect(second.text()).toMatch(/^Rate limit exceeded\. Retry after \d+ seconds$/);
});
```

Raise the limit, or catch the `RateLimitError` in the outer tool and answer without the inner call. The limits and their keys are in [Guard options](https://frontmcp.dev/reference/sdk/guard).

### `Access denied to Tool "desk:purge_closed"`

The called tool has `authorities`, and they're checked against the caller of the outer tool, who doesn't pass them. `this.callTool()` doesn't run with more rights than the caller has. Give the outer tool the same `authorities`, so callers who can't use it don't see it, or check `this.auth` before calling. See [Calling a tool clients can't call](#calling-a-tool-clients-cant-call).

### The model gets "Invalid tool input" for arguments that were fine

The outer tool built arguments that the called tool's `inputSchema` rejects, and the error went to the client as it was, so the model reads it as its own mistake:

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

@Tool({ name: "reassign_ticket", description: "Move a ticket to another agent", inputSchema: { id: z.string(), to: z.string() } })
export class ReassignTicket extends ToolContext {
  async execute({ id, to }: { id: string; to: string }) {
    // 🚩 set_assignee wants a lowercase agent id, and gets the name the model wrote
    return (await this.callTool("set_assignee", { id, agent: to })).structuredContent as { id: string; agent: string };
  }
}

@Tool({
  name: "set_assignee",
  description: "Set a ticket's assignee",
  inputSchema: { id: z.string(), agent: z.string().regex(/^[a-z]+$/) },
  visibility: "internal",
})
export class SetAssignee extends ToolContext {
  async execute({ id, agent }: { id: string; agent: string }) {
    return { id, agent };
  }
}
```

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

test("🚩 the inner call's input error reaches the model", async ({ mcp }) => {
  const result = await mcp.tools.call("reassign_ticket", { id: "T-1", to: "Sam" });
  expect(result).toBeError("INVALID_INPUT");
  expect(result.text()).toContain("Invalid tool input");
});
```

Check the arguments the model sent against what the inner tool needs, in the outer tool's `inputSchema` or in `execute()` before calling, and fail with a message about the outer tool's own input. Here, `to: z.string().regex(/^[a-z]+$/).describe("Agent id, like sam")` would let the model fix its call.
