# Hook decorators

> Run code before, after, around or inside a stage of every request with Will, Did, Around and Stage hooks. The flows and stages you can hook, priority and filter, what ctx can read and change, where hooks can be declared, and the order they run in.

Source: https://frontmcp.dev/reference/sdk/hooks

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](https://frontmcp.dev/reference/sdk/plugin), providers, and the classes of tools, resources, prompts, agents and jobs. [Hooking into Calls](https://frontmcp.dev/learn/hooking-into-calls) teaches them step by step.

```ts
@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](https://frontmcp.dev/reference/sdk/plugin), a provider, or a tool, resource, prompt, agent or job class ([where hooks can be declared](#where-hooks-can-be-declared)).

```ts 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.](#usage)

| 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](#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`](https://frontmcp.dev/reference/sdk/plugin#enforcing-an-option-of-your-own). |

#### 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](#hooking-an-agents-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](https://frontmcp.dev/reference/sdk/job): run by `execute_job`, while the client waits or in the background, each retry, and each [workflow](https://frontmcp.dev/reference/sdk/workflow) step (since 1.9.4). See [Hooking a job's attempts](#hooking-a-jobs-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`](https://frontmcp.dev/reference/sdk/channel#hooks-on-channel-flows). | `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](#toolscall-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`](https://frontmcp.dev/reference/sdk/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`](#adding-to-the-results-_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"`](#options)), 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`](https://frontmcp.dev/reference/sdk/plugin#where-you-register-a-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](#hooking-the-stages-before-an-entrys-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()`](https://frontmcp.dev/reference/sdk/plugin#hiding-what-a-plugin-refuses).
- **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`](#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:

```ts trace.plugin.ts active
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");
  }
}
```

```ts trace.ts
import { Provider } from "@frontmcp/sdk";

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

```ts tools.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { Trace } from "./trace";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (id === "T-9") this.fail(new PublicMcpError("There's no ticket T-9."));
    return { id, title: "Cannot log in" };
  }
}

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

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket, ShowTrace } from "./tools";
import { TracePlugin } from "./trace.plugin";

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, ShowTrace], plugins: [TracePlugin] })
export class HelpDeskApp {}
```

```ts stages.test.ts
import { test, expect } from "@frontmcp/testing";

const traceOf = async (mcp: any) => (await mcp.tools.call("show_trace", {})).json().trace;

test("a call that works runs every hook", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(await traceOf(mcp)).toEqual(["will validateInput", "did validateInput", "will execute", "did execute", "did finalize"]);
});

test("when execute fails, its Did hook is skipped", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "T-9" });
  expect(await traceOf(mcp)).toEqual(["will validateInput", "did validateInput", "will execute", "did finalize"]);
});

test("when validation fails, execute is skipped entirely", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "ticket nine" });
  expect(await traceOf(mcp)).toEqual(["will validateInput", "did finalize"]);
});
```

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:

```ts retry.plugin.ts active
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
    }
  }
}
```

```ts billing.ts
import { Provider, Tool, ToolContext, z } from "@frontmcp/sdk";

// Stands in for a service that drops the first request of each pair
@Provider({ name: "BillingApi" })
export class BillingApi {
  requests = 0;

  fetchInvoice(id: string) {
    this.requests += 1;
    if (this.requests % 2 === 1) throw new Error("Connection reset");
    return { id, total: 120, attempt: this.requests };
  }
}

@Tool({
  name: "get_invoice",
  description: "Get one invoice by its id",
  inputSchema: { id: z.string() },
  annotations: { readOnlyHint: true, idempotentHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return this.get(BillingApi).fetchInvoice(id);
  }
}

@Tool({ name: "charge_invoice", description: "Charge an invoice to the customer's card", inputSchema: { id: z.string() } })
export class ChargeInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return this.get(BillingApi).fetchInvoice(id);
  }
}
```

```ts billing.app.ts
import { App } from "@frontmcp/sdk";
import { BillingApi, ChargeInvoice, GetInvoice } from "./billing";
import { RetryPlugin } from "./retry.plugin";

@App({ id: "billing", name: "Billing", tools: [GetInvoice, ChargeInvoice], providers: [BillingApi], plugins: [RetryPlugin] })
export class BillingApp {}
```

