Hook decorators

Every request FrontMCP handles runs through a flow: a fixed list of named stages. tools/call runs the tools:call-tool flow, from parseInput and findTool to execute and finalize. A hook is a method that FrontMCP calls at one of those stages: before it (Will), after it (Did), around it (Around), or as an extra step inside it (Stage). Hooks live on plugins, providers, and the classes of tools, resources, prompts, agents and jobs. Hooking into Calls teaches them step by step.

@ToolHook.Will(stage, { priority, filter })
async before(ctx: FlowCtxOf<"tools:call-tool">) { /* ... */ }

@ToolHook.Around(stage, { priority, filter })
async around(ctx: FlowCtxOf<"tools:call-tool">, next: () => Promise<void>) { /* ... */ }

Reference

Will, Did, Around and Stage

Each flow has a set of four decorators, like ToolHook for tools:call-tool. Put one on an async method of a plugin, a provider, or a tool, resource, prompt, agent or job class (where hooks can be declared).

freeze.plugin.ts
import { DynamicPlugin, FlowCtxOf, Plugin, PublicMcpError, ToolHook } from "@frontmcp/sdk";

@Plugin({ name: "freeze" })
export class FreezePlugin extends DynamicPlugin<object> {
  @ToolHook.Will("execute", { filter: (ctx) => !ctx.state.tool?.metadata.annotations?.readOnlyHint })
  async refuseChanges(ctx: FlowCtxOf<"tools:call-tool">) {
    throw new PublicMcpError(`Changes are frozen until Monday, so ${ctx.state.tool?.metadata.name} is unavailable.`);
  }
}

See more examples below.

DecoratorThe method runsSignature
Will(stage, options?)Just before the stage.(ctx) => Promise<void>
Did(stage, options?)Just after the stage, only if the stage succeeded.(ctx) => Promise<void>
Around(stage, options?)Instead of the stage: await next() runs the stage and rejects if it fails. Code before next() runs after the Will hooks, code after it before the Did hooks. Not calling next() skips the stage; calling it again runs it again.(ctx, next) => Promise<void>
Stage(stage, options?)Inside the stage, as a step next to FrontMCP's own: at the default priority, before FrontMCP's step, and with a priority above 0, after it. Inside any Around hooks.(ctx) => Promise<void>

A stage name that isn't in the flow's plan is a TypeScript error, and a hook on one never runs.

Options

OptionTypeDefaultDescription
prioritynumber0The order among hooks of the same kind on the same stage: lower runs first, for Will, Did and Stage alike. For Around hooks, lower is outer. See the order hooks run in.
filter(ctx) => boolean | Promise<boolean>noneCalled with the same ctx before each run. When it returns false, the hook is skipped; for an Around hook, the stage still runs. It's a plain function in the decorator, so it can't use this.
appliesTo"own-app" | "uncovered-apps""own-app"For a hook of an app's plugin on tools/call, resources/read or prompts/get. "own-app" runs it for that app's entries. "uncovered-apps" also runs it for the entries of any app that no instance of the same hook covers, from its own plugins or the server's. Meant for gates that an entry's options ask for: see @Plugin.

Decorator sets and their flows

@frontmcp/sdk exports a decorator set for the flows you're most likely to hook. FlowHooksOf(flow) makes one for any other flow.

