diff --git a/README.md b/README.md index 70b948c..60ce0bf 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/config/config.exs b/config/config.exs index c253097..1f61471 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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 diff --git a/extension/src/globals.d.ts b/extension/src/globals.d.ts new file mode 100644 index 0000000..ce73b7d --- /dev/null +++ b/extension/src/globals.d.ts @@ -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 | undefined; diff --git a/extension/src/probe.js b/extension/src/probe.js index 3917137..b21caef 100644 --- a/extension/src/probe.js +++ b/extension/src/probe.js @@ -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. * @@ -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} + */ +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} */ (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) | undefined} + */ +function unspecifiedExecuteTool(context) { + const loose = /** @type {Record} */ (/** @type {unknown} */ (context)); + const execute = loose.executeTool; + if (typeof execute !== "function") return undefined; + return /** @type {(tool: WebMCP.RegisteredTool, input: string, options?: {signal?: AbortSignal}) => Promise} */ ( + execute.bind(context) + ); +} + /** * The specification declares `RegisteredTool.inputSchema` as a stringified JSON * Schema, and that spelling is what the type check pins. diff --git a/extension/src/service_worker.js b/extension/src/service_worker.js index 83c6ad3..06b86f0 100644 --- a/extension/src/service_worker.js +++ b/extension/src/service_worker.js @@ -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}; @@ -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() { diff --git a/extension/test/invocation.test.js b/extension/test/invocation.test.js new file mode 100644 index 0000000..deb8524 --- /dev/null +++ b/extension/test/invocation.test.js @@ -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; +}); diff --git a/extension/tsconfig.json b/extension/tsconfig.json index 768df5f..d1d0ae2 100644 --- a/extension/tsconfig.json +++ b/extension/tsconfig.json @@ -17,5 +17,5 @@ "skipLibCheck": true }, - "include": ["src/probe.js"] + "include": ["src/probe.js", "src/globals.d.ts"] } diff --git a/lib/webby/application.ex b/lib/webby/application.ex index 5324aeb..6db2bc7 100644 --- a/lib/webby/application.ex +++ b/lib/webby/application.ex @@ -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 diff --git a/lib/webby/browser_connections.ex b/lib/webby/browser_connections.ex new file mode 100644 index 0000000..e245b5d --- /dev/null +++ b/lib/webby/browser_connections.ex @@ -0,0 +1,158 @@ +defmodule Webby.BrowserConnections do + @moduledoc "Tracks authenticated browser channels and bounded tool calls." + + use GenServer + require Logger + + @timeout 15_000 + + def start_link(_opts), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__) + + def register(browser_id, pid \\ self()), + do: GenServer.call(__MODULE__, {:register, browser_id, pid}) + + def call(browser_id, payload, timeout \\ @timeout, external_key \\ nil), + do: + GenServer.call( + __MODULE__, + {:call, browser_id, payload, timeout, external_key}, + timeout + 1_000 + ) + + def cancel(external_key), do: GenServer.call(__MODULE__, {:cancel, external_key}) + + def complete(browser_id, payload), + do: GenServer.cast(__MODULE__, {:complete, browser_id, payload}) + + @impl true + def init(_state), do: {:ok, %{connections: %{}, calls: %{}}} + + @impl true + def handle_call({:register, browser_id, pid}, _from, state) do + state = drop_connection(state, browser_id, "browser_replaced") + monitor = Process.monitor(pid) + {:reply, :ok, put_in(state, [:connections, browser_id], {pid, monitor})} + end + + def handle_call({:call, browser_id, payload, timeout, external_key}, from, state) do + case state.connections[browser_id] do + {pid, _monitor} -> + call_id = Ecto.UUID.generate() + timer = Process.send_after(self(), {:call_timeout, call_id}, timeout) + send(pid, {:tool_call, Map.put(payload, "call_id", call_id)}) + + call = %{ + from: from, + browser_id: browser_id, + timer: timer, + payload: payload, + external_key: external_key + } + + {:noreply, put_in(state, [:calls, call_id], call)} + + nil -> + {:reply, {:error, "browser_offline", "The selected browser is not connected"}, state} + end + end + + def handle_call({:cancel, external_key}, _from, state) do + case Enum.find(state.calls, fn {_id, call} -> call.external_key == external_key end) do + {call_id, call} -> + Process.cancel_timer(call.timer) + send_cancel(state, call_id, call) + + GenServer.reply( + call.from, + {:error, "cancelled", "The MCP client cancelled the tool call"} + ) + + {:reply, :ok, update_in(state.calls, &Map.delete(&1, call_id))} + + nil -> + {:reply, :not_found, state} + end + end + + @impl true + def handle_cast({:complete, browser_id, %{"call_id" => call_id} = payload}, state) do + case state.calls[call_id] do + %{browser_id: ^browser_id} = call -> + Process.cancel_timer(call.timer) + GenServer.reply(call.from, completion(payload)) + {:noreply, update_in(state.calls, &Map.delete(&1, call_id))} + + _unknown -> + Logger.warning("ignored unmatched browser tool result", + event: "browser.tool_result.unmatched", + browser_id: browser_id, + call_id: call_id + ) + + {:noreply, state} + end + end + + def handle_cast({:complete, _browser_id, _payload}, state), do: {:noreply, state} + + @impl true + def handle_info({:call_timeout, call_id}, state) do + case Map.pop(state.calls, call_id) do + {nil, _calls} -> + {:noreply, state} + + {call, calls} -> + send_cancel(state, call_id, call) + + GenServer.reply( + call.from, + {:error, "tool_timeout", "The page tool exceeded its time limit"} + ) + + {:noreply, %{state | calls: calls}} + end + end + + def handle_info({:DOWN, monitor, :process, _pid, _reason}, state) do + case Enum.find(state.connections, fn {_id, {_pid, ref}} -> ref == monitor end) do + {browser_id, _connection} -> + {:noreply, drop_connection(state, browser_id, "browser_offline")} + + nil -> + {:noreply, state} + end + end + + defp completion(%{"type" => "tool.result", "result" => result}), do: {:ok, result} + + defp completion(%{"type" => "tool.error", "error" => error}) do + {:error, error["kind"] || "tool_failed", error["message"] || "The page tool failed"} + end + + defp send_cancel(state, call_id, call) do + case state.connections[call.browser_id] do + {pid, _monitor} -> send(pid, {:tool_cancel, Map.put(call.payload, "call_id", call_id)}) + nil -> :ok + end + end + + defp drop_connection(state, browser_id, kind) do + case Map.pop(state.connections, browser_id) do + {nil, _connections} -> + state + + {{_pid, monitor}, connections} -> + Process.demonitor(monitor, [:flush]) + + {failed, calls} = + Enum.split_with(state.calls, fn {_id, call} -> call.browser_id == browser_id end) + + Enum.each(failed, fn {_id, call} -> + Process.cancel_timer(call.timer) + GenServer.reply(call.from, {:error, kind, "The selected browser disconnected"}) + end) + + %{state | connections: connections, calls: Map.new(calls)} + end + end +end diff --git a/lib/webby/browser_protocol.ex b/lib/webby/browser_protocol.ex index 5866f58..52a612c 100644 --- a/lib/webby/browser_protocol.ex +++ b/lib/webby/browser_protocol.ex @@ -2,7 +2,7 @@ defmodule Webby.BrowserProtocol do @moduledoc "Transport-neutral validation for Webby Browser Protocol version 1." @version 1 - @types ~w(pairing.request pairing.status auth.respond browser.hello browser.resync browser.settings discovery.observed session.closed heartbeat) + @types ~w(pairing.request pairing.status auth.respond browser.hello browser.resync browser.settings discovery.observed session.closed heartbeat tool.result tool.error) def version, do: @version @@ -109,6 +109,23 @@ defmodule Webby.BrowserProtocol do defp validate_payload("session.closed", _payload), do: {:error, error("invalid_payload", %{"type" => "session.closed"})} + defp validate_payload("tool.result", %{"call_id" => call_id, "result" => result}) do + if valid_call_id?(call_id) and encoded_size(result) <= 131_072 and bounded_json?(result), + do: :ok, + else: {:error, error("invalid_payload", %{"type" => "tool.result"})} + end + + defp validate_payload("tool.error", %{"call_id" => call_id, "error" => error}) + when is_map(error) do + if valid_call_id?(call_id) and match?(:ok, required_string(error, "kind", 80)) and + encoded_size(error) <= 4_096, + do: :ok, + else: {:error, error("invalid_payload", %{"type" => "tool.error"})} + end + + defp validate_payload(type, _payload) when type in ["tool.result", "tool.error"], + do: {:error, error("invalid_payload", %{"type" => type})} + defp validate_payload(_type, _payload), do: :ok defp valid_observation?(%{"url" => url, "tools" => tools} = observation) @@ -149,4 +166,21 @@ defmodule Webby.BrowserProtocol do defp validate_optional_string(_value, _max_length), do: {:error, error("invalid_envelope")} + + defp valid_call_id?(value), do: is_binary(value) and byte_size(value) in 1..128 + defp encoded_size(value), do: value |> Jason.encode!() |> byte_size() + + defp bounded_json?(value), do: bounded_json?(value, 0) + defp bounded_json?(_value, depth) when depth > 32, do: false + + defp bounded_json?(value, _depth) + when is_binary(value) or is_number(value) or is_boolean(value) or is_nil(value), do: true + + defp bounded_json?(value, depth) when is_list(value), + do: Enum.all?(value, &bounded_json?(&1, depth + 1)) + + defp bounded_json?(value, depth) when is_map(value), + do: Enum.all?(value, fn {key, item} -> is_binary(key) and bounded_json?(item, depth + 1) end) + + defp bounded_json?(_value, _depth), do: false end diff --git a/lib/webby/invocation_audit.ex b/lib/webby/invocation_audit.ex new file mode 100644 index 0000000..05fea57 --- /dev/null +++ b/lib/webby/invocation_audit.ex @@ -0,0 +1,43 @@ +defmodule Webby.InvocationAudit do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + schema "invocation_audits" do + belongs_to :credential, Webby.MCP.Credential, type: :binary_id + belongs_to :registration, Webby.Pages.PageRegistration, type: :binary_id + belongs_to :session, Webby.Pages.DocumentSession, type: :binary_id + belongs_to :browser, Webby.Browsers.Browser, type: :binary_id + field :tool_name, :string + field :catalog_revision, :integer + field :outcome, :string + field :error_kind, :string + field :duration_ms, :integer + timestamps(type: :utc_datetime, updated_at: false) + end + + def changeset(audit, attrs) do + audit + |> cast(attrs, [ + :credential_id, + :registration_id, + :session_id, + :browser_id, + :tool_name, + :catalog_revision, + :outcome, + :error_kind, + :duration_ms + ]) + |> validate_required([ + :registration_id, + :session_id, + :browser_id, + :tool_name, + :catalog_revision, + :outcome, + :duration_ms + ]) + |> validate_inclusion(:outcome, ["started", "succeeded", "failed"]) + end +end diff --git a/lib/webby/invocations.ex b/lib/webby/invocations.ex new file mode 100644 index 0000000..8595294 --- /dev/null +++ b/lib/webby/invocations.ex @@ -0,0 +1,107 @@ +defmodule Webby.Invocations do + @moduledoc "Routes an authorized MCP call to one immutable browser document." + + alias Webby.{BrowserConnections, InvocationAudit, Repo} + require Logger + + @timeout 15_000 + + def call(registration, session, tool_name, arguments, context) do + started = System.monotonic_time() + + payload = %{ + "tab_id" => session.tab_id, + "document_id" => session.document_id, + "catalog_revision" => session.catalog_revision, + "tool_name" => tool_name, + "arguments" => arguments + } + + case begin_audit(registration, session, tool_name, context) do + {:ok, audit} -> + Logger.info("page tool call started", + event: "page.call.start", + browser_id: session.browser_id, + registration_id: registration.id, + session_id: session.id, + catalog_revision: session.catalog_revision + ) + + external_key = {context[:credential_id], context[:request_id]} + result = BrowserConnections.call(session.browser_id, payload, @timeout, external_key) + finish_audit(audit, result, started) + log_finish(registration, session, result, started) + result + + {:error, reason} -> + Logger.error("page tool call audit unavailable", + event: "page.call.audit_failed", + reason: inspect(reason) + ) + + {:error, "audit_unavailable", "The invocation audit could not be initialized"} + end + end + + defp begin_audit(registration, session, tool_name, context) do + %InvocationAudit{} + |> InvocationAudit.changeset(%{ + credential_id: context[:credential_id], + registration_id: registration.id, + session_id: session.id, + browser_id: session.browser_id, + tool_name: tool_name, + catalog_revision: session.catalog_revision, + outcome: "started", + duration_ms: 0 + }) + |> Repo.insert() + end + + defp finish_audit(audit, result, started) do + duration = System.convert_time_unit(System.monotonic_time() - started, :native, :millisecond) + + {outcome, error_kind} = + case result do + {:ok, _value} -> {"succeeded", nil} + {:error, kind, _message} -> {"failed", kind} + end + + case audit + |> InvocationAudit.changeset(%{ + outcome: outcome, + error_kind: error_kind, + duration_ms: duration + }) + |> Repo.update() do + {:ok, _updated} -> + :ok + + {:error, reason} -> + Logger.error("page tool call audit completion failed", + event: "page.call.audit_failed", + reason: inspect(reason) + ) + end + end + + defp log_finish(registration, session, result, started) do + duration = System.convert_time_unit(System.monotonic_time() - started, :native, :millisecond) + + kind = + case result do + {:ok, _value} -> "succeeded" + {:error, error_kind, _message} -> error_kind + end + + Logger.info("page tool call finished", + event: "page.call.finish", + browser_id: session.browser_id, + registration_id: registration.id, + session_id: session.id, + catalog_revision: session.catalog_revision, + outcome: kind, + duration_ms: duration + ) + end +end diff --git a/lib/webby/mcp/broker.ex b/lib/webby/mcp/broker.ex index 79d0fca..65a6d78 100644 --- a/lib/webby/mcp/broker.ex +++ b/lib/webby/mcp/broker.ex @@ -1,9 +1,9 @@ defmodule Webby.MCP.Broker do - @moduledoc "Read-only actions exposed through Webby's stable broker tool." + @moduledoc "Actions exposed through Webby's stable broker tool." alias Webby.{Browsers, Discovery, Pages} - @actions ~w(status browser.list discovery.list discovery.get page.list page.get page.tools) + @actions ~w(status browser.list discovery.list discovery.get page.list page.get page.tools page.call) def tool do %{ @@ -23,44 +23,69 @@ defmodule Webby.MCP.Broker do } end - def call(%{"action" => action} = arguments) when action in @actions do - dispatch(action, Map.get(arguments, "params", %{})) + def call(arguments, context \\ %{}) + + def call(%{"action" => action} = arguments, context) when action in @actions do + dispatch(action, Map.get(arguments, "params", %{}), context) end - def call(_arguments), do: {:error, "invalid_arguments", "A supported read action is required"} + def call(_arguments, _context), + do: {:error, "invalid_arguments", "A supported action is required"} - defp dispatch("status", _params) do + defp dispatch("status", _params, _context) do {_result, snapshot} = Webby.RuntimeStatus.snapshot() {:ok, snapshot} end - defp dispatch("browser.list", _params) do + defp dispatch("browser.list", _params, _context) do {:ok, Enum.map(Browsers.list_browsers(), &browser_view/1)} end - defp dispatch("discovery.list", _params) do + defp dispatch("discovery.list", _params, _context) do {:ok, Enum.map(Discovery.list_discoveries(), &discovery_view/1)} end - defp dispatch("discovery.get", %{"id" => id}) do + defp dispatch("discovery.get", %{"id" => id}, _context) do case Discovery.get_discovery(id) do nil -> {:error, "not_found", "Discovery not found"} discovery -> {:ok, discovery_view(discovery)} end end - defp dispatch("page.list", _params) do + defp dispatch("page.list", _params, _context) do {:ok, Enum.map(Pages.list_registrations(), ®istration_view/1)} end - defp dispatch(action, %{"page" => identifier}) when action in ["page.get", "page.tools"] do + defp dispatch(action, %{"page" => identifier}, _context) + when action in ["page.get", "page.tools"] do case Pages.get_registration(identifier) do nil -> {:error, "not_found", "Page registration not found"} registration -> page_result(action, registration) end end - defp dispatch(_action, _params), + defp dispatch( + "page.call", + %{"page" => identifier, "tool" => tool_name, "catalog_revision" => revision} = params, + context + ) + when is_binary(identifier) and is_binary(tool_name) and is_integer(revision) do + arguments = Map.get(params, "arguments", %{}) + + with :ok <- validate_arguments(arguments), + registration when not is_nil(registration) <- Pages.get_registration(identifier), + :ok <- validate_enabled(registration), + {:ok, session} <- Pages.select_session(registration, params), + :ok <- validate_revision(session, revision), + :ok <- validate_tool(session, tool_name) do + Webby.Invocations.call(registration, session, tool_name, arguments, context) + else + nil -> {:error, "not_found", "Page registration not found"} + {:error, _kind, _message} = error -> error + end + end + + defp dispatch(_action, _params, _context), do: {:error, "invalid_arguments", "Required parameters are missing"} defp page_result("page.get", registration) do @@ -133,4 +158,29 @@ defmodule Webby.MCP.Broker do defp iso8601(nil), do: nil defp iso8601(value), do: DateTime.to_iso8601(value) + + defp encoded_size(value), do: value |> Jason.encode!() |> byte_size() + + defp validate_arguments(arguments) do + if is_map(arguments) and encoded_size(arguments) <= 65_536, + do: :ok, + else: + {:error, "invalid_arguments", "Tool arguments must be an object no larger than 64 KiB"} + end + + defp validate_enabled(%{enabled: true}), do: :ok + + defp validate_enabled(_registration), + do: {:error, "page_disabled", "The page registration is disabled"} + + defp validate_revision(%{catalog_revision: revision}, revision), do: :ok + + defp validate_revision(_session, _revision), + do: {:error, "stale_catalog", "Refresh page.tools before invoking this tool"} + + defp validate_tool(session, tool_name) do + if Enum.any?(session.catalog_summary["tools"] || [], &(&1["name"] == tool_name)), + do: :ok, + else: {:error, "tool_not_found", "The tool is absent from the selected catalog"} + end end diff --git a/lib/webby/mcp/protocol.ex b/lib/webby/mcp/protocol.ex index 1e0923d..5b84fe1 100644 --- a/lib/webby/mcp/protocol.ex +++ b/lib/webby/mcp/protocol.ex @@ -8,7 +8,9 @@ defmodule Webby.MCP.Protocol do def supported_versions, do: @supported - def handle(%{"jsonrpc" => "2.0", "method" => "initialize", "id" => id} = request) do + def handle(request, context \\ %{}) + + def handle(%{"jsonrpc" => "2.0", "method" => "initialize", "id" => id} = request, _context) do requested = get_in(request, ["params", "protocolVersion"]) version = if requested in @supported, do: requested, else: @latest @@ -21,19 +23,22 @@ defmodule Webby.MCP.Protocol do }) end - def handle(%{"jsonrpc" => "2.0", "method" => "ping", "id" => id}), + def handle(%{"jsonrpc" => "2.0", "method" => "ping", "id" => id}, _context), do: response(id, %{}) - def handle(%{"jsonrpc" => "2.0", "method" => "tools/list", "id" => id}), + def handle(%{"jsonrpc" => "2.0", "method" => "tools/list", "id" => id}, _context), do: response(id, %{"resultType" => "complete", "tools" => [Broker.tool()]}) - def handle(%{ - "jsonrpc" => "2.0", - "method" => "tools/call", - "id" => id, - "params" => %{"name" => "webby", "arguments" => arguments} - }) do - case Broker.call(arguments) do + def handle( + %{ + "jsonrpc" => "2.0", + "method" => "tools/call", + "id" => id, + "params" => %{"name" => "webby", "arguments" => arguments} + }, + context + ) do + case Broker.call(arguments, Map.put(context, :request_id, id)) do {:ok, value} -> response(id, tool_result(value, false)) @@ -42,14 +47,27 @@ defmodule Webby.MCP.Protocol do end end - def handle(%{"jsonrpc" => "2.0", "method" => "notifications/initialized"}), + def handle(%{"jsonrpc" => "2.0", "method" => "notifications/initialized"}, _context), do: :accepted - def handle(%{"jsonrpc" => "2.0", "method" => method, "id" => id}) when is_binary(method), - do: error(id, -32_601, "Method not found") + def handle( + %{ + "jsonrpc" => "2.0", + "method" => "notifications/cancelled", + "params" => %{"requestId" => id} + }, + context + ) do + Webby.BrowserConnections.cancel({context[:credential_id], id}) + :accepted + end + + def handle(%{"jsonrpc" => "2.0", "method" => method, "id" => id}, _context) + when is_binary(method), + do: error(id, -32_601, "Method not found") - def handle(%{"jsonrpc" => "2.0", "method" => _method}), do: :accepted - def handle(_request), do: error(nil, -32_600, "Invalid Request") + def handle(%{"jsonrpc" => "2.0", "method" => _method}, _context), do: :accepted + def handle(_request, _context), do: error(nil, -32_600, "Invalid Request") defp tool_result(value, error?) do %{ diff --git a/lib/webby/pages.ex b/lib/webby/pages.ex index df97a01..17bd0f1 100644 --- a/lib/webby/pages.ex +++ b/lib/webby/pages.ex @@ -36,6 +36,37 @@ defmodule Webby.Pages do ) end + def select_session(registration, params) do + sessions = sessions_for(registration.id) + + cond do + is_binary(params["session"]) -> + case Enum.find(sessions, &(&1.id == params["session"])) do + nil -> {:error, "page_offline", "The requested document session is not active"} + session -> {:ok, session} + end + + registration.preferred_browser_id -> + select_recent( + Enum.filter(sessions, &(&1.browser_id == registration.preferred_browser_id)) + ) + + sessions == [] -> + {:error, "page_offline", "No active document session is available"} + + sessions |> Enum.map(& &1.browser_id) |> Enum.uniq() |> length() > 1 -> + {:error, "ambiguous_page_session", "Select a session to avoid crossing browser profiles"} + + true -> + {:ok, hd(sessions)} + end + end + + defp select_recent([]), + do: {:error, "page_offline", "The preferred browser has no active session"} + + defp select_recent([session | _]), do: {:ok, session} + def register_discovery(id) do case Repo.get(Discovery, id) do %Discovery{state: "discovered"} = discovery -> diff --git a/lib/webby/schema_metadata.ex b/lib/webby/schema_metadata.ex index d86d07d..b1f9537 100644 --- a/lib/webby/schema_metadata.ex +++ b/lib/webby/schema_metadata.ex @@ -16,9 +16,9 @@ defmodule Webby.SchemaMetadata do Webby.Repo, """ INSERT INTO webby_meta (key, value, inserted_at, updated_at) - VALUES ('schema_generation', '5', ?, ?) - ON CONFLICT(key) DO UPDATE SET value = '5', updated_at = excluded.updated_at - WHERE webby_meta.value IN ('1', '2', '3', '4') + VALUES ('schema_generation', '6', ?, ?) + ON CONFLICT(key) DO UPDATE SET value = '6', updated_at = excluded.updated_at + WHERE webby_meta.value IN ('1', '2', '3', '4', '5') """, [now, now] ) do @@ -34,7 +34,7 @@ defmodule Webby.SchemaMetadata do @doc false def validate_generation do case SQL.query(Webby.Repo, "SELECT value FROM webby_meta WHERE key = 'schema_generation'", []) do - {:ok, %{rows: [["5"]]}} -> + {:ok, %{rows: [["6"]]}} -> {:ok, %{}} {:ok, %{rows: [[generation]]}} -> diff --git a/lib/webby_web/channels/browser_channel.ex b/lib/webby_web/channels/browser_channel.ex index 3e48e98..83b3a6a 100644 --- a/lib/webby_web/channels/browser_channel.ex +++ b/lib/webby_web/channels/browser_channel.ex @@ -2,7 +2,7 @@ defmodule WebbyWeb.BrowserChannel do @moduledoc false use WebbyWeb, :channel - alias Webby.{BrowserProtocol, Browsers, Discovery, Pages} + alias Webby.{BrowserConnections, BrowserProtocol, Browsers, Discovery, Pages} alias Webby.Discovery.Discovery, as: DiscoveryRecord alias Webby.Pages.DocumentSession require Logger @@ -50,6 +50,26 @@ defmodule WebbyWeb.BrowserChannel do {:noreply, socket} end + def handle_info({:tool_call, payload}, socket) do + push( + socket, + "message", + BrowserProtocol.envelope("tool.call", payload, browser_id: socket.assigns.browser_id) + ) + + {:noreply, socket} + end + + def handle_info({:tool_cancel, payload}, socket) do + push( + socket, + "message", + BrowserProtocol.envelope("tool.cancel", payload, browser_id: socket.assigns.browser_id) + ) + + {:noreply, socket} + end + defp dispatch(%{type: "pairing.request", payload: payload, request_id: request_id}, socket) do attrs = Map.put(payload, "extension_id", socket.assigns.extension_id) @@ -78,6 +98,8 @@ defmodule WebbyWeb.BrowserChannel do ) do case Browsers.authenticate(browser_id, payload["challenge_id"], payload["signature"]) do {:ok, browser} -> + :ok = BrowserConnections.register(browser.id) + response = BrowserProtocol.envelope("auth.accepted", %{"browser_id" => browser.id}, browser_id: browser.id @@ -90,6 +112,15 @@ defmodule WebbyWeb.BrowserChannel do end end + defp dispatch( + %{type: type, payload: payload}, + %{assigns: %{authenticated: true, browser_id: browser_id}} = socket + ) + when type in ["tool.result", "tool.error"] do + BrowserConnections.complete(browser_id, Map.put(payload, "type", type)) + {:reply, {:ok, acknowledgement(type, nil, browser_id)}, socket} + end + defp dispatch( %{type: "session.closed", payload: payload, request_id: request_id}, %{assigns: %{authenticated: true, browser_id: browser_id}} = socket diff --git a/lib/webby_web/controllers/mcp_controller.ex b/lib/webby_web/controllers/mcp_controller.ex index 5d0984d..485b897 100644 --- a/lib/webby_web/controllers/mcp_controller.ex +++ b/lib/webby_web/controllers/mcp_controller.ex @@ -7,9 +7,9 @@ defmodule WebbyWeb.MCPController do with :ok <- valid_origin(conn), :ok <- valid_accept(conn), {:ok, credential} <- authenticate(conn), - true <- Credentials.scope?(credential, "read"), + true <- Credentials.scope?(credential, required_scope(request)), :ok <- valid_version(conn, request) do - dispatch(conn, Protocol.handle(request)) + dispatch(conn, Protocol.handle(request, %{credential_id: credential.id})) else {:error, :invalid_origin} -> json_error(conn, 403, "invalid_origin") @@ -108,4 +108,17 @@ defmodule WebbyWeb.MCPController do body = %{"jsonrpc" => "2.0", "id" => nil, "error" => %{"code" => -32_000, "message" => kind}} conn |> put_status(status) |> json(body) end + + defp required_scope(%{ + "method" => "tools/call", + "params" => %{ + "name" => "webby", + "arguments" => %{"action" => "page.call"} + } + }), + do: "call" + + defp required_scope(%{"method" => "notifications/cancelled"}), do: "call" + + defp required_scope(_request), do: "read" end diff --git a/lib/webby_web/live/dashboard_live.ex b/lib/webby_web/live/dashboard_live.ex index 77ce0de..c5c15eb 100644 --- a/lib/webby_web/live/dashboard_live.ex +++ b/lib/webby_web/live/dashboard_live.ex @@ -45,8 +45,12 @@ defmodule WebbyWeb.DashboardLive do {:noreply, resolve_page(socket, Webby.Pages.register_discovery(id), "Page registration created")} - def handle_event("create-mcp-credential", _params, socket) do - case Credentials.create("Local MCP client") do + def handle_event("create-mcp-credential", params, socket) do + call? = params["scope"] == "call" + scopes = if call?, do: ["read", "call"], else: ["read"] + name = if call?, do: "Local MCP call client", else: "Local MCP read client" + + case Credentials.create(name, scopes) do {:ok, _credential, token} -> {:noreply, socket |> assign(:credential_token, token) |> assign_credentials()} @@ -110,6 +114,11 @@ defmodule WebbyWeb.DashboardLive do phx-click="create-mcp-credential" class="rounded-lg bg-cyan-700 px-3 py-2 text-sm font-medium text-white" >Create read credential +

{credential.display_name}

- {if credential.revoked_at, do: "Revoked", else: "Read access"} + {if credential.revoked_at, + do: "Revoked", + else: Enum.join(credential.scopes["values"], " + ") <> " access"}