```ts retry.test.ts
import { test, expect } from "@frontmcp/testing";

test("an idempotent tool is retried, and the second attempt's result is sent", async ({ mcp }) => {
  const result = await mcp.tools.call("get_invoice", { id: "INV-7" });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({ id: "INV-7", total: 120, attempt: 2 });
});

test("other tools aren't retried", async ({ mcp }) => {
  const result = await mcp.tools.call("charge_invoice", { id: "INV-7" });
  expect(result).toBeError();
  expect(result).toHaveTextContent("Connection reset");
});
```

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](#flow-exited-without-producing-output).

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

```ts renames.plugin.ts active
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,
    });
  }
}
```

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}

@Tool({ name: "get_invoice", description: "Get one invoice by its id", inputSchema: { id: z.string() } })
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, total: 120 };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { GetInvoice, GetTicket } from "./tools";
import { RenamesPlugin } from "./renames.plugin";

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [RenamesPlugin] })
export class HelpDeskApp {}

@App({ id: "billing", name: "Billing", tools: [GetInvoice] })
export class BillingApp {}

@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp] })
export default class Server {}
```

```ts renames.test.ts
import { test, expect } from "@frontmcp/testing";

test("the old name gets a result that names the new one", async ({ mcp }) => {
  const result = await mcp.tools.call("find_ticket", { id: "T-1" });
  expect(result).toBeError();
  expect(result.text()).toBe("find_ticket was renamed to get_ticket. Call get_ticket with the same arguments.");
});

test("other unknown names still fail as usual", async ({ mcp }) => {
  const result = await mcp.tools.call("delete_everything", {});
  expect(result).not.toHaveTextContent("renamed");
});

test("the new name works", async ({ mcp }) => {
  expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeSuccessful();
});
```

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`](https://frontmcp.dev/reference/sdk/respond#answering-a-call-from-a-hook).

### 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](https://frontmcp.dev/reference/plugins/cache) marks a hit this way, with `cache: "hit"`. Here a plugin marks every result as audited:

```ts audit.plugin.ts active
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 });
  }
}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { AuditPlugin } from "./audit.plugin";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id",
  inputSchema: { id: z.string() },
  outputSchema: { id: z.string(), status: z.string() },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "open" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [AuditPlugin] })
export class HelpDeskApp {}
```

```ts result-meta.test.ts
import { test, expect } from "@frontmcp/testing";

test("`resultMeta` goes into `_meta`", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result.raw._meta?.audited).toBe(true);
});

test("the tool's data is what `execute()` returned", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result.raw.structuredContent).toEqual({ id: "T-1", status: "open" });
  expect(result.json()).toEqual({ id: "T-1", status: "open" });
});
```

### 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`:

```ts reads.plugin.ts active
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)}`);
  }
}
```

```ts entries.ts
import { Prompt, PromptContext, ResourceContext, ResourceTemplate, type GetPromptResult } from "@frontmcp/sdk";

@ResourceTemplate({ name: "ticket", uriTemplate: "desk://tickets/{id}", mimeType: "application/json" })
export class TicketResource extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}

@Prompt({
  name: "summarize_ticket",
  description: "Summarize a ticket for a handover",
  arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }],
})
export class SummarizeTicket extends PromptContext {
  async execute({ id }: Record<string, string>): Promise<GetPromptResult> {
    return { messages: [{ role: "user", content: { type: "text", text: `Summarize ticket ${id} for the next agent on shift.` } }] };
  }
}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext } from "@frontmcp/sdk";
import { SummarizeTicket, TicketResource } from "./entries";
import { ReadLog, ReadsPlugin } from "./reads.plugin";

@Tool({ name: "read_log", description: "Show resource reads and prompt requests", inputSchema: {} })
export class ShowReadLog extends ToolContext {
  async execute() {
    return { lines: [...this.get(ReadLog).lines] };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [ShowReadLog],
  resources: [TicketResource],
  prompts: [SummarizeTicket],
  plugins: [ReadsPlugin],
})
export class HelpDeskApp {}
```

```ts reads.test.ts
import { test, expect } from "@frontmcp/testing";

test("reads and prompt requests are both logged", async ({ mcp }) => {
  await mcp.resources.read("desk://tickets/T-1");
  await mcp.prompts.get("summarize_ticket", { id: "T-2" });
  expect((await mcp.tools.call("read_log", {})).json().lines).toEqual([
    "read desk://tickets/T-1",
    'prompt summarize_ticket {"id":"T-2"}',
  ]);
});
```

### 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`:

```ts hide-internal.plugin.ts active
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/")));
  }
}
```

