# Hooking into Calls

> How FrontMCP runs a tool call as a flow of stages, and how plugin hooks run before and after them to log calls, normalize input, block calls and change results. Which order hooks run in, and what they can change.

Source: https://frontmcp.dev/learn/hooking-into-calls

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

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

## The stages of a tool call

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

```ts trace.plugin.ts active
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk";

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

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

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

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

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

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

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

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

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

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

```ts get-ticket.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@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 }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket } from "./get-ticket.tool";
import { TracePlugin } from "./trace.plugin";

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

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

test("a call that works passes through every stage", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "T-1" });
  expect((await mcp.tools.call("show_trace", {})).json().trace).toEqual([
    "will validateInput",
    "did validateInput",
    "will execute",
    "did execute",
    "will finalize",
    "did finalize",
  ]);
});

test("a call with bad input stops at validateInput, then finalizes", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "ticket one" });
  expect((await mcp.tools.call("show_trace", {})).json().trace).toEqual([
    "will validateInput",
    "will finalize",
    "did finalize",
  ]);
});
```

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

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

| Group | Stages | Runs |
| --- | --- | --- |
| Before | `parseInput`, `ensureRemoteCapabilities`, `findTool`, `checkPublicAccess`, `checkToolAuthorization`, `checkEntryAuthorities`, `createTaskIfRequested`, `createToolCallContext`, `checkToolCredentials`, `acquireQuota`, `acquireSemaphore` | Until one fails |
| Execute | `validateInput`, `execute`, `validateOutput` | Until one fails |
| Finally | `releaseSemaphore`, `releaseQuota`, `applyUI`, `finalize` | Always |

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

The hooks in this lesson are on plugins, so they run for every tool. A tool class can also declare hooks for its own calls, `static` ones included for the stages before its instance exists, like `parseInput`: see [Hook decorators](https://frontmcp.dev/reference/sdk/hooks#hooking-the-stages-before-an-entrys-instance).

## What a hook can read

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

| Field | What it is | From |
| --- | --- | --- |
| `ctx.state.input` | The raw `tools/call` params: the tool `name`, the `arguments` as the client sent them, and `_meta` | `parseInput` |
| `ctx.state.tool` | The tool being called. `metadata` holds everything from `@Tool({ ... })`, and `owner.id` is its app's `id` | `findTool` |
| `ctx.state.toolContext` | This call's tool instance: `input` (checked against the schema once `validateInput` is done), `output` (once `execute` is done) and `get()` for providers | `createToolCallContext` |

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

## Logging every call

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

```ts call-log.plugin.ts active
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk";

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

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

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

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

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

```ts get-ticket.tool.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@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 }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket } from "./get-ticket.tool";
import { CallLogPlugin } from "./call-log.plugin";

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

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

test("calls are logged with their outcome and duration", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "T-1" }); // works
  await mcp.tools.call("get_ticket", { id: "T-9" }); // execute() fails
  await mcp.tools.call("get_ticket", { id: "ticket one" }); // rejected before execute()
  expect((await mcp.tools.call("recent_calls", {})).json().calls).toEqual([
    { tool: "get_ticket", outcome: "ok", ms: expect.any(Number) },
    { tool: "get_ticket", outcome: "failed", ms: expect.any(Number) },
  ]);
});

test("a failed call still gets the tool's error", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-9" });
  expect(result.raw._meta?.code).toBe("PUBLIC_ERROR");
  expect(result.text()).toBe("There's no ticket T-9.");
});
```

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

## Normalizing input

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

```ts ticket-ids.plugin.ts active
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";

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

```ts get-ticket.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@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 }) {
    return { id, title: "Invoice total is wrong", status: "closed" };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket } from "./get-ticket.tool";
import { TicketIdsPlugin } from "./ticket-ids.plugin";

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

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

test("\" t-2 \" is read as T-2", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: " t-2 " });
  expect(result).toBeSuccessful();
  expect(result.json().id).toBe("T-2");
});

test("the schema still rejects ids that aren't ticket ids", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "ticket two" });
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("INVALID_INPUT");
});
```

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

For a single tool, you can do the same in its schema with `z.string().trim().toUpperCase()` ([Schemas Are Contracts](https://frontmcp.dev/learn/schemas-are-contracts#what-reaches-execute) covers it). The hook's advantage is reach: it cleans up every tool's `id`, including tools added later or by other plugins.

## Blocking a call

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

```ts read-only.plugin.ts active
import { DynamicPlugin, FlowCtxOf, ListToolsHook, Plugin, PublicMcpError, ToolHook } from "@frontmcp/sdk";

