Hooking into Calls
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,DidandAroundhooks 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:
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:
| Group | Stages | Runs |
|---|---|---|
| Before | parseInput, ensureRemoteCapabilities, findTool, checkPublicAccess, checkToolAuthorization, checkEntryAuthorities, createTaskIfRequested, createToolCallContext, checkToolCredentials, acquireQuota, acquireSemaphore | Until one fails |
| Execute | validateInput, execute, validateOutput | Until one fails |
| Finally | releaseSemaphore, releaseQuota, applyUI, finalize | Always |
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:
| Field | What it is | From |
|---|---|---|
ctx.state.input | The raw tools/call params: the tool name, the arguments as the client sent them, and _meta | parseInput |
ctx.state.tool | The tool being called. metadata holds everything from @Tool({ ... }), and owner.id is its app's id | findTool |
ctx.state.toolContext | This call's tool instance: input (checked against the schema once validateInput is done), output (once execute is done) and get() for providers | createToolCallContext |
@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:
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:
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:
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
PublicMcpErrorthrown from a hook arrives as anisErrorresult 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 aSERVER_ERRORwhose message is hidden in production. filterdecides per call whether the hook runs at all. It gets the samectx. 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
ListToolsHookonly changes whattools/listreturns. A client that callsclose_ticketby name still reaches it, which is why theToolHookrefuses 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:
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:
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:
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-toolflow: stages before,validateInput→execute→validateOutput, then closing stages that always run. @ToolHook.Will(stage)runs before a stage and@ToolHook.Did(stage)after it.Didhooks 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.argumentsis what the client sent,ctx.state.toolis the tool, andctx.state.toolContexthas the validatedinputand, afterexecute, theoutput.- Time and log calls with
Around("execute"), normalize arguments inWill("validateInput"), refuse calls by throwing aPublicMcpErrorinWill("execute"), change results inDid("execute"), and answer from a cache withctx.respond(). - Hooks on a stage run by
priority, lowest first, forWillandDidalike.filterskips 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.
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.