ExportFlowRuns forStages, in order
ToolHooktools:call-tooltools/callparseInput, ensureRemoteCapabilities, findTool, checkPublicAccess, checkToolAuthorization, checkEntryAuthorities, createTaskIfRequested, createToolCallContext, checkToolCredentials, acquireQuota, acquireSemaphore; then validateInput, execute, validateOutput; then always releaseSemaphore, releaseQuota, applyUI, finalize
ListToolsHooktools:list-toolstools/listparseInput, ensureRemoteCapabilities, findTools, filterByAuthorities, resolveConflicts, parseTools
ResourceHookresources:read-resourceresources/readparseInput, ensureRemoteCapabilities, findResource, checkEntryAuthorities, createResourceContext; then execute, validateOutput; then always finalize
ListResourcesHookresources:list-resourcesresources/listparseInput, ensureRemoteCapabilities, findResources, filterByAuthorities, resolveConflicts, parseResources
ListResourceTemplatesHookresources:list-resource-templatesresources/templates/listparseInput, ensureRemoteCapabilities, findTemplates, filterByAuthorities, resolveConflicts, parseTemplates
PromptHookprompts:get-promptprompts/getparseInput, ensureRemoteCapabilities, findPrompt, checkPublicAccess, checkEntryAuthorities, createPromptContext; then execute, validateOutput; then always finalize
ListPromptsHookprompts:list-promptsprompts/listparseInput, ensureRemoteCapabilities, findPrompts, filterByAuthorities, resolveConflicts, parsePrompts
HttpHookhttp:requestEvery HTTP request, before it's handledtraceRequest, checkIpFilter, acquireQuota, acquireSemaphore, checkAuthorization, acquireIdentityQuota, router, then the transport stages (handleMcp2026, …) and audit, metrics, releaseSemaphore, releaseQuota, finalize
AgentCallHookagents:call-agentA call to an agent's invoke_<name>, inside its tools:call-tool flow (since 1.8.5). See Hooking an agent's calls.parseInput, findAgent, checkCallDepth (since 1.9), checkEntryAuthorities, checkAgentAuthorization, createAgentContext, acquireQuota, acquireSemaphore; then validateInput, execute, validateOutput; then always releaseSemaphore, releaseQuota, parseOutput, emitCompletion, finalize
JobHookjobs:execute-jobEach attempt of a job: run by execute_job, while the client waits or in the background, each retry, and each workflow step (since 1.9.4). See Hooking a job's attempts.parseInput, checkJobAuthorization, validateInput, createJobContext; then execute, validateOutput; then always updateRunState, finalize
ChannelSendHook, ChannelListHookchannels:send-notification, channels:listEvery channel event, and the channels a session subscribes to (since 1.9; before, only runs of those flows you started yourself). See @Channel.parseInput, resolveMeta, send, finalize; listChannels, finalize
OAuthAuthorizeHook, OAuthTokenHook, OAuthCallbackHook, OAuthRegisterHookoauth:authorize, oauth:token, oauth:callback, oauth:registerThe OAuth endpoints of local and remote auth. They need a real server, so this page doesn't show them.

WillHookOf(flow), DidHookOf(flow), AroundHookOf(flow) and StageHookOf(flow) each return one of the four decorators, as in @(WillHookOf("resources:read-resource")("execute")).

CompletionHook hooks completion:complete, the completion/complete requests. JobHook, and the ExecuteJobFlow type, are new in 1.9.4: before, jobs ran through no flow of their own. Changed in 1.9: PromptHook, ListPromptsHook and CompletionHook are new, and FlowHooksOf() and FlowCtxOf<> accept the three flows' names. Before, TypeScript rejected FlowHooksOf("prompts:get-prompt"), and code wrote FlowHooksOf<any> with a ctx type of its own.

Stages run in groups. The first group runs until a stage fails or answers; the execute group runs if it got that far; the last group, where the table says "always", runs whether the request worked or not. When a stage fails, its Did hooks and every later stage outside the last group are skipped.

ctx

A hook's first argument is the running flow. Type it with FlowCtxOf<"tools:call-tool">, or the name of the flow you hook.

MemberDescription
ctx.stateWhat the flow knows so far. Fields depend on the flow and on how far it has got: see tools:call-tool state. Read a field as ctx.state.tool or ctx.state.get("tool"); ctx.state.required.tool and ctx.state.getOrThrow("tool") throw if it isn't set. ctx.state.set("tools", list) or ctx.state.set({ ... }) replaces fields, and ctx.state.snapshot() returns a copy.
ctx.respond(result)Ends the request with result, which must be a complete response for the flow, like a whole tools/call result with content, or { result, logs } for a job's attempt. The stages and hooks that haven't run yet are skipped, apart from the closing group. In a Did hook it's ignored. this.respond compares it with a tool's own this.respond().
ctx.fail(error)Ends the request with error, exactly like throwing it.
ctx.get(token)A provider of the server, from @FrontMcp({ providers }). An app's providers aren't found here: in a plugin use this.get(), and in a tool call ctx.state.toolContext.get().
ctx.rawInputThe flow's input before parsing. For http:request, ctx.rawInput.request has method, path, headers, query and body.
ctx.scopeLoggerThe server's logger.

