Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ offers explicit broad all-tabs scanning with continuous disclosure, and records
sanitized WebMCP catalogs in the local discovery inbox. Discoveries can be
explicitly promoted into durable page registrations, which bind matching open
documents to live catalog sessions. The authenticated Streamable HTTP endpoint
at `/mcp` exposes one stable, read-only `webby` broker tool for inspecting
status, browsers, discoveries, registered pages, sessions, and current catalogs.
Page tool invocation remains disabled until the next delivery slice.
at `/mcp` exposes one stable `webby` broker tool for inspecting status, browsers,
discoveries, registered pages, sessions, and current catalogs. Explicitly scoped
clients can invoke `page.call`; Webby pins each call to a browser, immutable Chrome
document ID, and catalog revision, propagates cancellation, bounds arguments,
results, and execution time, and stores metadata-only invocation audits.

Webby is independent software. It has no dependency on Labby or any other MCP
gateway, and no particular MCP client receives privileged integration.
Expand Down
5 changes: 4 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,15 @@ config :logger, :default_formatter,
:session_id,
:session_count,
:catalog_revision,
:call_id,
:replaced_count,
:observation_count,
:publication_id,
:path,
:reason,
:timeout_ms
:timeout_ms,
:outcome,
:duration_ms
]

# Use Jason for JSON parsing in Phoenix
Expand Down
13 changes: 13 additions & 0 deletions extension/src/globals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Globals the extension itself installs in a page's main world.
*
* These are Webby's own, not part of any specification -- declaring them here
* keeps the WebMCP surface in `webmcp-types` the only externally-owned
* contract the probe is checked against.
*/

/**
* In-flight WebMCP tool calls for this document, keyed by call id, so a
* cancellation can abort the exact call it names.
*/
declare var __webbyToolCalls: Map<string, AbortController> | undefined;
133 changes: 119 additions & 14 deletions extension/src/probe.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
/**
* Normalizes a WebMCP catalog into the shape Webby transports and records.
*
* Shared by discovery and invocation on purpose. `invokeWebMcp` compares its
* normalized catalog against the one the server recorded from `probeWebMcp`;
* if the two normalizations ever diverged, every invocation would fail with
* `stale_catalog`.
*
* @param {readonly WebMCP.RegisteredTool[]} tools
* @returns {Array<{name: string, description: string, input_schema: unknown}>}
*/
function normalizeCatalog(tools) {
return Array.from(tools ?? []).slice(0, 64).flatMap((tool) => {
if (!tool || typeof tool.name !== "string") return [];
let inputSchema = readInputSchema(tool);
if (typeof inputSchema === "string") {
try { inputSchema = JSON.parse(inputSchema); } catch { return []; }
}
return [{
name: tool.name,
description: typeof tool.description === "string" ? tool.description : "",
input_schema: inputSchema ?? {}
}];
});
}

/**
* Reads a document's WebMCP catalog from the page's main world.
*
Expand All @@ -12,25 +38,104 @@ export async function probeWebMcp() {
const context = document.modelContext;
if (!context || typeof context.getTools !== "function") return {supported: false, tools: []};
try {
const tools = await context.getTools();
const summary = Array.from(tools ?? []).slice(0, 64).flatMap((tool) => {
if (!tool || typeof tool.name !== "string") return [];
let inputSchema = readInputSchema(tool);
if (typeof inputSchema === "string") {
try { inputSchema = JSON.parse(inputSchema); } catch { return []; }
}
return [{
name: tool.name,
description: typeof tool.description === "string" ? tool.description : "",
input_schema: inputSchema ?? {}
}];
});
return {supported: true, tools: summary};
return {supported: true, tools: normalizeCatalog(await context.getTools())};
} catch {
return {supported: false, tools: []};
}
}

/**
* Invokes one tool on the current document, if the catalog still matches.
*
* @param {string} toolName
* @param {unknown} input
* @param {string} callId
* @param {string} expectedCatalog
* @returns {Promise<unknown>}
*/
export async function invokeWebMcp(toolName, input, callId, expectedCatalog) {
const context = document.modelContext;
if (!context || typeof context.getTools !== "function") throw new Error("webmcp_unavailable");

const executeTool = unspecifiedExecuteTool(context);
if (!executeTool) throw new Error("webmcp_unavailable");

const tools = Array.from(await context.getTools() ?? []);
if (JSON.stringify(stable(normalizeCatalog(tools))) !== expectedCatalog) {
throw new Error("stale_catalog");
}

const tool = tools.find((candidate) => candidate?.name === toolName);
if (!tool) throw new Error("tool_not_found");

const controllers = globalThis.__webbyToolCalls ??= new Map();
const controller = new AbortController();
controllers.set(callId, controller);
try {
const result = await executeTool(tool, JSON.stringify(input ?? {}), {signal: controller.signal});
if (typeof result !== "string") return result;
try { return JSON.parse(result); } catch { return result; }
} finally {
controllers.delete(callId);
}
}

