Hooking into Calls

Intermediate

Every tools/call that FrontMCP receives runs through a flow: a fixed list of named stages, from finding the tool to formatting its result. A hook is a method that runs just before or just after one of those stages, on every call. The previous lesson used one to fill an audit log. This one shows the stages, what a hook can read and change at each of them, and the order hooks run in.

You will learn

  • Which stages a tool call goes through
  • How Will, Did and Around hooks run around a stage, and what they can read
  • How to use hooks to log calls, normalize input, block calls and change results
  • Which order hooks run in

The stages of a tool call

This plugin traces three stages of every call: it records a line just before (Will) and just after (Did) each one. Call get_ticket, then show_trace:

Open
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk";

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

@Provider({ name: "Trace" })
export class Trace {
  lines: string[] = [];
}

@Tool({ name: "show_trace", description: "Show the stages traced since the last call to show_trace", inputSchema: {} })
export class ShowTrace extends ToolContext {
  async execute() {
    return { trace: this.get(Trace).lines.splice(0) };
  }
}

@Plugin({ name: "trace", providers: [Trace], tools: [ShowTrace] })
export class TracePlugin extends DynamicPlugin<object> {
  private note(ctx: CallCtx, line: string) {
    if (ctx.state.tool?.metadata.name !== "show_trace") this.get(Trace).lines.push(line);
  }

  @ToolHook.Will("validateInput")
  async willValidate(ctx: CallCtx) {
    this.note(ctx, "will validateInput");
  }

  @ToolHook.Did("validateInput")
  async didValidate(ctx: CallCtx) {
    this.note(ctx, "did validateInput");
  }

  @ToolHook.Will("execute")
  async willExecute(ctx: CallCtx) {
    this.note(ctx, "will execute");
  }

  @ToolHook.Did("execute")
  async didExecute(ctx: CallCtx) {
    this.note(ctx, "did execute");
  }

  @ToolHook.Will("finalize")
  async willFinalize(ctx: CallCtx) {
    this.note(ctx, "will finalize");
  }