Throwing from any hook, or calling ctx.fail(), fails the request. A PublicMcpError reaches the client with its message and code, PUBLIC_ERROR by default; any other error becomes a SERVER_ERROR, whose message is hidden in production. A Did hook that throws fails the call after the stage ran, so the tool's side effects have already happened.

tools:call-tool state

FieldSet byWhat it is
ctx.state.inputparseInputThe tools/call params as the client sent them: name, arguments and _meta. Changing arguments before validateInput changes what's validated.
ctx.state.authInfo, progressToken, jsonRpcRequestIdparseInputWho's calling, and the request's progress token and JSON-RPC id.
ctx.state.toolfindToolThe tool: metadata is everything from @Tool({ ... }), including keys FrontMCP doesn't know, and owner.id is its app's id.
ctx.state.toolContextcreateToolCallContextThe call's tool instance: input (validated once validateInput has run), output (once execute has run; assign it in a Did("execute") hook to change the result), and get() for the app's providers.
ctx.state.rawOutputvalidateOutputWhat execute() returned. Not set yet in Did("execute"); the output schema is checked later, in finalize.
ctx.state.resultMetaYour hook, with ctx.state.set("resultMeta", { … })An object FrontMCP merges into the result's _meta in finalize. It never becomes part of the tool's data. New in 1.8.6: see Adding to the result's _meta.

Where hooks can be declared