/**
* @param {string} callId
* @returns {boolean}
*/
export function cancelWebMcp(callId) {
const controller = globalThis.__webbyToolCalls?.get(callId);
if (!controller) return false;
controller.abort();
return true;
}

/**
* Key order is not observable through the WebMCP surface, so the catalog is
* canonicalized before it is compared as a string.
*
* @param {unknown} value
* @returns {unknown}
*/
function stable(value) {
if (Array.isArray(value)) return value.map(stable);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, stable(/** @type {Record<string, unknown>} */ (value)[key])])
);
}
return value;
}

/**
* `executeTool()` is NOT part of the WebMCP specification.
*
* The upstream README still reads "TODO: Spec and describe the
* `modelContext.getTools()` and `modelContext.executeTool()` APIs", and
* webmachinelearning/webmcp#51 -- the issue defining how an agent invokes a
* site's declared tools -- has been open since 2025-11-03. `getTools()` was
* specced in #223; its sibling was not.
*
* Webby therefore feature-detects it and reports `webmcp_unavailable` rather
* than simulating invocation, as section 21 of the design spec requires. This
* function is the single boundary where that unspecified surface enters typed
* code, kept deliberately narrow so everything the spec *does* define stays
* checked. When upstream specs it, `webmcp-types` will publish a signature and
* the contract check will report the version bump; replace this then.
*
* @param {WebMCP.ModelContext} context
* @returns {((tool: WebMCP.RegisteredTool, input: string, options?: {signal?: AbortSignal}) => Promise<unknown>) | undefined}
*/
function unspecifiedExecuteTool(context) {
const loose = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (context));
const execute = loose.executeTool;
if (typeof execute !== "function") return undefined;
return /** @type {(tool: WebMCP.RegisteredTool, input: string, options?: {signal?: AbortSignal}) => Promise<unknown>} */ (
execute.bind(context)
);
}