type ReadOnlyOptions = { enabled: boolean };

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

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

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

```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() },
  annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}

@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" };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CloseTicket, GetTicket } from "./tools";
import { ReadOnlyPlugin } from "./read-only.plugin";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket, CloseTicket],
  plugins: [ReadOnlyPlugin.init({ enabled: true })],
})
export class HelpDeskApp {}
```

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

test("close_ticket is left out of tools/list", async ({ mcp }) => {
  expect(await mcp.tools.list()).not.toContainTool("close_ticket");
});

test("calling it anyway is refused with the hook's message", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result).toBeError();
  expect(result).toHaveTextContent("read-only during maintenance");
  expect(result.raw._meta?.code).toBe("PUBLIC_ERROR");
});

test("read-only tools still work", async ({ mcp }) => {
  expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeSuccessful();
});
```

A few things to notice:

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

## Changing the result

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

```ts redact.plugin.ts active
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";

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

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

```ts get-ticket.tool.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", customer: { name: "Dana Levi", email: "dana@acme.com" } };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket } from "./get-ticket.tool";
import { RedactPlugin } from "./redact.plugin";

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

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

test("the email is redacted in the structured result", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result.raw.structuredContent).toEqual({
    id: "T-1",
    title: "Cannot log in",
    customer: { name: "Dana Levi", email: "[redacted]" },
  });
});

test("and in the text the model reads", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result).not.toHaveTextContent("dana@acme.com");
});
```

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

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

```ts cache.plugin.ts active
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";

type CallCtx = FlowCtxOf<"tools:call-tool">;
const readOnly = (ctx: CallCtx) => ctx.state.tool?.metadata.annotations?.readOnlyHint === true;

@Plugin({ name: "cache", description: "Answers repeated read-only calls from memory" })
export class CachePlugin extends DynamicPlugin<object> {
  private results = new Map<string, Record<string, unknown>>();

  private key(ctx: CallCtx) {
    return `${ctx.state.tool?.metadata.name}:${JSON.stringify(ctx.state.toolContext?.input)}`;
  }

  @ToolHook.Will("execute", { filter: readOnly })
  async answerFromCache(ctx: CallCtx) {
    const cached = this.results.get(this.key(ctx));
    if (cached) ctx.respond({ content: [{ type: "text", text: JSON.stringify(cached) }], structuredContent: cached });
  }

  @ToolHook.Did("execute", { filter: readOnly })
  async store(ctx: CallCtx) {
    this.results.set(this.key(ctx), ctx.state.toolContext?.output as Record<string, unknown>);
  }
}
```

```ts get-ticket.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { Database } from "./database";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id",
  inputSchema: { id: z.string() },
  annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return this.get(Database).loadTicket(id);
  }
}
```

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

@Provider({ name: "Database" })
export class Database {
  private queries = 0;

