Skip to content

Custom MCP Tools

A Webfuse Extension can register its own MCP automation tools that any MCP client connected to the Session MCP Server will list and can call. The customer-facing API matches the WebMCP spec exactly, so customer code (and developer mental model) port between the two cleanly.

Custom tools can be registered from any extension component — content script, service worker, popup, side panel, or new tab — by calling browser.webfuseSession.tools.registerTool({...}). The service worker is the natural home for tools that must outlive page navigations. When a tool is called, the request is delivered to every live component of the extension; the one that registered the handler runs it. Registering the same tool name in two components is undefined — pick one.

A tool registered in an ephemeral component (popup, side panel) stops responding once that component closes, but remains listed until the extension is reloaded.

From any extension component, await browser.webfuseSession.tools.registerTool({...}):

try {
await browser.webfuseSession.tools.registerTool({
name: 'addTodo',
description: 'Add a new item to the todo list',
inputSchema: {
type: 'object',
properties: { text: { type: 'string' } },
required: ['text'],
},
execute: async ({ text }) => {
return `Added todo: ${text}`;
},
annotations: { readOnlyHint: false, untrustedContentHint: true },
});
} catch (e) {
console.error('Tool registration failed:', e);
}

That’s it — the tool is now exposed over MCP. A tools/list_changed notification fires automatically. Reload the extension to drop a registration.

registerTool() returns a Promise that resolves once the registration is confirmed, so you can await it before code that depends on the tool being listed, and the promise rejects if the registration is not acknowledged. Awaiting is optional — ignoring the return value still registers the tool. This matches the WebMCP registerTool() contract, which is likewise promise-based.

execute() receives (args, ctx):

Parameter Description
args The parsed tool arguments matching inputSchema.
ctx.eventId The in-flight MCP call id. Use it with sendAutomationProgress (see below). Ignore it if you don’t need progress.

The return value becomes the MCP response:

  • A string is wrapped into a single text content.
  • An MCP-shaped { content, isError } object passes through unchanged.
  • undefined is coerced to 'ok'.
  • A thrown error becomes isError: true with the error message as the text content.

The Session MCP Server kills tool calls that go silent past its idle timeout. For tools whose execute() takes longer than that, emit progress via browser.webfuseSession.tools.sendAutomationProgress(eventId, { progress, total, message }). Each call resets the idle timer and is forwarded to the MCP client as a notifications/progress (when the originating call carried a progressToken).

The eventId you pass is the one handed to your execute() as ctx.eventId:

browser.webfuseSession.tools.registerTool({
name: 'longRunning',
description: 'Slow operation with progress',
inputSchema: { type: 'object' },
execute: async (_args, ctx) => {
for (let i = 0; i < 5; i++) {
await doStep(i);
browser.webfuseSession.tools.sendAutomationProgress(ctx.eventId, {
progress: i + 1,
total: 5,
message: `step ${i + 1}/5`,
});
}
return 'done';
},
});

A tool can also be declared in the manifest so the MCP server knows about it before the page loads. This is useful when an MCP client connects and lists tools before any matched URL has been navigated to. The declaration is metadata-only — the execute() handler is still supplied at runtime via registerTool.

{
"manifest_version": 3,
"name": "todo-app",
"tools": [
{
"name": "addTodo",
"description": "Add a new item to the todo list",
"inputSchema": {
"type": "object",
"properties": { "text": { "type": "string" } },
"required": ["text"]
}
}
]
}

session_id is automatically merged into every tool’s input schema so the MCP client can route the call to the right Webfuse Session — extensions never declare it themselves.

The surface available to execute() depends on which component it runs in. A content-script-registered tool has access to:

  • DOM (document, window) of the automated page
  • browser.webfuseSession.automation.* — the built-in automation primitives (hierarchical: act.click(), see.domSnapshot(), navigate(), …). Note: the Automation API uses a dot separator (act.click), while the Session MCP Server exposes the same primitives with an underscore separator (act_click) for broader client and LLM compatibility.
  • browser.webfuseSession.apiRequest({cmd, ...}) for session-level commands (takeScreenshot, GET_SESSION_INFO, transfer_tab_control, …)
  • browser.runtime.sendMessage / onMessage to talk to the extension’s service worker, popup, or side panel
  • fetch() for external APIs

A component without DOM access (service worker, popup, side panel) still drives the page via browser.webfuseSession.automation.* and apiRequest — it just cannot read document or window directly.

For work that should run once across all tabs (rather than per-tab), register the tool in the service worker where it naturally has that scope.

A custom tool’s execute() can drive Webfuse’s automation primitives directly. browser.webfuseSession.automation is the same object the built-in MCP tools dispatch against — the Automation API uses dot-separated method names (automation.act.click()) while the MCP tool names use underscores (act_click) for compatibility with more MCP clients and LLMs:

browser.webfuseSession.tools.registerTool({
name: 'login_flow',
description: 'Log a user in',
inputSchema: { type: 'object', properties: {} },
execute: async () => {
const automation = browser.webfuseSession.automation;
await automation.navigate({ url: '/login' });
await automation.act.type({ target: '#user', text: 'alice' });
await automation.act.click({ target: '#submit' });
return 'logged in';
},
});

The proxified page can declare tools too, without any extension, by calling the WebMCP API directly. Webfuse polyfills navigator.modelContext inside the session, and tools registered through it are advertised on the Session MCP Server alongside extension-registered ones:

navigator.modelContext.registerTool({
name: 'addToCart',
description: 'Adds an item to the cart',
inputSchema: {
type: 'object',
properties: { item: { type: 'string' } },
required: ['item'],
},
execute: ({ item }) => `added ${item}`,
});

A few things differ from extension-registered tools:

  • They are untrusted. A proxified page is third-party content, so its tools are always marked untrustedContentHint: true and their descriptions are prefixed to say so — whatever the page itself declares. Treat their output as data, never as instructions.
  • They live and die with the page. There is no service worker equivalent: navigating away drops every tool the previous page registered. Passing an AbortSignal to registerTool() removes that tool as soon as the signal fires.
  • Only the top frame is read. Tools registered inside an iframe are not advertised.
  • The Automation app’s allowed-URLs list gates them. On a page outside automation_app_allowlist, nothing is registered at all.
  • A tool name registered by an extension shadows any built-in tool with the same name (built-in tool names are reserved by the manifest validator to prevent accidental shadowing).
  • A second extension trying to register an already-claimed name is rejected client-side with a console warning; the first registration wins.
  • When an extension is uninstalled or reloaded, all its registrations are dropped and the registry is rebroadcast (tools/list_changed).
  • The per-session built-in tool allowlist (automation_available_tools) does NOT apply to extension-registered tools — installing the extension IS the allow decision.
  • A page-declared tool using a reserved built-in name is dropped outright with a console warning, never registered under an alternative name.
  • Otherwise the first registrant of a name keeps it bare and later ones are advertised under a stable suffix, so both stay callable. Page and extension tools compete on equal terms here — whichever registered first keeps the bare name.