# server.registerTool()

> Add a tool to a running create() or createDirect() server from code outside it, and remove it again; the 'webmcp' call surface; and scope.onDispose(), which runs code when the server is disposed. New in FrontMCP 1.9.

Source: https://frontmcp.dev/reference/sdk/register-tool

`server.registerTool()` adds a tool to a server that's already running, from code outside the server: a page that shows a ticket, a React component, a script that finds out at run time what it can offer. The tool joins the server's app and from then on is a tool like the app's own: `tools/list` lists it, a call runs through the same flow, with the app's plugin hooks, limits and `availableWhen`, and connected clients get `notifications/tools/list_changed`. It returns a function that removes the tool. This page also covers the `"webmcp"` call surface, for tools only agents in the user's browser should see, and `scope.onDispose()`, which runs code when the server is disposed. All three are new in FrontMCP 1.9.

```ts
const unregister = await server.registerTool({ name, description?, title?, inputSchema?, annotations?, availableWhen?, app?, execute })
unregister()

availableWhen: { surface: ["webmcp"] }   // only agents in the browser, through WebMCP

const remove = scope.onDispose(callback)
```

---

## Reference

### `server.registerTool(definition)`

`registerTool()` is a method of the server [`create()` and `createDirect()`](https://frontmcp.dev/reference/sdk/create) return. Give it the tool's name, its JSON Schema and an `execute` function:

```ts ticket-view.ts
import { create } from "@frontmcp/sdk";
import { SearchTickets } from "./search-tickets.tool";

const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [SearchTickets] });

const unregister = await server.registerTool({
  name: "get_open_ticket",
  description: "The ticket the support agent has open on screen",
  inputSchema: { type: "object", properties: {} },
  annotations: { readOnlyHint: true },
  execute: async () => ({ content: [{ type: "text", text: JSON.stringify(openTicket) }] }),
});

// when the ticket is closed
unregister();
```

[See more examples below.](#usage)

#### The definition

| Field | Type | Description |
| --- | --- | --- |
| `name` | `string` | **Required.** 1 to 64 characters, and no other tool of the server may have it. |
| `execute(args, { signal })` | function | **Required.** Runs the tool. Returns an MCP result, `{ content, structuredContent?, isError? }`, or a promise of one. `args` is what the caller sent; `signal` is aborted when the call is cancelled. [See below.](#how-execute-is-called) |
| `description` | `string` | What the tool does, for the model choosing it. |
| `title` | `string` | A name for people. |
| `inputSchema` | JSON Schema | The arguments, as `tools/list` shows them. It must be an object schema (`type: "object"`). Defaults to `{ type: "object", properties: {} }`. **Not checked against what callers send**: validate in `execute`. |
| `annotations` | `ToolAnnotations` | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, as on [`@Tool`](https://frontmcp.dev/reference/sdk/tool#describing-side-effects-with-annotations). |
| `availableWhen` | `EntryAvailability` | As on `@Tool`: see [`availableWhen`](https://frontmcp.dev/reference/server/environment#availablewhen) and [the `"webmcp"` surface](#the-webmcp-call-surface). |
| `app` | `string` | The id of the app the tool joins. Needed only when the server has more than one app; a `create()` server has exactly one. |

#### Return value

A promise of a function that removes the tool. Calling it again does nothing. Once removed, the tool isn't listed, a call answers `Tool "…" not found`, and connected clients get `notifications/tools/list_changed` again.

#### What the tool gets

- **It's one of the app's tools.** Its owner is the app it joined, so the app's [plugins](https://frontmcp.dev/reference/sdk/plugin) run their hooks for it and the server's [limits](https://frontmcp.dev/reference/sdk/guard) count it, as for the app's own tools. `this.scope.tools` lists it.
- **Clients hear about it.** Registering and removing it send `notifications/tools/list_changed` to connected clients, like a [`DirectClient`](https://frontmcp.dev/reference/sdk/connect#the-directclient) from `server.connect()`.
- **The name is shared with the whole server.** A tool of any app, FrontMCP's own included, takes the name: a runtime tool is never renamed to `<app>:<name>` the way two apps' tools with one name are.
- **It lasts until it's removed or the server is disposed.** It isn't stored anywhere: a server built again, or another instance of it, doesn't have it.

#### How `execute` is called

- **Arguments aren't validated.** `execute` gets the arguments as the caller sent them, whatever `inputSchema` says, as for other tools with a JSON Schema. [Check them yourself](#checking-the-arguments).
- **What it returns is the result.** An `isError: true` result is passed on as it is, so `server.callTool()` returns it and doesn't reject. From plain JavaScript, a value that isn't an MCP result is sent as a tool's return value is: an object as JSON text and `structuredContent`. Returning nothing fails the call with `Flow exited without producing output`.
- **When it throws,** the call fails with `ToolExecutionError`, code `TOOL_EXECUTION_ERROR`, message `Tool "…" execution failed: ` and the error's message. `server.callTool()` rejects with it; a client gets an `isError` result with that text.
- **It runs outside the call's turn.** In a browser, without `AsyncContext`, FrontMCP's browser build serves requests one at a time. `execute` doesn't hold that turn while it runs, so it can call the server back, or wait on the network, without blocking other requests: in Chrome, a runtime tool whose `execute` called `server.callTool()` got the other tool's result. On Node, nothing waits either way.

#### Errors

`registerTool()` rejects when the tool can't be added:

| Problem | Rejects with |
| --- | --- |
| Another tool of the server has the name | `ToolNameConflictError`, code `TOOL_NAME_CONFLICT`: `A tool named "get_open_ticket" is already registered` |
| The name is empty or longer than 64 characters | `EntryValidationError`, code `ENTRY_VALIDATION_FAILED`: `Tool entry validation failed: runtime tool name must be 1-64 characters` |
| No `execute` function | `EntryValidationError`: `… runtime tool "x" has no execute function` |
| `inputSchema` isn't an object schema | `EntryValidationError`: `… runtime tool "x" input schema must be an object schema: type: Invalid input: expected "object"` |
| The server has several apps and the definition names none | `EntryValidationError`: `… runtime tool "x" must name its app (one of: help-desk, billing)` |
| `app` names no app of the server | `EntryValidationError`: `… runtime tool "x" names app "crm", which is not here` |
| The server was disposed | `InternalMcpError`: `DirectMcpServer has been disposed` |

`ToolNameConflictError` and `EntryValidationError` are exported from `@frontmcp/sdk`, with the types `RuntimeToolDefinition` and `RuntimeToolExecuteContext`.

#### Caveats

- **Only on `create()` and `createDirect()` servers.** A server you run with `@FrontMcp` and serve over HTTP or stdio, a [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) handler and a client from [`connect()`](https://frontmcp.dev/reference/sdk/connect) have no `registerTool()`. Register every tool of those at startup, and hide what isn't ready with [`availableWhen`](https://frontmcp.dev/reference/server/environment#availablewhen) or [authorities](https://frontmcp.dev/learn/authorizing-calls).
- **Arguments aren't validated.**
- **A runtime tool is there for every caller.** It isn't per user or per session.
- **React's dynamic tools are runtime tools.** Since 1.9, [`useDynamicTool()`](https://frontmcp.dev/reference/react/hooks#usedynamictool) registers its tool with `registerTool()` on the server you gave `FrontMcpProvider`, so `server.callTool()` reaches it and the same rules apply. A name a server tool already has is refused, and the provider's `onError` gets `Dynamic tool "search_tickets" was not registered: A tool named "search_tickets" is already registered`.

### The `"webmcp"` call surface

[`availableWhen.surface`](https://frontmcp.dev/reference/server/environment#surfaces) says who a tool is offered to. FrontMCP 1.9 adds `"webmcp"`: the calls an agent in the user's browser makes through [`@frontmcp/plugin-webmcp`](https://frontmcp.dev/reference/plugins/webmcp), which offers a page's tools to the browser's agents through WebMCP (`document.modelContext`).

| `availableWhen` | MCP clients and `server.callTool()` | Agents in the browser, through WebMCP |
| --- | --- | --- |
| no `surface` | Offered | Offered |
| `{ surface: ["webmcp"] }` | Not offered: not listed, and a call answers `Tool "…" not found` | Offered |
| `{ surface: ["mcp"] }` | Offered | Not offered |
| `{ surface: ["mcp", "webmcp"] }` | Offered | Offered |

It works on `@Tool`, `tool()` and runtime tools alike. Inside a tool, [`getCallSurface()`](https://frontmcp.dev/reference/server/environment#surfaces) returns `"webmcp"` while an agent in the browser calls it.

### `scope.onDispose(callback)`

Registers a function to run when the server's scope is disposed, and returns a function that removes it. Plugins and providers use it to release what they hold outside the server: a timer, a subscription, the tools a page registered with the browser. `@frontmcp/plugin-webmcp` removes its WebMCP tools this way.

```ts ticket-feed.provider.ts
import { ScopeEntry } from "@frontmcp/sdk";

export const ticketFeed = {
  name: "TicketFeed",
  provide: TicketFeed,
  inject: () => [ScopeEntry] as const,
  useFactory: (scope: ScopeEntry) => {
    const feed = new TicketFeed();
    scope.onDispose(() => feed.stop());
    return feed;
  },
};
```

- **When it runs:** once, from `dispose()` on the server `create()` or `createDirect()` returned, from `close()` on the last client from [`connect()`](https://frontmcp.dev/reference/sdk/connect) of a configuration, which disposes the server it built, and when a Node server started with `@FrontMcp` shuts down on `SIGINT` or `SIGTERM`.
- **In reverse order:** the last callback registered runs first. Each is awaited before the next.
- **A callback that throws** is logged, `Scope dispose callback failed: …`, and the others still run. `dispose()` doesn't reject.
- **Registered after the scope was disposed,** a callback runs right away.
- **Where to get the scope:** inject `ScopeEntry` into a factory provider, as above, or use [`this.scope`](https://frontmcp.dev/reference/sdk/scope) in a tool.

`scope.onServerStarted()`, the other lifecycle hook, never runs for a `create()` server, which starts no HTTP server. `onDispose()` does.

---

## Usage

### Adding a tool while something is on screen

A support agent's console shows one ticket at a time. While a ticket is open, the model can read it with `get_open_ticket`; when the agent closes it, the tool goes away. The view registers the tool once and reads the current ticket in `execute`, so it never needs registering again:

```ts ticket-view.ts active
import type { DirectMcpServer } from "@frontmcp/sdk";

type Ticket = { id: string; title: string; status: string };

export function openTicketView(server: DirectMcpServer) {
  let shown: Ticket | undefined;
  let unregister: (() => void) | undefined;

  return {
    async show(ticket: Ticket) {
      shown = ticket;
      unregister ??= await server.registerTool({
        name: "get_open_ticket",
        description: "The ticket the support agent has open on screen",
        inputSchema: { type: "object", properties: {} },
        annotations: { readOnlyHint: true },
        execute: async () => ({ content: [{ type: "text", text: JSON.stringify(shown) }], structuredContent: shown }),
      });
    },
    close() {
      unregister?.();
      unregister = undefined;
      shown = undefined;
    },
  };
}
```

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

@Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } })
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { query, tickets: [{ id: "T-1", title: "Cannot log in" }] };
  }
}
```

```ts ticket-view.test.ts
import { test, expect } from "@frontmcp/testing";
import { create } from "@frontmcp/sdk";
import { openTicketView } from "./ticket-view";
import { SearchTickets } from "./search-tickets.tool";

const info = { name: "help-desk", version: "1.0.0" };

test("the tool is listed and callable while a ticket is open", async () => {
  const server = await create({ info, tools: [SearchTickets] });
  try {
    const view = openTicketView(server);
    await view.show({ id: "T-1", title: "Cannot log in", status: "open" });

    const { tools } = await server.listTools();
    expect(tools.map((t) => t.name)).toEqual(["get_open_ticket", "search_tickets"]);
    expect(tools[0]).toEqual({
      name: "get_open_ticket",
      description: "The ticket the support agent has open on screen",
      inputSchema: { type: "object", properties: {} },
      annotations: { readOnlyHint: true },
    });
    expect((await server.callTool("get_open_ticket")).structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" });

    await view.show({ id: "T-2", title: "Refund", status: "pending" });
    expect((await server.callTool("get_open_ticket")).structuredContent).toMatchObject({ id: "T-2" });
  } finally {
    await server.dispose();
  }
});

test("closing the ticket removes the tool", async () => {
  const server = await create({ info, tools: [SearchTickets] });
  try {
    const view = openTicketView(server);
    await view.show({ id: "T-1", title: "Cannot log in", status: "open" });
    view.close();
    view.close();
    expect((await server.listTools()).tools.map((t) => t.name)).toEqual(["search_tickets"]);
    await expect(server.callTool("get_open_ticket")).rejects.toMatchObject({ code: "TOOL_NOT_FOUND", message: 'Tool "get_open_ticket" not found' });
  } finally {
    await server.dispose();
  }
});

test("a name that's taken is refused", async () => {
  const server = await create({ info, tools: [SearchTickets] });
  try {
    const view = openTicketView(server);
    await view.show({ id: "T-1", title: "Cannot log in", status: "open" });
    const again = server.registerTool({ name: "get_open_ticket", execute: () => ({ content: [] }) });
    await expect(again).rejects.toMatchObject({ name: "ToolNameConflictError", code: "TOOL_NAME_CONFLICT", message: 'A tool named "get_open_ticket" is already registered' });
    const static_ = server.registerTool({ name: "search_tickets", execute: () => ({ content: [] }) });
    await expect(static_).rejects.toMatchObject({ code: "TOOL_NAME_CONFLICT" });
  } finally {
    await server.dispose();
  }
});
```

The Playground's own server, which its **Call** tab talks to, is built from the exported tools and has no `registerTool()`: only the tests' servers have the runtime tool.

### Telling connected clients

A client connected with `server.connect()` gets `notifications/tools/list_changed` each time a tool is added or removed, and its next `listTools()` has the change:

```ts list-changed.test.ts active
import { test, expect } from "@frontmcp/testing";
import { create } from "@frontmcp/sdk";
import { SearchTickets } from "./search-tickets.tool";

test("each change reaches a connected client", async () => {
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [SearchTickets] });
  const client = await server.connect();
  const heard: string[] = [];
  client.onNotification((notification) => heard.push(notification.method));
  try {
    const unregister = await server.registerTool({ name: "get_open_ticket", execute: () => ({ content: [{ type: "text", text: "T-1" }] }) });
    await new Promise((resolve) => setTimeout(resolve, 10));
    expect(heard).toEqual(["notifications/tools/list_changed"]);
    expect((await client.listTools()).map((t: { name: string }) => t.name)).toContain("get_open_ticket");

    unregister();
    await new Promise((resolve) => setTimeout(resolve, 10));
    expect(heard).toEqual(["notifications/tools/list_changed", "notifications/tools/list_changed"]);
    expect((await client.listTools()).map((t: { name: string }) => t.name)).not.toContain("get_open_ticket");
  } finally {
    await client.close();
    await server.dispose();
  }
});
```

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

@Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } })
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { query, tickets: [] };
  }
}
```

### Checking the arguments

`inputSchema` is only what `tools/list` shows. FrontMCP doesn't check calls against it, so `execute` gets whatever the caller sent. Parse the arguments yourself, here with the SDK's `z`, and answer bad ones with an `isError` result the model can act on:

```ts add-note.ts active
import { toJSONSchema, z, type DirectMcpServer } from "@frontmcp/sdk";

const AddNote = z.object({ ticketId: z.string().regex(/^T-\d+$/), note: z.string().min(1) });

export const notes: { ticketId: string; note: string }[] = [];

export function registerAddNote(server: DirectMcpServer) {
  return server.registerTool({
    name: "add_internal_note",
    description: "Add a note to a ticket, visible to the support team only",
    inputSchema: toJSONSchema(AddNote) as Record<string, unknown>,
    execute: async (args) => {
      const parsed = AddNote.safeParse(args);
      if (!parsed.success) {
        return { isError: true, content: [{ type: "text", text: `Invalid arguments: ${z.prettifyError(parsed.error)}` }] };
      }
      notes.push(parsed.data);
      return { content: [{ type: "text", text: `Note added to ${parsed.data.ticketId}` }] };
    },
  });
}
```

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

@Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } })
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { query, tickets: [] };
  }
}
```

```ts add-note.test.ts
import { test, expect } from "@frontmcp/testing";
import { create } from "@frontmcp/sdk";
import { notes, registerAddNote } from "./add-note";
import { SearchTickets } from "./search-tickets.tool";

const info = { name: "help-desk", version: "1.0.0" };

test("the schema is listed as given", async () => {
  const server = await create({ info, tools: [SearchTickets] });
  try {
    await registerAddNote(server);
    const tool = (await server.listTools()).tools.find((t) => t.name === "add_internal_note");
    expect(tool?.inputSchema).toMatchObject({ type: "object", required: ["ticketId", "note"] });
  } finally {
    await server.dispose();
  }
});

test("bad arguments reach execute, which refuses them", async () => {
  const server = await create({ info, tools: [SearchTickets] });
  try {
    await registerAddNote(server);
    const result = await server.callTool("add_internal_note", { ticketId: 7 });
    expect(result.isError).toBe(true);
    expect(result.content).toEqual([{ type: "text", text: expect.stringContaining("Invalid arguments") }]);
    expect(notes).toEqual([]);

    await server.callTool("add_internal_note", { ticketId: "T-1", note: "Customer called back" });
    expect(notes).toEqual([{ ticketId: "T-1", note: "Customer called back" }]);
  } finally {
    await server.dispose();
  }
});

test("a throw fails the call", async () => {
  const server = await create({ info, tools: [SearchTickets] });
  try {
    await server.registerTool({ name: "sync_tickets", execute: () => { throw new Error("desk API is down"); } });
    await expect(server.callTool("sync_tickets")).rejects.toMatchObject({
      code: "TOOL_EXECUTION_ERROR",
      message: 'Tool "sync_tickets" execution failed: desk API is down',
    });
  } finally {
    await server.dispose();
  }
});
```

### Joining one of several apps

A server from `createDirect()` can have several apps. A runtime tool joins one, named by `app`, and that app's plugins run their hooks for it. Here the help-desk app counts every call with a plugin, and the billing app has none:

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

export const calls: string[] = [];

@Plugin({ name: "usage", description: "Records every tool call" })
export class UsagePlugin extends DynamicPlugin<object> {
  @ToolHook.Did("execute")
  async record(ctx: FlowCtxOf<"tools:call-tool">) {
    const name = ctx.state.tool?.metadata.name;
    if (name) calls.push(name);
  }
}
```

```ts apps.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { UsagePlugin } from "./usage.plugin";

@Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } })
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { query, tickets: [] };
  }
}

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

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

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

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp] };
```

```ts apps.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./apps";
import { calls } from "./usage.plugin";

const openTicket = { content: [{ type: "text" as const, text: "T-1" }] };

test("with several apps, the tool names its app", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  try {
    await expect(server.registerTool({ name: "get_open_ticket", execute: () => openTicket })).rejects.toMatchObject({
      code: "ENTRY_VALIDATION_FAILED",
      message: 'Tool entry validation failed: runtime tool "get_open_ticket" must name its app (one of: help-desk, billing)',
    });
    await expect(server.registerTool({ name: "get_open_ticket", app: "crm", execute: () => openTicket })).rejects.toThrow(
      'runtime tool "get_open_ticket" names app "crm", which is not here',
    );
  } finally {
    await server.dispose();
  }
});

test("the app's plugin hooks run for the runtime tool", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  try {
    await server.registerTool({ name: "get_open_ticket", app: "help-desk", execute: () => openTicket });
    await server.registerTool({ name: "get_open_invoice", app: "billing", execute: () => openTicket });
    await server.callTool("get_open_ticket");
    await server.callTool("get_open_invoice");
    await server.callTool("search_tickets", { query: "log" });
    expect(calls).toEqual(["get_open_ticket", "search_tickets"]);
  } finally {
    await server.dispose();
  }
});
```

### Tools only agents in the browser see

A tool that fills in a form on the page is for an agent working in that page, not for an MCP client somewhere else. `availableWhen: { surface: ["webmcp"] }` keeps it from MCP clients, `server.callTool()` included, while [`@frontmcp/plugin-webmcp`](https://frontmcp.dev/reference/plugins/webmcp) offers it to the browser's agents. `surface: ["mcp"]` does the opposite:

```ts reply-form.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "fill_reply_form",
  description: "Type a reply into the open ticket's reply box, for the support agent to review and send",
  inputSchema: { text: z.string() },
  availableWhen: { surface: ["webmcp"] },
})
export class FillReplyForm extends ToolContext {
  async execute({ text }: { text: string }) {
    return { filled: text.length };
  }
}

@Tool({
  name: "export_tickets",
  description: "Export every ticket as CSV",
  inputSchema: {},
  availableWhen: { surface: ["mcp"] },
})
export class ExportTickets extends ToolContext {
  async execute() {
    return { csv: "id,title\nT-1,Cannot log in" };
  }
}
```

```ts surface.test.ts
import { test, expect } from "@frontmcp/testing";
import { create } from "@frontmcp/sdk";
import { ExportTickets, FillReplyForm } from "./reply-form.tool";

test("an MCP client isn't offered the webmcp-only tool", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t: { name: string }) => t.name)).toEqual(["export_tickets"]);
  expect((await mcp.tools.call("fill_reply_form", { text: "hi" })).text()).toBe('Tool "fill_reply_form" not found');
});

test("nor is server.callTool(), and runtime tools follow the same rule", async () => {
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [FillReplyForm, ExportTickets] });
  try {
    await server.registerTool({
      name: "get_open_ticket",
      availableWhen: { surface: ["webmcp"] },
      execute: () => ({ content: [{ type: "text", text: "T-1" }] }),
    });
    expect((await server.listTools()).tools.map((t) => t.name)).toEqual(["export_tickets"]);
    await expect(server.callTool("get_open_ticket")).rejects.toMatchObject({ code: "TOOL_NOT_FOUND" });
  } finally {
    await server.dispose();
  }
});
```

### Releasing what a provider holds

A provider that starts a timer should stop it when the server is disposed. Inject `ScopeEntry` into its factory and register the stop with `onDispose()`:

```ts ticket-feed.provider.ts active
import { ScopeEntry } from "@frontmcp/sdk";

export const log: string[] = [];

export class TicketFeed {
  private timer = setInterval(() => this.poll(), 60_000);
  poll() {}
  stop() {
    clearInterval(this.timer);
    log.push("feed stopped");
  }
}

export const ticketFeed = {
  name: "TicketFeed",
  provide: TicketFeed,
  inject: () => [ScopeEntry] as const,
  useFactory: (scope: ScopeEntry) => {
    const feed = new TicketFeed();
    scope.onDispose(() => feed.stop());
    return feed;
  },
};
```

```ts watch-queue.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";
import { TicketFeed, log } from "./ticket-feed.provider";

@Tool({ name: "watch_queue", description: "Watch the ticket queue until the server stops", inputSchema: {} })
export class WatchQueue extends ToolContext {
  async execute() {
    this.get(TicketFeed);
    this.scope.onDispose(() => void log.push("watch ended"));
    return { watching: true };
  }
}
```

```ts dispose.test.ts
import { test, expect } from "@frontmcp/testing";
import { create } from "@frontmcp/sdk";
import { log, ticketFeed } from "./ticket-feed.provider";
import { WatchQueue } from "./watch-queue.tool";

test("callbacks run once, last registered first", async () => {
  log.length = 0;
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [WatchQueue], providers: [ticketFeed] });
  await server.callTool("watch_queue");
  expect(log).toEqual([]);
  await server.dispose();
  await server.dispose();
  expect(log).toEqual(["watch ended", "feed stopped"]);
});
```

---

## Troubleshooting

### `A tool named "…" is already registered`

Another tool of the server has the name: a tool of any of its apps, FrontMCP's own, or a runtime tool registered earlier and not removed. Runtime tools aren't renamed to `<app>:<name>`. Remove the earlier one with the function `registerTool()` returned, or pick another name. A component that registers on every render should register once and read its current state in `execute`, as in [Adding a tool while something is on screen](#adding-a-tool-while-something-is-on-screen).

### `Tool entry validation failed: runtime tool "…" must name its app (one of: …)`

The server has more than one app, so FrontMCP can't tell which the tool joins. Add `app` with one of the ids listed. See [Joining one of several apps](#joining-one-of-several-apps).

### `Tool entry validation failed: runtime tool "…" input schema must be an object schema`

`inputSchema` is a JSON Schema whose `type` isn't `"object"`, or a Zod schema. Pass a JSON Schema object, `{ type: "object", properties: { … } }`; from a Zod object, `toJSONSchema(schema)` from `@frontmcp/sdk` (Zod's own `z.toJSONSchema()` throws on an optional object inside the schema: see [`z`, `eagerZ` and `lazyZ`](https://frontmcp.dev/reference/sdk#z-eagerz-and-lazyz)).

### `server.registerTool is not a function`

The object isn't a server from `create()` or `createDirect()`. A `@FrontMcp` class, a `createFetchHandler()` handler and a `connect()` client don't have it. See [Caveats](#caveats).

### My tool gets arguments its schema doesn't allow

FrontMCP doesn't validate runtime tools' arguments. [Parse them in `execute`](#checking-the-arguments).

### `Tool "…" not found` for a tool with `surface: ["webmcp"]`

The tool is offered only to agents in the browser, through [`@frontmcp/plugin-webmcp`](https://frontmcp.dev/reference/plugins/webmcp). MCP clients and `server.callTool()` call on the `"mcp"` surface. Add `"mcp"` to `surface` if they should reach it.

### An `onDispose()` callback never runs

The scope wasn't disposed: nothing called `dispose()` on the `create()` or `createDirect()` server, or `close()` on the last `connect()` client of its configuration. Call `dispose()` in a `finally`, as in [`create()`](https://frontmcp.dev/reference/sdk/create#disposing). A Node server started with `@FrontMcp` disposes its scopes when it shuts down on `SIGINT` or `SIGTERM`.