  loadTicket(id: string) {
    this.queries += 1;
    return { id, title: "Cannot log in", query: this.queries };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket } from "./get-ticket.tool";
import { Database } from "./database";
import { CachePlugin } from "./cache.plugin";

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

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

test("asking for T-1 twice queries the database once", async ({ mcp }) => {
  const first = await mcp.tools.call("get_ticket", { id: "T-1" });
  const second = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(first.json()).toEqual({ id: "T-1", title: "Cannot log in", query: 1 });
  expect(second.json()).toEqual({ id: "T-1", title: "Cannot log in", query: 1 });
});

test("a different ticket is a different entry", async ({ mcp }) => {
  expect((await mcp.tools.call("get_ticket", { id: "T-2" })).json().query).toBe(2);
});
```

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

What `respond()` sends, from a hook and from a tool, and where it doesn't work, is in the [`this.respond()` reference](https://frontmcp.dev/reference/sdk/respond).

## The order hooks run in

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

```ts plugins.ts active
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";
import { Trace } from "./trace";

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

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

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

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

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

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

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

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

@Provider({ name: "Trace", scope: ProviderScope.CONTEXT })
export class Trace {
  lines: string[] = [];
}
```

```ts get-ticket.tool.ts
import { 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() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    this.get(Trace).lines.push("get_ticket runs");
    return { id, trace: this.get(Trace).lines };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket } from "./get-ticket.tool";
import { Trace } from "./trace";
import { FirstPlugin, SecondPlugin } from "./plugins";

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

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

test("hooks run by priority, lowest first", async ({ mcp }) => {
  const trace = (await mcp.tools.call("get_ticket", { id: "T-1" })).json().trace;
  expect(trace).toEqual([
    "second: will, priority -10",
    "first: will, priority 0",
    "second: around, before next()",
    "get_ticket runs",
    "second: around, after next()",
    "second: did, priority 0",
    "first: did, priority 10",
  ]);
});
```

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

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

## Recap

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

## Try some challenges

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

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

```ts ticket-ids.plugin.ts
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";

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

```ts ticket-ids.plugin.ts solution
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";

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

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

const ticketId = z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1");

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: ticketId } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Invoice total is wrong" };
  }
}

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

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CloseTicket, GetTicket } from "./tools";
import { TicketIdsPlugin } from "./ticket-ids.plugin";

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

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

test("`get_ticket` reads \" t-2 \" as T-2", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: " t-2 " });
  expect(result).toBeSuccessful();
  expect(result.json().id).toBe("T-2");
});

test("it works for every tool, like `close_ticket`", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "t-4" });
  expect(result).toBeSuccessful();
  expect(result.json().id).toBe("T-4");
});

test("\"ticket two\" is still rejected", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "ticket two" });
  expect(result.raw._meta?.code).toBe("INVALID_INPUT");
});
```

**Hint:**
Look at the stage table. Which stage rejects `" t-2 "`, and does it run before or after `execute`?

**Solution:**
`validateInput` runs before `execute`, so a hook in `Will("execute")` never sees an id the schema rejected. In `Will("validateInput")` the arguments haven't been checked yet, so the hook edits `ctx.state.input.arguments`, the raw arguments, and the schema then checks the cleaned-up value.

### Challenge: Freeze changes over the weekend
Add a hook to `FreezePlugin` that refuses every tool not marked `readOnlyHint`, with a `PublicMcpError` whose message mentions the freeze. The refused tool must not run.

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

@Plugin({ name: "freeze", description: "Refuses changes during the weekend freeze" })
export class FreezePlugin extends DynamicPlugin<object> {}
```

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

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

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

@Provider({ name: "TicketStore" })
export class TicketStore {
  status: Record<string, string> = { "T-1": "open", "T-2": "open" };
}

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id",
  inputSchema: { id: z.string() },
  annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: this.get(TicketStore).status[id] };
  }
}

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

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CloseTicket, GetTicket, TicketStore } from "./tools";
import { FreezePlugin } from "./freeze.plugin";

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

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

test("`close_ticket` is refused with a message about the freeze", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result).toBeError();
  expect(result.text()).toMatch(/freeze/i);
  expect(result.raw._meta?.code).toBe("PUBLIC_ERROR");
});

test("the ticket stays open, because the tool never ran", async ({ mcp }) => {
  await mcp.tools.call("close_ticket", { id: "T-2" });
  expect((await mcp.tools.call("get_ticket", { id: "T-2" })).json().status).toBe("open");
});

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

**Hint:**
`@ToolHook.Will("execute", { filter })` runs right before a tool, only for calls where `filter` returns `true`. Throw from the hook to stop the call.

**Solution:**
The hook runs before `execute()`, so throwing there stops the call before `close_ticket` can change anything, and the `filter` leaves read-only tools alone. Because the error is a `PublicMcpError` thrown from a hook, the model gets the message as written, with the code `PUBLIC_ERROR`. A good message says why the call was refused and when to try again.

### Challenge: Redact every email
Customer emails must never reach the model, and `list_tickets` returns several customers. Add a hook to `RedactPlugin` that replaces the value of every `email` field in a tool's result, however deeply nested, with `"[redacted]"`.

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

@Plugin({ name: "redact", description: "Removes email addresses from tool results" })
export class RedactPlugin extends DynamicPlugin<object> {}
```

```ts redact.plugin.ts solution
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";

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

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

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

const tickets = [
  { id: "T-1", title: "Cannot log in", customer: { name: "Dana Levi", email: "dana@acme.com" } },
  { id: "T-2", title: "Invoice total is wrong", customer: { name: "Omar Aziz", email: "omar@globex.com" } },
];

@Tool({ name: "list_tickets", description: "List every support ticket with its customer", inputSchema: {} })
export class ListTickets extends ToolContext {
  async execute() {
    return { tickets };
  }
}

@Tool({ name: "get_customer", description: "Get the customer of one ticket", inputSchema: { id: z.string() } })
export class GetCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return tickets.find((t) => t.id === id)?.customer ?? { name: "unknown" };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { GetCustomer, ListTickets } from "./tools";
import { RedactPlugin } from "./redact.plugin";

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

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

test("no email address appears in `list_tickets`", async ({ mcp }) => {
  const result = await mcp.tools.call("list_tickets", {});
  expect(JSON.stringify(result.raw)).not.toContain("@");
});

test("each customer's `email` is \"[redacted]\"", async ({ mcp }) => {
  const result = await mcp.tools.call("list_tickets", {});
  expect(result.json().tickets.map((t: any) => t.customer.email)).toEqual(["[redacted]", "[redacted]"]);
});

test("`get_customer` is redacted too, and names are kept", async ({ mcp }) => {
  const result = await mcp.tools.call("get_customer", { id: "T-2" });
  expect(result.json()).toEqual({ name: "Omar Aziz", email: "[redacted]" });
});
```

**Hint:**
`Did("execute")` sees the tool's return value as `ctx.state.toolContext.output`. Build a redacted copy and assign it back; a small recursive function handles arrays and nested objects.

**Solution:**
The hook replaces `output` after every tool returns and before FrontMCP formats it, so both the text block and `structuredContent` are built from the redacted copy. Returning a copy instead of editing in place also keeps the tool's own data intact: `tickets` still has the real addresses for the next call.

### Challenge: Clean up before checking the lock
Ticket T-7 is locked for a legal hold, and `LockPlugin` refuses any call for it. But a model that sends `" t-7 "` gets through and closes it. Change the order of the two hooks so the lock check sees the cleaned-up id.

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

type CallCtx = FlowCtxOf<"tools:call-tool">;
const locked = ["T-7"];

@Plugin({ name: "lock", description: "Normalizes ticket ids and refuses calls for locked tickets" })
export class LockPlugin extends DynamicPlugin<object> {
  @ToolHook.Will("validateInput")
  async checkLock(ctx: CallCtx) {
    const id = ctx.state.input?.arguments?.id;
    if (typeof id === "string" && locked.includes(id)) {
      throw new PublicMcpError(`Ticket ${id} is locked for a legal hold and can't be changed.`);
    }
  }

