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.

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.

Parameters

ParameterTypeDescription
namestringThe 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.)
argsRecord<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.signalAbortSignalOptional. Becomes the called tool's this.signal, so aborting it stops a tool that listens to it.
opts.progressTokenstring | numberOptional. 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 toolthis.callTool() throwsIf you don't catch it, the client gets
Fails with this.fail(error)A FlowControl whose message is "", with your error as originalErrorThe called tool's error, as if the calling tool had failed with it
Throws a PublicMcpErrorThat errorThat error: its message and its code
Throws any other errorToolExecutionError: Tool "email_customer" execution failed: <message>, code TOOL_EXECUTION_ERRORTool "close_ticket" execution failed: Tool "email_customer" execution failed: <message> in development, "Internal FrontMCP error" in production
Doesn't existToolNotFoundError: Tool "email_customer" not found, code TOOL_NOT_FOUNDThat message and code
Gets arguments that don't match its inputSchemaInvalidInputError: "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 outputSchemaInvalidOutputError: "Tool output validation failed (…)", code INVALID_OUTPUTThat 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 authoritiesAuthorityDeniedError: Access denied to Tool "desk:admin_only": …, code AUTHORITY_DENIEDThat message and code
Is over its rateLimitRateLimitError: "Rate limit exceeded. Retry after N seconds", code RATE_LIMIT_EXCEEDEDThat 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.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() 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, which counts the inner call like any other. globalConcurrency doesn't count inner calls: see Guard options.

Where it works

Inthis.callTool()
Tools, resourcesWorks.
AgentsWorks 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.
JobsWorks, as the caller who started the run, on the "job" surface, so the same goes for a tool whose surface leaves out "job".
ChannelsWorks, 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.
PromptsWorks, 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.

Open
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:

Open
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:

Open
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:

Open
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:

Open
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:

Open
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

Open
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:

Open
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:

Open
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:

Open
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:

Open
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.