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).
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.`);
}
}| Decorator | The method runs | Signature |
|---|---|---|
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
| Option | Type | Default | Description |
|---|---|---|---|
priority | number | 0 | The 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> | none | Called 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.
| Export | Flow | Runs for | Stages, in order |
|---|---|---|---|
ToolHook | tools:call-tool | tools/call | parseInput, ensureRemoteCapabilities, findTool, checkPublicAccess, checkToolAuthorization, checkEntryAuthorities, createTaskIfRequested, createToolCallContext, checkToolCredentials, acquireQuota, acquireSemaphore; then validateInput, execute, validateOutput; then always releaseSemaphore, releaseQuota, applyUI, finalize |
ListToolsHook | tools:list-tools | tools/list | parseInput, ensureRemoteCapabilities, findTools, filterByAuthorities, resolveConflicts, parseTools |
ResourceHook | resources:read-resource | resources/read | parseInput, ensureRemoteCapabilities, findResource, checkEntryAuthorities, createResourceContext; then execute, validateOutput; then always finalize |
ListResourcesHook | resources:list-resources | resources/list | parseInput, ensureRemoteCapabilities, findResources, filterByAuthorities, resolveConflicts, parseResources |
ListResourceTemplatesHook | resources:list-resource-templates | resources/templates/list | parseInput, ensureRemoteCapabilities, findTemplates, filterByAuthorities, resolveConflicts, parseTemplates |
PromptHook | prompts:get-prompt | prompts/get | parseInput, ensureRemoteCapabilities, findPrompt, checkPublicAccess, checkEntryAuthorities, createPromptContext; then execute, validateOutput; then always finalize |
ListPromptsHook | prompts:list-prompts | prompts/list | parseInput, ensureRemoteCapabilities, findPrompts, filterByAuthorities, resolveConflicts, parsePrompts |
HttpHook | http:request | Every HTTP request, before it's handled | traceRequest, checkIpFilter, acquireQuota, acquireSemaphore, checkAuthorization, acquireIdentityQuota, router, then the transport stages (handleMcp2026, …) and audit, metrics, releaseSemaphore, releaseQuota, finalize |
AgentCallHook | agents:call-agent | A 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 |
JobHook | jobs:execute-job | Each 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, ChannelListHook | channels:send-notification, channels:list | Every 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, OAuthRegisterHook | oauth:authorize, oauth:token, oauth:callback, oauth:register | The 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.
| Member | Description |
|---|---|
ctx.state | What 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.rawInput | The flow's input before parsing. For http:request, ctx.rawInput.request has method, path, headers, query and body. |
ctx.scopeLogger | The 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
| Field | Set by | What it is |
|---|---|---|
ctx.state.input | parseInput | The tools/call params as the client sent them: name, arguments and _meta. Changing arguments before validateInput changes what's validated. |
ctx.state.authInfo, progressToken, jsonRpcRequestId | parseInput | Who's calling, and the request's progress token and JSON-RPC id. |
ctx.state.tool | findTool | The tool: metadata is everything from @Tool({ ... }), including keys FrontMCP doesn't know, and owner.id is its app's id. |
ctx.state.toolContext | createToolCallContext | The 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.rawOutput | validateOutput | What execute() returned. Not set yet in Did("execute"); the output schema is checked later, in finalize. |
ctx.state.resultMeta | Your 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 on | Flows | Runs for | this in the hook |
|---|---|---|---|
A plugin class, or a provider in its providers | Any | An 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 }) | Any | Like a plugin on that app | The provider. For a CONTEXT provider, the instance built for that request (since 1.9) |
A provider in @FrontMcp({ providers }) | Any | Like a plugin on the server: every app's requests (since 1.9) | The provider |
A @Tool class | tools:call-tool: an instance method from Did("createToolCallContext") on, a static method on any stage | That tool's calls | An instance method: the call's tool context, this.input, this.get(). A static method: the class |
A @Resource or @ResourceTemplate class | resources:read-resource: an instance method from Did("createResourceContext") on, a static method on any stage | That resource's reads | The read's resource context, or the class |
A @Prompt class | prompts:get-prompt: an instance method from Did("createPromptContext") on, a static method on any stage | That prompt's requests | The request's prompt context, or the class |
An @Agent class | agents:call-agent: an instance method from Did("createAgentContext") on, a static method on any stage | That agent's calls | The call's agent context, or the class |
A @Job class | jobs:execute-job: an instance method from Did("createJobContext") on, a static method on any stage | That job's attempts | The 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:
Willhooks, lowest priority first.Aroundhooks, lowest priority outermost.- The stage itself:
Stagesteps with priority 0 or less, FrontMCP's own step, thenStagesteps with a higher priority. Didhooks, 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 withisEntryGatedBy(). - 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
Aroundhook, the errornext()rejects with is FrontMCP's: aToolExecutionErrorwrapping whatexecute()threw, or, when the tool calledthis.fail(), an internal error whosemessageis empty. Rethrow it to keep the tool's own error for the client. - To give a call a result from an
Aroundhook that skips the stage, assignctx.state.toolContext.outputor callctx.respond().ctx.state.set("output", …)isn't read: the call ends withFlow exited without producing output. - In
Did("finalize"),ctx.state.outputisn't set yet.ctx.state.rawOutputis whatexecute()returned, if it returned. HttpHookhooks 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:
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:
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:
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:
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:
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:
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
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:
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):
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:
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:
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:
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:
- The class the hook is on is registered: the plugin in
plugins, the provider in@App({ providers }). - 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 aCONTEXTprovider never ran; before 1.8.5,AgentCallHooknever ran anywhere. - The stage exists in the flow: a hook on a stage name that isn't in the stage list never runs.
- The request reaches the stage.
Didhooks are skipped when their stage fails, and a failed stage skips every later stage but the closing ones. - 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 hookappliesTo: "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. filterreturnstruefor 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:
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 }):
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.