```ts main.ts
import { App, FrontMcp, Resource, ResourceContext } from "@frontmcp/sdk";
import { HideInternalPlugin } from "./hide-internal.plugin";

@Resource({ name: "open-tickets", uri: "desk://open-tickets", mimeType: "application/json" })
export class OpenTickets extends ResourceContext {
  async execute() {
    return { count: 12 };
  }
}

@Resource({ name: "escalations", uri: "desk://internal/escalations", mimeType: "application/json" })
export class Escalations extends ResourceContext {
  async execute() {
    return { escalated: ["T-7"] };
  }
}

@Resource({ name: "ledger", uri: "billing://internal/ledger", mimeType: "application/json" })
export class Ledger extends ResourceContext {
  async execute() {
    return { balance: 0 };
  }
}

@App({ id: "help-desk", name: "Help Desk", resources: [OpenTickets, Escalations], plugins: [HideInternalPlugin] })
export class HelpDeskApp {}

// No plugin here
@App({ id: "billing", name: "Billing", resources: [Ledger] })
export class BillingApp {}

@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp] })
export default class Server {}
```

```ts list.test.ts
import { test, expect } from "@frontmcp/testing";

test("internal resources aren't listed", async ({ mcp }) => {
  const resources = await mcp.resources.list();
  expect(resources).toContainResource("desk://open-tickets");
  expect(resources).not.toContainResource("desk://internal/escalations");
});

test("the help desk app's list hook hides the billing app's resource too", async ({ mcp }) => {
  expect(await mcp.resources.list()).not.toContainResource("billing://internal/ledger");
});

test("hiding isn't blocking: a hidden resource can still be read", async ({ mcp }) => {
  expect((await mcp.resources.read("desk://internal/escalations")).json()).toEqual({ escalated: ["T-7"] });
});
```

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:

<Examples title="Declaring hooks without a plugin">

#### Example: On a tool
```ts schedule-callback.tool.ts active
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 };
  }
}
```

```ts callback.test.ts
import { test, expect } from "@frontmcp/testing";

test("a time in the past is refused by the tool's own hook", async ({ mcp }) => {
  const result = await mcp.tools.call("schedule_callback", { id: "T-1", at: "2020-01-01T09:00:00Z" });
  expect(result).toBeError();
  expect(result.text()).toBe("2020-01-01T09:00:00Z is in the past. Pick a time after now.");
});

test("a future time is scheduled", async ({ mcp }) => {
  expect(await mcp.tools.call("schedule_callback", { id: "T-1", at: "2027-01-01T09:00:00Z" })).toBeSuccessful();
});
```

#### Example: On an app's provider
```ts legal-hold.ts active
import { FlowCtxOf, Provider, PublicMcpError, ToolHook } from "@frontmcp/sdk";

@Provider({ name: "LegalHold" })
export class LegalHold {
  held = ["T-7"];

  // On a provider in @App({ providers }): runs for every tool in that app
  @ToolHook.Will("execute")
  async refuseHeldTickets(ctx: FlowCtxOf<"tools:call-tool">) {
    const id = (ctx.state.toolContext?.input as { id?: string } | undefined)?.id;
    if (id && this.held.includes(id)) throw new PublicMcpError(`Ticket ${id} is on legal hold.`);
  }
}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { LegalHold } from "./legal-hold";

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "closed" };
  }
}

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket, GetTicket], providers: [LegalHold] })
export class HelpDeskApp {}
```

```ts hold.test.ts
import { test, expect } from "@frontmcp/testing";

test("the provider's hook runs for every tool in the app", async ({ mcp }) => {
  expect(await mcp.tools.call("close_ticket", { id: "T-7" })).toHaveTextContent("legal hold");
  expect(await mcp.tools.call("get_ticket", { id: "T-7" })).toHaveTextContent("legal hold");
  expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeSuccessful();
});
```

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](#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:

```ts get-ticket.tool.ts active
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" };
  }
}
```

```ts static-hooks.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Tool, ToolContext, ToolHook } from "@frontmcp/sdk";

test("the static hook cleans up the id before the schema checks it", async ({ mcp }) => {
  expect((await mcp.tools.call("get_ticket", { id: " t-2 " })).json()).toEqual({ id: "T-2", title: "Cannot log in" });
});

test("it runs for its own tool only", async ({ mcp }) => {
  expect(await mcp.tools.call("close_ticket", { id: " t-2 " })).toBeError("INVALID_INPUT");
});

test("an archived ticket is refused before the tool is built", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "t-0" });
  expect(result).toBeError("PUBLIC_ERROR");
  expect(result.text()).toBe("T-0 is archived.");
});

test("an instance method on an early stage stops the server from starting", async () => {
  @Tool({ name: "early", description: "Hooks a stage it can't reach", inputSchema: {} })
  class Early extends ToolContext {
    @ToolHook.Will("findTool")
    async tooSoon() {}

    async execute() {
      return {};
    }
  }
  @App({ id: "help-desk", name: "Help Desk", tools: [Early] })
  class HelpDeskApp {}

  await expect(FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] })).rejects.toThrow(
    /tooSoon\(\) \(will 'findTool' of tools:call-tool\): runs before 'createToolCallContext' builds the instance it runs on; declare it as a static method/,
  );
});
```

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](#tool--declares-hooks-that-would-never-run)).

### 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`](https://frontmcp.dev/reference/sdk/agent#giving-the-agents-tools-a-plugin)):

```ts call-log.plugin.ts active
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}`);
  }
}
```

```ts main.ts
import { Agent, AgentContext, App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CallLog } from "./call-log.plugin";

@Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}

// A stand-in model: it reads the ticket, then answers.
@Agent({
  name: "triage",
  inputSchema: { ticketId: z.string() },
  tools: [GetTicket],
  llm: {
    adapter: {
      async completion(prompt) {
        if (prompt.messages.length === 1) return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "1", name: "get_ticket", arguments: { id: "T-1" } }] };
        return { content: "High", finishReason: "stop" };
      },
    },
  },
})
export class Triage extends AgentContext {}

@App({ id: "help-desk", name: "Help Desk", agents: [Triage] })
export class HelpDeskApp {}

@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [CallLog] })
export default class Server {}
```

```ts agent-hooks.test.ts
import { test, expect } from "@frontmcp/testing";
import { calls } from "./call-log.plugin";

test("the tool call, then the agent's own flow; not the agent's tool", async ({ mcp }) => {
  calls.length = 0;
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(calls).toEqual(["tool invoke_triage", "agent triage"]);
});
```

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](https://frontmcp.dev/reference/sdk/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](https://frontmcp.dev/reference/sdk/workflow). Here a plugin records how each attempt ended, and answers `count-open-tickets` from a snapshot instead of running it:

```ts job-audit.plugin.ts active
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"] });
  }
}
```

```ts jobs.ts
import { Job, JobContext, z } from "@frontmcp/sdk";

let mailCalls = 0;
export let counted = 0;

@Job({ name: "notify-agent", inputSchema: { agent: z.string() }, outputSchema: { sent: z.boolean() }, retry: { maxAttempts: 3, backoffMs: 10 } })
export class NotifyAgent extends JobContext {
  async execute() {
    mailCalls += 1;
    if (mailCalls % 3 !== 0) throw new Error("Mail server busy");
    return { sent: true };
  }
}

@Job({ name: "count-open-tickets", inputSchema: {}, outputSchema: { open: z.number() } })
export class CountOpenTickets extends JobContext {
  async execute() {
    counted += 1;
    return { open: 15 };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { JobAudit } from "./job-audit.plugin";
import { CountOpenTickets, NotifyAgent } from "./jobs";

@App({ id: "help-desk", name: "Help Desk", jobs: [NotifyAgent, CountOpenTickets], plugins: [JobAudit] })
export class HelpDeskApp {}
```

```ts job-hooks.test.ts
import { test, expect } from "@frontmcp/testing";
import { attempts } from "./job-audit.plugin";
import { counted } from "./jobs";

test("each attempt runs the flow, retries included", async ({ mcp }) => {
  attempts.length = 0;
  const result = await mcp.tools.call("execute_job", { name: "notify-agent", input: { agent: "nour" } });
  expect(result.json()).toMatchObject({ state: "completed", result: { sent: true } });
  expect(attempts).toEqual([
    "notify-agent attempt 1: Mail server busy",
    "notify-agent attempt 2: Mail server busy",
    "notify-agent attempt 3: ok",
  ]);
});

test("a hook's `respond()` is the run's result, and the job doesn't run", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: {} });
  expect(result.json()).toMatchObject({ state: "completed", result: { open: 12 }, logs: ["Read from the 06:00 snapshot"] });
  expect(counted).toBe(0);
});
```

`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`](https://frontmcp.dev/reference/server/flows#jobsexecute-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`:

```ts requests.plugin.ts active
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;
  }
}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext } from "@frontmcp/sdk";
import { RequestCounts, RequestsPlugin } from "./requests.plugin";

@Tool({ name: "request_counts", description: "How many requests of each kind the server has had", inputSchema: {} })
export class ShowRequestCounts extends ToolContext {
  async execute() {
    return { ...this.get(RequestCounts).byMethod };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [ShowRequestCounts], plugins: [RequestsPlugin] })
export class HelpDeskApp {}
```