  @ToolHook.Did("finalize")
  async didFinalize(ctx: CallCtx) {
    this.note(ctx, "did finalize");
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The Tests tab shows two calls. The first passes every stage, and each stage's Will hook runs before it and its Did hook after it. The second has an id that doesn't match the pattern, so validateInput fails: its Did hook doesn't run, execute is skipped entirely, and the flow jumps to its closing stages, which run whether the call worked or not.

These are the stages of tools:call-tool, the flow behind tools/call, in order:

GroupStagesRuns
BeforeparseInput, ensureRemoteCapabilities, findTool, checkPublicAccess, checkToolAuthorization, checkEntryAuthorities, createTaskIfRequested, createToolCallContext, checkToolCredentials, acquireQuota, acquireSemaphoreUntil one fails
ExecutevalidateInput, execute, validateOutputUntil one fails
FinallyreleaseSemaphore, releaseQuota, applyUI, finalizeAlways

Most hooks need only a few of them: validateInput (the arguments are checked against inputSchema), execute (your tool's execute() runs), and finalize (the result is put together for the client).

The hooks in this lesson are on plugins, so they run for every tool. A tool class can also declare hooks for its own calls, static ones included for the stages before its instance exists, like parseInput: see Hook decorators.

What a hook can read

A hook method gets one argument, ctx, which describes the call in progress. Type it as FlowCtxOf<"tools:call-tool">. What's in ctx.state depends on how far the call has got:

FieldWhat it isFrom
ctx.state.inputThe raw tools/call params: the tool name, the arguments as the client sent them, and _metaparseInput
ctx.state.toolThe tool being called. metadata holds everything from @Tool({ ... }), and owner.id is its app's idfindTool
ctx.state.toolContextThis call's tool instance: input (checked against the schema once validateInput is done), output (once execute is done) and get() for providerscreateToolCallContext

@frontmcp/sdk exports hook decorators for other flows too, with the same Will and Did: ListToolsHook for tools/list, ResourceHook for resources/read, ListResourcesHook, ListResourceTemplatesHook and HttpHook, among others. You'll use ListToolsHook below.

Logging every call

A log of every call with how long it took and whether it worked is a common first plugin. It needs code on both sides of execute, including when execute fails, and a Did hook doesn't run for a failed stage. That's what @ToolHook.Around(stage) is for. Its method gets a second argument, next, which runs the stage: code before await next() runs just before the stage, code after it just after, and if the stage fails, next() throws:

Open
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk";

type CallCtx = FlowCtxOf<"tools:call-tool">;
type Entry = { tool: string; outcome: "ok" | "failed"; ms: number };

@Provider({ name: "CallLog" })
export class CallLog {
  entries: Entry[] = [];
}

@Tool({
  name: "recent_calls",
  description: "Show recent tool calls, how long each took, and whether it worked",
  inputSchema: {},
  annotations: { readOnlyHint: true },
})
export class RecentCalls extends ToolContext {
  async execute() {
    return { calls: [...this.get(CallLog).entries] };
  }
}

@Plugin({ name: "call-log", providers: [CallLog], tools: [RecentCalls] })
export class CallLogPlugin extends DynamicPlugin<object> {
  @ToolHook.Around("execute")
  async time(ctx: CallCtx, next: () => Promise<void>) {
    const startedAt = Date.now();
    const record = (outcome: Entry["outcome"]) =>
      this.get(CallLog).entries.push({ tool: ctx.state.tool?.metadata.name ?? "?", outcome, ms: Date.now() - startedAt });

    try {
      await next(); // runs execute()
      record("ok");
    } catch (error) {
      record("failed");
      throw error; // the client still gets the tool's own error
    }
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Call get_ticket with T-9, then recent_calls, to see a failure logged. The hook rethrows what next() threw, so the client gets the same error it would have without the plugin. Take out throw error and call T-9 again: with the error swallowed, the call ends with no result at all, and FrontMCP answers with FLOW_EXITED_WITHOUT_OUTPUT. A call rejected before execute() never reaches the stage, so the hook doesn't run for it: the test's third call, with a malformed id, isn't logged. (recent_calls is a tool like any other, so it logs itself too: each call to it shows the one before.)

Normalizing input

Models are loose with ids. Asked about "ticket t-2", a model may well send " t-2 ", and the schema's pattern rejects it. One hook can clean up ticket ids for every tool, before FrontMCP checks them:

Open
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";

@Plugin({ name: "ticket-ids", description: "Accepts ticket ids in any case, with stray spaces" })
export class TicketIdsPlugin extends DynamicPlugin<object> {
  @ToolHook.Will("validateInput")
  async normalizeTicketId(ctx: FlowCtxOf<"tools:call-tool">) {
    const args = ctx.state.input?.arguments;
    if (typeof args?.id === "string") args.id = args.id.trim().toUpperCase();
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The hook changes ctx.state.input.arguments, the arguments as the client sent them, in Will("validateInput"): just before they're checked. So the schema still has the last word, and "ticket two" is still rejected. By Will("execute") it's too late for this: validation is over, a malformed id has already been turned away, and anything you change there skips the schema altogether.

For a single tool, you can do the same in its schema with z.string().trim().toUpperCase() (Schemas Are Contracts covers it). The hook's advantage is reach: it cleans up every tool's id, including tools added later or by other plugins.

Blocking a call

During a data migration, the help desk has to stay read-only. A hook in Will("execute") runs right before the tool, and can stop it by throwing. The same plugin hides the tools that would change something from tools/list, so the model doesn't try them in the first place:

Open
import { DynamicPlugin, FlowCtxOf, ListToolsHook, Plugin, PublicMcpError, ToolHook } from "@frontmcp/sdk";

type ReadOnlyOptions = { enabled: boolean };

@Plugin({ name: "read-only", description: "Turns off every tool that isn't marked read-only" })
export class ReadOnlyPlugin extends DynamicPlugin<ReadOnlyOptions> {
  constructor(private readonly options: ReadOnlyOptions = { enabled: false }) {
    super();
  }

  // Refuse calls, even from a client that never listed the tools
  @ToolHook.Will("execute", { filter: (ctx) => !ctx.state.tool?.metadata.annotations?.readOnlyHint })
  async refuse(ctx: FlowCtxOf<"tools:call-tool">) {
    if (!this.options.enabled) return;
    throw new PublicMcpError(
      `The help desk is read-only during maintenance, so ${ctx.state.tool?.metadata.name} is unavailable. Try again after 18:00 UTC.`,
    );
  }

  // Leave them out of tools/list
  @ListToolsHook.Did("findTools")
  async hide(ctx: FlowCtxOf<"tools:list-tools">) {
    if (!this.options.enabled) return;
    const tools = ctx.state.tools ?? [];
    ctx.state.set("tools", tools.filter(({ tool }) => tool.metadata.annotations?.readOnlyHint));
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A few things to notice:

  • The error reaches the model word for word. A PublicMcpError thrown from a hook arrives as an isError result with its message and _meta.code: "PUBLIC_ERROR". Say why the call was refused and what to do instead, because the model will pass it on. Any other error a hook throws fails the call too, but as a SERVER_ERROR whose message is hidden in production.
  • filter decides per call whether the hook runs at all. It gets the same ctx. It's a plain function in the decorator, so it can't see the plugin's options; those are checked inside.
  • Hiding isn't blocking. The ListToolsHook only changes what tools/list returns. A client that calls close_ticket by name still reaches it, which is why the ToolHook refuses the call as well.

Changing the result

Did("execute") runs after the tool returns and before FrontMCP turns its return value into content and structuredContent. Assign a new value to ctx.state.toolContext.output, and that's what the client gets. Here a plugin keeps customer email addresses out of every result, so they never reach the model:

Open
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";

function redactEmails(value: unknown): unknown {
  if (Array.isArray(value)) return value.map(redactEmails);
  if (value && typeof value === "object") {
    return Object.fromEntries(
      Object.entries(value).map(([key, v]) => [key, key === "email" ? "[redacted]" : redactEmails(v)]),
    );
  }
  return value;
}

@Plugin({ name: "redact", description: "Removes email addresses from tool results" })
export class RedactPlugin extends DynamicPlugin<object> {
  @ToolHook.Did("execute")
  async redact(ctx: FlowCtxOf<"tools:call-tool">) {
    const { toolContext } = ctx.state;
    if (toolContext) toolContext.output = redactEmails(toolContext.output);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Because the hook runs before the result is formatted, the text block and structuredContent both come from the redacted value. The tool doesn't know it happened, and new tools get the same treatment without any change.

Deep diveAnswering without running the toolShow detailsHide details

A Will("execute") hook can also answer the call itself, and the tool never runs. That's how a cache works. ctx.respond() takes a complete tools/call result, with content and, for objects, structuredContent:

Open
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>);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

ctx.respond() ends the call on the spot: the tool, the Did("execute") hooks and the result formatting are all skipped, so the value you pass has to be a finished result. For real use, the official Cache plugin, @frontmcp/plugin-cache, adds expiry, Redis, and keys that include the caller, so one user's cached answer is never served to another.

What respond() sends, from a hook and from a tool, and where it doesn't work, is in the this.respond() reference.

The order hooks run in

When several hooks share a stage, FrontMCP runs them by priority, a number you pass next to filter. Lower numbers run first, for Will and Did hooks alike, and the default is 0. Hooks with the same priority run in the order their plugins are listed in plugins:

Open
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";
import { Trace } from "./trace";

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

// Trace is a CONTEXT provider: one per call, reached through the call's toolContext
const trace = (ctx: CallCtx, line: string) => ctx.state.toolContext?.get(Trace).lines.push(line);

@Plugin({ name: "first" })
export class FirstPlugin extends DynamicPlugin<object> {
  @ToolHook.Will("execute")
  async will(ctx: CallCtx) {
    trace(ctx, "first: will, priority 0");
  }

  @ToolHook.Did("execute", { priority: 10 })
  async did(ctx: CallCtx) {
    trace(ctx, "first: did, priority 10");
  }
}

@Plugin({ name: "second" })
export class SecondPlugin extends DynamicPlugin<object> {
  @ToolHook.Will("execute", { priority: -10 })
  async will(ctx: CallCtx) {
    trace(ctx, "second: will, priority -10");
  }

  @ToolHook.Did("execute")
  async did(ctx: CallCtx) {
    trace(ctx, "second: did, priority 0");
  }

  // Wraps the stage: its first half runs after the Will hooks, its second half before the Did hooks
  @ToolHook.Around("execute")
  async around(ctx: CallCtx, next: () => Promise<void>) {
    trace(ctx, "second: around, before next()");
    await next();
    trace(ctx, "second: around, after next()");
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The result shows the whole call: the Will hooks from lowest priority to highest, the first half of the Around hook, the tool, the second half, then the Did hooks, again from lowest to highest. (The Did lines show up in the result because they're added to the same array after the tool returns it and before it's formatted.)

Give a hook a priority when its order matters: a check that must see the cleaned-up input needs a higher number than the hook that cleans it up. The last challenge below is about exactly that.

Recap

  • A tool call runs through the tools:call-tool flow: stages before, validateInput → execute → validateOutput, then closing stages that always run.
  • @ToolHook.Will(stage) runs before a stage and @ToolHook.Did(stage) after it. Did hooks only run if the stage worked; Did("finalize") runs for every call. @ToolHook.Around(stage) wraps the stage: await next() runs it, and throws if it fails.
  • ctx.state.input.arguments is what the client sent, ctx.state.tool is the tool, and ctx.state.toolContext has the validated input and, after execute, the output.
  • Time and log calls with Around("execute"), normalize arguments in Will("validateInput"), refuse calls by throwing a PublicMcpError in Will("execute"), change results in Did("execute"), and answer from a cache with ctx.respond().
  • Hooks on a stage run by priority, lowest first, for Will and Did alike. filter skips a hook for some calls.
  • Every hook decorator, flow and stage, and the full run order, are in the hook decorators reference.

Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press Check.

Challenge 1 of 4

Accept lowercase ticket ids

This plugin is meant to accept ids like " t-2 ", but they're still rejected with INVALID_INPUT. Fix the hook so they reach the tool as T-2, while ids that aren't ticket ids are still rejected.

Open
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";

@Plugin({ name: "ticket-ids" })
export class TicketIdsPlugin extends DynamicPlugin<object> {
  @ToolHook.Will("execute")
  async normalizeTicketId(ctx: FlowCtxOf<"tools:call-tool">) {
    const input = ctx.state.toolContext?.input as { id?: unknown } | undefined;
    if (typeof input?.id === "string") input.id = input.id.trim().toUpperCase();
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.