Declared onFlowsRuns forthis in the hook
A plugin class, or a provider in its providersAnyAn app's plugin: that app's tool calls, resource reads and prompt gets (and other apps' too, for a hook with appliesTo: "uncovered-apps"), its job attempts, calls to tool names no app has, and every list and HTTP request. A server's plugin: every request and job attempt. An agent's plugin: the calls of the tools the agent's model uses, and its reads of the agent's resources and prompts (since 1.9.4). See @Plugin.The plugin, or the provider
A provider in @App({ providers })AnyLike a plugin on that appThe provider. For a CONTEXT provider, the instance built for that request (since 1.9)
A provider in @FrontMcp({ providers })AnyLike a plugin on the server: every app's requests (since 1.9)The provider
A @Tool classtools:call-tool: an instance method from Did("createToolCallContext") on, a static method on any stageThat tool's callsAn instance method: the call's tool context, this.input, this.get(). A static method: the class
A @Resource or @ResourceTemplate classresources:read-resource: an instance method from Did("createResourceContext") on, a static method on any stageThat resource's readsThe read's resource context, or the class
A @Prompt classprompts:get-prompt: an instance method from Did("createPromptContext") on, a static method on any stageThat prompt's requestsThe request's prompt context, or the class
An @Agent classagents:call-agent: an instance method from Did("createAgentContext") on, a static method on any stageThat agent's callsThe call's agent context, or the class
A @Job classjobs:execute-job: an instance method from Did("createJobContext") on, a static method on any stageThat job's attemptsThe attempt's job context, this.input, this.attempt, or the class

A static hook on an entry class gets the flow as ctx, like any hook, and needs no instance, so it can hook the stages before the entry's instance exists, like parseInput, findTool or checkToolAuthorization: see Hooking the stages before an entry's instance.

Hooks that could never run stop the server from starting, with InvalidHookFlowError (INVALID_HOOK_FLOW): an instance method on an entry class for a stage before its context is built, like Will("findTool") or Will("createToolCallContext") on a tool; list hooks, like ListToolsHook, on an entry class, static or not; and hooks for another flow, like a ToolHook on a @Job class. The message names the class, the method and the stage, and says to declare an early hook as a static method. A provider or a plugin can hook every stage and flow.

Changed in 1.9: before, all of these were accepted and never ran, with no warning, and so were hooks on a provider in @FrontMcp({ providers }) or with scope: ProviderScope.CONTEXT, which run now.

Changed in 1.9.4: static hook methods are new; before, only a provider or a plugin could hook the stages before an entry's instance. And a @Job class's hooks run on its attempts; before, jobs ran through no flow, and any hook on a @Job class stopped the server from starting.

The order hooks run in

For one stage:

  1. Will hooks, lowest priority first.
  2. Around hooks, lowest priority outermost.
  3. The stage itself: Stage steps with priority 0 or less, FrontMCP's own step, then Stage steps with a higher priority.
  4. Did hooks, lowest priority first.

Hooks with the same priority on the same stage run in this order: an app's providers, then the app's plugins in the order of its plugins array, then the server's plugins, then the tool's own class. Within one class, in the order they're declared.

Caveats

  • Hooks of an app's plugin or provider on list requests (tools/list, resources/list, resources/templates/list, prompts/list) and on HTTP requests aren't tied to the app: they see and can change every app's entries. To change only the entries the plugin's own call hooks judge, check each one with isEntryGatedBy().
  • Hiding isn't blocking. A list hook changes what's listed; a client that calls a hidden tool by name still reaches it.
  • In an Around hook, the error next() rejects with is FrontMCP's: a ToolExecutionError wrapping what execute() threw, or, when the tool called this.fail(), an internal error whose message is empty. Rethrow it to keep the tool's own error for the client.
  • To give a call a result from an Around hook that skips the stage, assign ctx.state.toolContext.output or call ctx.respond(). ctx.state.set("output", …) isn't read: the call ends with Flow exited without producing output.
  • In Did("finalize"), ctx.state.output isn't set yet. ctx.state.rawOutput is what execute() returned, if it returned.
  • HttpHook hooks see requests as they arrive. For 2026-07-28 requests, the flow's closing stages can run before the call they carry is answered, so don't time requests with them.

Usage

Running code before and after a stage

A Will hook runs before its stage, and a Did hook after it if the stage worked. The closing stages, like finalize, run for every call. This plugin records which of its hooks ran:

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

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

@Plugin({ name: "trace", providers: [Trace], exports: [Trace] })
export class TracePlugin extends DynamicPlugin<object> {
  private note(ctx: CallCtx, line: string) {
    if (ctx.state.input?.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.Did("finalize")
  async didFinalize(ctx: CallCtx) {
    this.note(ctx, "did finalize");
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

For code that must run whether a stage worked or not, use Around or a hook on a closing stage like Did("finalize").

Wrapping a stage

An Around hook runs the stage itself, with await next(). It can catch a failure, and it can run the stage again. This one retries tools marked idempotentHint once:

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

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

@Plugin({ name: "retry", description: "Retries idempotent tools once when they fail" })
export class RetryPlugin extends DynamicPlugin<object> {
  @ToolHook.Around("execute", { filter: (ctx) => ctx.state.tool?.metadata.annotations?.idempotentHint === true })
  async retryOnce(ctx: CallCtx, next: () => Promise<void>) {
    try {
      await next(); // runs execute()
    } catch (error) {
      ctx.scopeLogger.warn(`${ctx.state.tool?.metadata.name} failed, retrying once`);
      await next(); // runs execute() again; if it fails again, so does the call
    }
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The filter keeps the hook off charge_invoice, which isn't safe to run twice. Swallowing the error without running the stage again would leave the call with no result: see Troubleshooting.

Answering without running the rest

ctx.respond() ends the request with a result of your own. In Will("findTool") it runs before FrontMCP looks the tool up, so it can answer calls to a tool that doesn't exist, like one that was renamed:

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

const renamed: Record<string, string> = { find_ticket: "get_ticket", ticket_search: "search_tickets" };

@Plugin({ name: "renames", description: "Tells the model the new name of a renamed tool" })
export class RenamesPlugin extends DynamicPlugin<object> {
  @ToolHook.Will("findTool")
  async pointToNewName(ctx: FlowCtxOf<"tools:call-tool">) {
    const name = ctx.state.input?.name;
    const newName = name ? renamed[name] : undefined;
    if (!newName) return;
    ctx.respond({
      content: [{ type: "text", text: `${name} was renamed to ${newName}. Call ${newName} with the same arguments.` }],
      isError: true,
    });
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

An app's plugin hooks tools/call only for that app's tools, but a name no app has belongs to no app, and FrontMCP runs every app's hooks for it: the plugin is on the help desk app and still answers find_ticket for the whole server. The result you pass is sent as it is: give it content, or the client gets "content": []. Answering from a cache in Will("execute") works the same way: see this.respond.

Adding to the result's _meta

A hook that wants to say something about a result, without changing the tool's data, sets ctx.state.resultMeta. FrontMCP merges it into the result's _meta when the call finishes, and structuredContent and the text stay what the tool returned. The Cache plugin marks a hit this way, with cache: "hit". Here a plugin marks every result as audited:

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

@Plugin({ name: "audit" })
export class AuditPlugin extends DynamicPlugin<object> {
  @ToolHook.Did("execute")
  async mark(ctx: FlowCtxOf<"tools:call-tool">) {
    ctx.state.set("resultMeta", { audited: true });
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Hooking resources and prompts

ResourceHook hooks resources/read, and PromptHook hooks prompts/get. Their state has resource and resourceContext, or prompt and promptContext, in place of tool and toolContext:

Open
import { DynamicPlugin, FlowCtxOf, Plugin, PromptHook, Provider, ResourceHook } from "@frontmcp/sdk";

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

@Plugin({ name: "reads", providers: [ReadLog], exports: [ReadLog] })
export class ReadsPlugin extends DynamicPlugin<object> {
  @ResourceHook.Did("execute")
  async resourceRead(ctx: FlowCtxOf<"resources:read-resource">) {
    this.get(ReadLog).lines.push(`read ${ctx.state.input?.uri}`);
  }

  @PromptHook.Will("execute")
  async promptRequested(ctx: FlowCtxOf<"prompts:get-prompt">) {
    this.get(ReadLog).lines.push(`prompt ${ctx.state.input?.name} ${JSON.stringify(ctx.state.input?.arguments)}`);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Changing what clients list

A hook on a list flow's find… stage can filter what's listed. This one hides every resource whose URI has an internal/ path from resources/list:

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

const uriOf = (metadata: { uri?: string; uriTemplate?: string }) => metadata.uri ?? metadata.uriTemplate ?? "";

@Plugin({ name: "hide-internal" })
export class HideInternalPlugin extends DynamicPlugin<object> {
  @ListResourcesHook.Did("findResources")
  async hide(ctx: FlowCtxOf<"resources:list-resources">) {
    const resources = ctx.state.resources ?? [];
    ctx.state.set("resources", resources.filter(({ resource }) => !uriOf(resource.metadata).includes("://internal/")));
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The plugin is on the help desk app, but list flows aren't tied to an app, so it hides the billing app's ledger too. To refuse reads as well, add a ResourceHook.Will("execute") that throws.

Hooks on a tool or a provider

Hooks don't need a plugin. On a @Tool class they run only for that tool, and this is the call's tool context. On a provider in @App({ providers }), they run for every tool of the app:

Declaring hooks without a plugin

Example 1 of 2

On a tool

Open
import { FlowCtxOf, PublicMcpError, Tool, ToolContext, ToolHook, z } from "@frontmcp/sdk";

type Input = { id: string; at: string };

@Tool({
  name: "schedule_callback",
  description: "Schedule a phone call back to the customer",
  inputSchema: { id: z.string(), at: z.string().datetime().describe("When to call, as an ISO date and time") },
})
export class ScheduleCallback extends ToolContext {
  // Runs for this tool only; `this` is the call's ToolContext, so this.input is the validated input
  @ToolHook.Will("execute")
  async refusePastTimes(ctx: FlowCtxOf<"tools:call-tool">) {
    const { at } = this.input as Input;
    if (new Date(at) < new Date("2026-01-01")) throw new PublicMcpError(`${at} is in the past. Pick a time after now.`);
  }

  async execute({ id, at }: Input) {
    return { id, callbackAt: at };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A tool's instance methods can only hook stages from Did("createToolCallContext") on: before that, there's no tool instance to run them on. A static method can hook any stage, as the next section shows. A provider's hooks run on an app's provider, a CONTEXT one included, and on a provider in @FrontMcp({ providers }) for every app (where hooks can be declared).

Hooking the stages before an entry's instance

A static hook method on a tool class runs for that tool only, like an instance method, but needs no instance: this is the class, and it can hook the stages before createToolCallContext. Here one cleans up the id the client sent before anything checks it, and another refuses an archived ticket before the tool is built. close_ticket takes the same input and has no hooks of its own:

Open
import { FlowCtxOf, PublicMcpError, Tool, ToolContext, ToolHook, z } from "@frontmcp/sdk";

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string().regex(/^T-\d+$/) } })
export class GetTicket extends ToolContext {
  @ToolHook.Did("parseInput")
  static cleanId(ctx: FlowCtxOf<"tools:call-tool">) {
    const input = ctx.state.required.input;
    const id = input.arguments?.id;
    if (typeof id !== "string") return;
    ctx.state.set("input", { ...input, arguments: { ...input.arguments, id: id.trim().toUpperCase() } });
  }

  @ToolHook.Will("checkToolAuthorization")
  static refuseArchived(ctx: FlowCtxOf<"tools:call-tool">) {
    if (ctx.state.input?.arguments?.id === "T-0") throw new PublicMcpError("T-0 is archived.");
  }

  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string().regex(/^T-\d+$/) } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "closed" };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A static hook gets the same ctx as a plugin's, so it reads and changes ctx.state the same way: here input, which parseInput sets. It has no this.input or this.get(): this is the class, on a later stage too. With the same priority, an entry's static hooks run after the providers' and plugins' hooks on that stage, as its instance hooks do.

@Resource, @Prompt, @Agent and @Job classes take static hooks the same way, each on its own flow, like PromptHook.Will("findPrompt"), AgentCallHook.Will("findAgent") or JobHook.Did("parseInput"). Hooks on list flows, like ListToolsHook, still stop the server from starting on an entry class, static or not: put them on a plugin or a provider. New in 1.9.4: before, only a provider or a plugin could hook these stages, and the startup error didn't mention static methods (Troubleshooting).

Hooking an agent's calls

A client calls an agent as a tool, invoke_<name>, so a ToolHook runs for it. Inside that call, the agent runs in its own flow, agents:call-agent, whose hooks are AgentCallHook, on a plugin or on the agent class. The tools the agent's model calls are the agent's own: a plugin on the app or the server doesn't hook them, only a plugin in @Agent({ plugins }) does (@Agent):

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

export const calls: string[] = [];

@Plugin({ name: "call-log" })
export class CallLog extends DynamicPlugin<object> {
  @ToolHook.Will("execute")
  async tool(ctx: FlowCtxOf<"tools:call-tool">) {
    calls.push(`tool ${ctx.state.tool?.metadata.name}`);
  }

  @AgentCallHook.Will("execute")
  async agent(ctx: FlowCtxOf<"agents:call-agent">) {
    calls.push(`agent ${ctx.state.agent?.metadata.name}`);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The limits of @Agent, like rateLimit, are applied once, by the tool call. Changed in 1.8.5: before, the agents:call-agent flow never ran, so AgentCallHook hooks never did.

Hooking a job's attempts

Each attempt of a job runs the jobs:execute-job flow, whose hooks are JobHook: a call to execute_job while the client waits, a run in the background, each retry, and each step of a workflow. Here a plugin records how each attempt ended, and answers count-open-tickets from a snapshot instead of running it:

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

export const attempts: string[] = [];

@Plugin({ name: "job-audit" })
export class JobAudit extends DynamicPlugin<object> {
  @JobHook.Did("updateRunState")
  async record(ctx: FlowCtxOf<"jobs:execute-job">) {
    const { job, attempt, flowError } = ctx.state;
    attempts.push(`${job?.name} attempt ${attempt}: ${flowError ? flowError.message : "ok"}`);
  }

  @JobHook.Will("execute", { filter: (ctx) => ctx.state.job?.name === "count-open-tickets" })
  async fromSnapshot(ctx: FlowCtxOf<"jobs:execute-job">) {
    ctx.respond({ result: { open: 12 }, logs: ["Read from the 06:00 snapshot"] });
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

ctx.respond() takes the attempt's whole answer, { result, logs }. The result isn't checked against the job's outputSchema: it's recorded as the run's result as it is. updateRunState still runs, so the run is completed and Did("updateRunState") hooks see it, like the first hook here. That hook runs for failed attempts too: updateRunState is a closing stage, and ctx.state.flowError is the attempt's error. jobs:execute-job lists the stages and what each one sets.

An app's plugins hook that app's jobs only; a plugin on @FrontMcp hooks every app's. On a @Job class, instance methods hook from Did("createJobContext") on, with this the attempt's job context, and static methods hook any stage. New in 1.9.4: before, jobs ran through no flow, so the closest a hook got was the execute_job tool's tools:call-tool: one call, not each attempt, and only the start of a run in the background.

Seeing every HTTP request

HttpHook runs for each HTTP request the server receives, before FrontMCP routes it, so it sees requests of every kind, including ones that fail later. ctx.rawInput.request is the request, with its parsed body:

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

@Provider({ name: "RequestCounts" })
export class RequestCounts {
  byMethod: Record<string, number> = {};
}

@Plugin({ name: "requests", providers: [RequestCounts], exports: [RequestCounts] })
export class RequestsPlugin extends DynamicPlugin<object> {
  @HttpHook.Will("router")
  async count(ctx: FlowCtxOf<"http:request">) {
    const method: string = ctx.rawInput.request.body?.method ?? "(not JSON-RPC)";
    const { byMethod } = this.get(RequestCounts);
    byMethod[method] = (byMethod[method] ?? 0) + 1;
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The call to no_such_tool is counted too: the hook ran before FrontMCP found out the tool doesn't exist. The Playground's own requests, like the one it sends when the example starts, go through the same hook.

The order hooks run in

This example puts every kind of hook on one stage, from an app provider, two app plugins, a server plugin and the tool itself, and records the order they ran in:

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

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

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

  @ToolHook.Around("execute", { priority: 5 })
  async inner(ctx: CallCtx, next: () => Promise<void>) {
    trace(ctx, "first plugin: Around, priority 5, before next()");
    await next();
    trace(ctx, "first plugin: Around, priority 5, after next()");
  }

  @ToolHook.Stage("execute", { priority: 10 })
  async afterTheTool(ctx: CallCtx) {
    trace(ctx, "first plugin: Stage, priority 10");
  }
}

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

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

  @ToolHook.Around("execute")
  async outer(ctx: CallCtx, next: () => Promise<void>) {
    trace(ctx, "second plugin: Around, before next()");
    await next();
    trace(ctx, "second plugin: Around, after next()");
  }

  @ToolHook.Stage("execute")
  async beforeTheTool(ctx: CallCtx) {
    trace(ctx, "second plugin: Stage");
  }
}

@Plugin({ name: "server-wide" })
export class ServerPlugin extends DynamicPlugin<object> {
  @ToolHook.Will("execute")
  async will(ctx: CallCtx) {
    trace(ctx, "server plugin: Will");
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The Did lines are in the result because the hooks add them to the same array after the tool returns it and before FrontMCP formats it.


Troubleshooting

My hook never runs

Check, in order:

  1. The class the hook is on is registered: the plugin in plugins, the provider in @App({ providers }).
  2. It's declared on something whose hooks run (where hooks can be declared). Before 1.9, hooks on a provider in @FrontMcp({ providers }) or on a CONTEXT provider never ran; before 1.8.5, AgentCallHook never ran anywhere.
  3. The stage exists in the flow: a hook on a stage name that isn't in the stage list never runs.
  4. The request reaches the stage. Did hooks are skipped when their stage fails, and a failed stage skips every later stage but the closing ones.
  5. For a plugin on an app: the call is to one of that app's tools, or the job is one of that app's jobs. For every app's, register the plugin on @FrontMcp, or give the hook appliesTo: "uncovered-apps" to reach the apps that don't have the plugin. For a plugin on an agent: the call comes from the agent's model, a tool call or, since 1.9.4, a read of the agent's resources or prompts, not from a client.
  6. filter returns true for this request.

Tool "…" declares hooks that would never run

The server didn't start, with InvalidHookFlowError (INVALID_HOOK_FLOW). The full message names each hook that can't run, and why:

Tool "Early" declares hooks that would never run: tooSoon() (will 'findTool' of tools:call-tool): runs before 'createToolCallContext' builds the instance it runs on; declare it as a static method to run it without an instance. Hooks declared as instance methods on a tool class run on the instance built for each call; static methods run without one, from the first stage of a call. Declare hooks for list flows on a provider or a plugin instead.

An instance method hooks a stage before the entry's instance is built. Make it static, reading ctx.state instead of this, as in Hooking the stages before an entry's instance, or move it to a plugin. For a list flow, like (did 'findTools' of tools:list-tools): list flows build no entry instance, move the hook to a plugin or a provider: static doesn't help there. Resource "…", Prompt "…", Agent "…" and Job "…" read the same way. A hook for another flow, like a ToolHook on a job class, fails with Job "…" has hooks for unsupported flows: … on tools:call-tool.

Flow exited without producing output

The call ended with no result, code FLOW_EXITED_WITHOUT_OUTPUT. An Around hook caught the error next() threw and didn't rethrow it, or returned without calling next() and without setting a result:

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

@Plugin({ name: "log-errors" })
export class LogErrorsPlugin extends DynamicPlugin<object> {
  @ToolHook.Around("execute")
  async logFailures(ctx: FlowCtxOf<"tools:call-tool">, next: () => Promise<void>) {
    try {
      await next();
    } catch (error) {
      ctx.scopeLogger.error(`${ctx.state.tool?.metadata.name} failed`);
      // 🚩 The error is swallowed, and the call has no result
    }
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Add throw error; after logging, and the client gets the tool's own error, There's no ticket T-9., again. To answer instead of failing, give the call a result: assign ctx.state.toolContext.output before returning, or call ctx.respond(). ctx.state.set("output", …) isn't read.

Provider "…" is not available from ctx.get() in a hook

ctx.get() only finds the server's providers, the ones in @FrontMcp({ providers }):

Open
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";
import { DeskSettings } from "./desk-settings";

@Plugin({ name: "region" })
export class RegionPlugin extends DynamicPlugin<object> {
  @ToolHook.Will("execute")
  async checkRegion(ctx: FlowCtxOf<"tools:call-tool">) {
    // 🚩 DeskSettings is an app provider: ctx.get() can't find it. this.get(DeskSettings) can.
    const settings = ctx.get(DeskSettings);
    ctx.scopeLogger.info(`region ${settings.region}`);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

In a plugin's hook, use this.get(), which finds the plugin's providers and the app's. In any tools:call-tool hook, ctx.state.toolContext?.get() finds what the tool would.

The client gets "content": [] after ctx.respond()

ctx.respond() sends what you pass as the whole result. Pass a complete tools/call result: { content: [{ type: "text", text }], structuredContent? }.

My Did hook doesn't see failed calls

Did hooks only run when their stage worked. To see failures too, use Around and catch what next() throws, as in Wrapping a stage, or a hook on a closing stage like Did("finalize").

ctx.respond() in a Did hook does nothing

By the time a Did hook runs, the stage has finished, and FrontMCP ignores ctx.respond() there. In Did("execute"), assign ctx.state.toolContext.output to change the result, as in Hooking into Calls.

The client sees "Internal FrontMCP error" instead of my hook's message

The hook threw an error that isn't a PublicMcpError, and the server runs in production, which hides the messages of other errors. Throw new PublicMcpError("…") for messages the model should read.