```ts requests.test.ts
import { test, expect } from "@frontmcp/testing";

test("every request is counted by its JSON-RPC method", async ({ mcp }) => {
  await mcp.tools.list();
  await mcp.resources.list();
  await mcp.tools.call("no_such_tool", {});
  expect((await mcp.tools.call("request_counts", {})).json()).toEqual({
    "tools/list": 1,
    "resources/list": 1,
    "tools/call": 2,
  });
});
```

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:

```ts plugins.ts active
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");
  }
}
```

```ts trace.ts
import { FlowCtxOf, Provider, ProviderScope } from "@frontmcp/sdk";

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

// One per call, so each call's result shows only its own trace
@Provider({ name: "Trace", scope: ProviderScope.CONTEXT })
export class Trace {
  lines: string[] = [];
}

export const trace = (ctx: CallCtx, line: string) => ctx.state.toolContext?.get(Trace).lines.push(line);
```

```ts main.ts
import { App, FlowCtxOf, FrontMcp, Provider, Tool, ToolContext, ToolHook, z } from "@frontmcp/sdk";
import { FirstPlugin, SecondPlugin, ServerPlugin } from "./plugins";
import { Trace, trace } from "./trace";

@Provider({ name: "Guard" })
export class Guard {
  @ToolHook.Will("execute")
  async will(ctx: FlowCtxOf<"tools:call-tool">) {
    trace(ctx, "app provider: Will");
  }
}

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  @ToolHook.Will("execute")
  async will(ctx: FlowCtxOf<"tools:call-tool">) {
    trace(ctx, "tool class: Will");
  }

  async execute({ id }: { id: string }) {
    this.get(Trace).lines.push("get_ticket runs");
    return { id, trace: this.get(Trace).lines };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket],
  providers: [Guard, Trace],
  plugins: [FirstPlugin, SecondPlugin],
})
export class HelpDeskApp {}

@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [ServerPlugin] })
export default class Server {}
```

```ts order.test.ts
import { test, expect } from "@frontmcp/testing";

test("hooks run by kind, then priority, then where they're declared", async ({ mcp }) => {
  const { trace } = (await mcp.tools.call("get_ticket", { id: "T-1" })).json();
  expect(trace).toEqual([
    "second plugin: Will, priority -10",
    "app provider: Will",
    "first plugin: Will",
    "server plugin: Will",
    "tool class: Will",
    "second plugin: Around, before next()",
    "first plugin: Around, priority 5, before next()",
    "second plugin: Stage",
    "get_ticket runs",
    "first plugin: Stage, priority 10",
    "first plugin: Around, priority 5, after next()",
    "second plugin: Around, after next()",
    "second plugin: Did",
    "first plugin: Did, priority 10",
  ]);
});
```

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](#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](#decorator-sets-and-their-flows) 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"`](#options) 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](#hooking-the-stages-before-an-entrys-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:

```ts log-errors.plugin.ts active
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
    }
  }
}
```

```ts help-desk.app.ts
import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { LogErrorsPlugin } from "./log-errors.plugin";

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (id !== "T-1") this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return { id, title: "Cannot log in" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [LogErrorsPlugin] })
export class HelpDeskApp {}
```

```ts swallowed.test.ts
import { test, expect } from "@frontmcp/testing";

test("the tool's error is replaced by FLOW_EXITED_WITHOUT_OUTPUT", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-9" });
  expect(result.raw._meta?.code).toBe("FLOW_EXITED_WITHOUT_OUTPUT");
  expect(result.text()).toBe("Flow exited without producing output");
});
```

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 })`:

```ts region.plugin.ts active
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}`);
  }
}
```

```ts desk-settings.ts
import { Provider } from "@frontmcp/sdk";

@Provider({ name: "DeskSettings" })
export class DeskSettings {
  region = "eu";
}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { DeskSettings } from "./desk-settings";
import { RegionPlugin } from "./region.plugin";

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], providers: [DeskSettings], plugins: [RegionPlugin] })
export class HelpDeskApp {}
```

```ts provider.test.ts
import { test, expect } from "@frontmcp/testing";

test("ctx.get() doesn't find the app's provider", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result).toBeError();
  expect(result).toHaveTextContent('Provider "DeskSettings" is not available');
});
```

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](#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](https://frontmcp.dev/learn/hooking-into-calls#changing-the-result).

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