/**
* The specification declares `RegisteredTool.inputSchema` as a stringified JSON
* Schema, and that spelling is what the type check pins.
Expand Down
68 changes: 64 additions & 4 deletions extension/src/service_worker.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {WebbyChannel} from "./channel.js";
import {buildObservation, canScanTab} from "./scanning.js";
import {probeWebMcp} from "./probe.js";
import {cancelWebMcp, invokeWebMcp, probeWebMcp} from "./probe.js";
import {reconcileModeAfterRemoval} from "./permissions.js";

const DEFAULTS = {baseUrl: "http://127.0.0.1:6477", scanningMode: "granted_sites", scanningPaused: false};
Expand Down Expand Up @@ -93,9 +93,69 @@ async function resumeAndScan() {
}

async function handleServerEvent(envelope) {
if (envelope?.type !== "pairing.approved" || !envelope.payload?.browser_id) return;
if (channel?.browserId === envelope.payload.browser_id) return;
await chrome.storage.local.set({browserId: envelope.payload.browser_id});
if (envelope?.type === "pairing.approved" && envelope.payload?.browser_id) {
if (channel?.browserId !== envelope.payload.browser_id) {
await chrome.storage.local.set({browserId: envelope.payload.browser_id});
}
return;
}
if (envelope?.type === "tool.call") return executeToolCall(envelope.payload);
if (envelope?.type === "tool.cancel") return cancelToolCall(envelope.payload);
}

async function executeToolCall(payload) {
const observation = observations.get(payload.tab_id);
if (!observation || observation.document_id !== payload.document_id) {
return sendToolError(payload.call_id, "stale_document", "The requested document is no longer active");
}
const expectedCatalog = stableStringify(observation.tools);
try {
const [execution] = await chrome.scripting.executeScript({
target: {tabId: payload.tab_id, documentIds: [payload.document_id]},
world: "MAIN",
func: invokeWebMcp,
args: [payload.tool_name, payload.arguments ?? {}, payload.call_id, expectedCatalog]
});
const result = execution?.result;
if (encodedSize(result) > 131_072 || jsonDepth(result) > 32) throw new Error("result_too_large");
await channel.message("tool.result", {call_id: payload.call_id, result});
} catch (error) {
const kind = knownToolError(error?.message) ? error.message : "tool_failed";
await sendToolError(payload.call_id, kind, "The page tool could not be completed");
}
}

async function cancelToolCall(payload) {
const observation = [...observations.values()].find((entry) => entry.document_id === payload.document_id);
if (!observation) return;
await chrome.scripting.executeScript({
target: {tabId: observation.tab_id, documentIds: [observation.document_id]},
world: "MAIN", func: cancelWebMcp, args: [payload.call_id]
}).catch(() => {});
}

function sendToolError(callId, kind, message) {
return channel.message("tool.error", {call_id: callId, error: {kind, message}}).catch(() => {});
}

function stableStringify(value) {
const stable = (item) => Array.isArray(item) ? item.map(stable) :
item && typeof item === "object" ? Object.fromEntries(Object.keys(item).sort().map((key) => [key, stable(item[key])])) : item;
return JSON.stringify(stable(value));
}

function encodedSize(value) {
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
}

function jsonDepth(value, depth = 0) {
if (!value || typeof value !== "object") return depth;
const values = Array.isArray(value) ? value : Object.values(value);
return values.reduce((maximum, item) => Math.max(maximum, jsonDepth(item, depth + 1)), depth);
}

function knownToolError(kind) {
return ["webmcp_unavailable", "stale_catalog", "tool_not_found", "AbortError"].includes(kind);
}

async function scanAll() {
Expand Down
37 changes: 37 additions & 0 deletions extension/test/invocation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import {cancelWebMcp, invokeWebMcp} from "../src/probe.js";

test("executes only the named tool from the expected catalog", async () => {
const tool = {name: "find", description: "Find", inputSchema: {type: "object"}};
globalThis.document = {modelContext: {
getTools: async () => [tool],
executeTool: async (selected, input) => {
assert.equal(selected, tool);
assert.deepEqual(JSON.parse(input), {query: "hello"});
return '{"count":1}';
}
}};

const catalog = '[{"description":"Find","input_schema":{"type":"object"},"name":"find"}]';
assert.deepEqual(await invokeWebMcp("find", {query: "hello"}, "call-1", catalog), {count: 1});
delete globalThis.document;
});

test("aborts the exact pending page call", async () => {
const tool = {name: "wait", description: "Wait", inputSchema: {}};
globalThis.document = {modelContext: {
getTools: async () => [tool],
executeTool: (_tool, _input, {signal}) => new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new Error("aborted")));
})
}};

const catalog = '[{"description":"Wait","input_schema":{},"name":"wait"}]';
const pending = invokeWebMcp("wait", {}, "call-2", catalog);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(cancelWebMcp("call-2"), true);
await assert.rejects(pending, /aborted/);
assert.equal(cancelWebMcp("call-2"), false);
delete globalThis.document;
});
2 changes: 1 addition & 1 deletion extension/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@

"skipLibCheck": true
},
"include": ["src/probe.js"]
"include": ["src/probe.js", "src/globals.d.ts"]
}
1 change: 1 addition & 0 deletions lib/webby/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ defmodule Webby.Application do
{Task.Supervisor, name: Webby.ProbeSupervisor},
{DNSCluster, query: Application.get_env(:webby, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Webby.PubSub},
Webby.BrowserConnections,
# Start a worker by calling: Webby.Worker.start_link(arg)
# {Webby.Worker, arg},
# Start to serve requests, typically the last entry
Expand Down
Loading
Loading