server.registerTool()
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.
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() return. Give it the tool's name, its JSON Schema and an execute function:
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();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. |
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. |
availableWhen | EntryAvailability | As on @Tool: see availableWhen and the "webmcp" 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 run their hooks for it and the server's limits count it, as for the app's own tools.
this.scope.toolslists it. - Clients hear about it. Registering and removing it send
notifications/tools/list_changedto connected clients, like aDirectClientfromserver.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.
executegets the arguments as the caller sent them, whateverinputSchemasays, as for other tools with a JSON Schema. Check them yourself. - What it returns is the result. An
isError: trueresult is passed on as it is, soserver.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 andstructuredContent. Returning nothing fails the call withFlow exited without producing output. - When it throws, the call fails with
ToolExecutionError, codeTOOL_EXECUTION_ERROR, messageTool "…" execution failed:and the error's message.server.callTool()rejects with it; a client gets anisErrorresult 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.executedoesn'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 whoseexecutecalledserver.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()andcreateDirect()servers. A server you run with@FrontMcpand serve over HTTP or stdio, acreateFetchHandler()handler and a client fromconnect()have noregisterTool(). Register every tool of those at startup, and hide what isn't ready withavailableWhenor authorities. - 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()registers its tool withregisterTool()on the server you gaveFrontMcpProvider, soserver.callTool()reaches it and the same rules apply. A name a server tool already has is refused, and the provider'sonErrorgetsDynamic tool "search_tickets" was not registered: A tool named "search_tickets" is already registered.
The "webmcp" call surface
availableWhen.surface 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, 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() 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.
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 servercreate()orcreateDirect()returned, fromclose()on the last client fromconnect()of a configuration, which disposes the server it built, and when a Node server started with@FrontMcpshuts down onSIGINTorSIGTERM. - 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
ScopeEntryinto a factory provider, as above, or usethis.scopein 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:
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;
},
};
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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();
}
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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}` }] };
},
});
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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);
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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 offers it to the browser's agents. surface: ["mcp"] does the opposite:
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" };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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():
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;
},
};Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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.
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).
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.
My tool gets arguments its schema doesn't allow
FrontMCP doesn't validate runtime tools' arguments. Parse them in execute.
Tool "…" not found for a tool with surface: ["webmcp"]
The tool is offered only to agents in the browser, through @frontmcp/plugin-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(). A Node server started with @FrontMcp disposes its scopes when it shuts down on SIGINT or SIGTERM.