this.callTool
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. When the tool fails, this.callTool() throws instead of returning an error result.
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.
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 };
}
}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 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, 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. |
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 describes. Read the value from structuredContent.
It never resolves to an error result: every failure 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 are exported from @frontmcp/sdk, except AuthorityDeniedError: check its code instead. See Handling a failure.
What the called tool sees
- The same caller.
this.authandthis.context.authInfoare the calling tool's, soauthoritiesare checked against the person who made the outer call. - The same request.
this.contextis the same object: the samerequestIdand trace, and values youset()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'sWill("execute")runs for it too), and itsrateLimit, which counts the inner call like any other.globalConcurrencydoesn't count inner calls: see Guard options.
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. |
| 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. While onEvent() handles a webhook, 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.
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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 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:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
Other callers
Example 1 of 3
Resource
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) }] };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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;
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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. The other failures arrive as errors of their own, with a message and a code:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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.
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:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.