  @ToolHook.Will("validateInput", { priority: 10 })
  async normalizeTicketId(ctx: CallCtx) {
    const args = ctx.state.input?.arguments;
    if (typeof args?.id === "string") args.id = args.id.trim().toUpperCase();
  }
}
```

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

type CallCtx = FlowCtxOf<"tools:call-tool">;
const locked = ["T-7"];

@Plugin({ name: "lock", description: "Normalizes ticket ids and refuses calls for locked tickets" })
export class LockPlugin extends DynamicPlugin<object> {
  @ToolHook.Will("validateInput", { priority: 10 })
  async checkLock(ctx: CallCtx) {
    const id = ctx.state.input?.arguments?.id;
    if (typeof id === "string" && locked.includes(id)) {
      throw new PublicMcpError(`Ticket ${id} is locked for a legal hold and can't be changed.`);
    }
  }

  @ToolHook.Will("validateInput")
  async normalizeTicketId(ctx: CallCtx) {
    const args = ctx.state.input?.arguments;
    if (typeof args?.id === "string") args.id = args.id.trim().toUpperCase();
  }
}
```

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

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

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CloseTicket } from "./tools";
import { LockPlugin } from "./lock.plugin";

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

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

test("\" t-7 \" is refused as locked", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: " t-7 " });
  expect(result).toBeError();
  expect(result).toHaveTextContent("T-7 is locked");
});

test("\"T-7\" is refused as locked", async ({ mcp }) => {
  expect(await mcp.tools.call("close_ticket", { id: "T-7" })).toHaveTextContent("locked");
});

test("other tickets still close, in any case", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: " t-1 " });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({ id: "T-1", status: "closed" });
});
```

**Hint:**
Both hooks are on `Will("validateInput")`, so `priority` decides. Which one runs first now?

**Solution:**
Lower priorities run first. With `normalizeTicketId` at the default `0` and `checkLock` at `10`, the id is cleaned up before the lock check reads it, so `" t-7 "` is refused like `"T-7"`. Moving the methods around in the class wouldn't have helped: the order in the class only breaks ties between equal priorities.
