From 0023bd7d1900d1f82f1013fd90de3f8662ccd21a Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 12 May 2026 16:09:17 +0200 Subject: [PATCH 01/10] Add WebMCP navigator.modelContext Implements the page-side surface of the W3C WebMCP spec (https://webmachinelearning.github.io/webmcp/): exposes `navigator.modelContext.registerTool(...)` for declaring MCP tools to a browser agent, with full name/description validation, AbortSignal-based unregistration, and a `ModelContextClient` whose `requestUserInteraction` invokes its callback directly (closest faithful behavior in a headless browser). --- src/browser/js/bridge.zig | 1 + src/browser/tests/webmcp/model_context.html | 176 +++++++++++++++ src/browser/webapi/ModelContext.zig | 236 ++++++++++++++++++++ src/browser/webapi/Navigator.zig | 6 + src/browser/webapi/Window.zig | 2 + 5 files changed, 421 insertions(+) create mode 100644 src/browser/tests/webmcp/model_context.html create mode 100644 src/browser/webapi/ModelContext.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 41bf3604..106a8845 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -887,6 +887,7 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/animation/Animation.zig"), @import("../webapi/EventTarget.zig"), @import("../webapi/Location.zig"), + @import("../webapi/ModelContext.zig"), @import("../webapi/Navigator.zig"), @import("../webapi/NavigatorUAData.zig"), @import("../webapi/net/FormData.zig"), diff --git a/src/browser/tests/webmcp/model_context.html b/src/browser/tests/webmcp/model_context.html new file mode 100644 index 00000000..32927309 --- /dev/null +++ b/src/browser/tests/webmcp/model_context.html @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/ModelContext.zig b/src/browser/webapi/ModelContext.zig new file mode 100644 index 00000000..939fb651 --- /dev/null +++ b/src/browser/webapi/ModelContext.zig @@ -0,0 +1,236 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// WebMCP — https://webmachinelearning.github.io/webmcp/ +// +// Exposes `navigator.modelContext`, the page-side surface for declaring MCP +// tools to a browser agent. Lightpanda doesn't ship an agent yet; the +// follow-ups will wire the registered tools through: +// 1. CDP `WebMCP` domain (https://chromedevtools.github.io/devtools-protocol/tot/WebMCP/) +// 2. Lightpanda's own MCP server forwarding tools to an external LLM. +// +// Both consumers reach into `tools()` / `findTool()` from Zig; the JS-side +// surface (`registerTool`, `requestUserInteraction`) is the only part shipped +// today. + +const std = @import("std"); + +const js = @import("../js/js.zig"); +const Frame = @import("../Frame.zig"); + +const AbortSignal = @import("AbortSignal.zig"); + +pub fn registerTypes() []const type { + return &.{ ModelContext, ModelContextClient }; +} + +const ModelContext = @This(); + +_tools: std.ArrayList(*Tool) = .{}, + +pub const init: ModelContext = .{}; + +pub const Annotations = struct { + readOnlyHint: bool = false, + untrustedContentHint: bool = false, + // Not in the W3C spec yet. The CDP `WebMCP.Annotation` type has an + // `autosubmit` field; storing it here means the CDP follow-up won't have + // to re-shape this struct. + autoSubmitHint: bool = false, +}; + +pub const Tool = struct { + name: []const u8, + title: ?[]const u8, + description: []const u8, + input_schema: ?js.Object.Global, + execute: js.Function.Global, + annotations: Annotations, + // When present, the tool is considered unregistered once the signal + // fires. Checked lazily on each `tools()` / `findTool()` call — fine + // for headless usage where there's no synchronous observer to notify. + signal: ?*AbortSignal, +}; + +const ToolDict = struct { + name: []const u8, + title: ?[]const u8 = null, + description: []const u8, + inputSchema: ?js.Object.Global = null, + execute: js.Function.Global, + annotations: ?Annotations = null, +}; + +const RegisterToolOptions = struct { + signal: ?*AbortSignal = null, +}; + +pub fn registerTool( + self: *ModelContext, + tool: ToolDict, + options_: ?RegisterToolOptions, + frame: *Frame, +) !void { + try validateName(tool.name); + if (tool.description.len == 0) { + return error.InvalidStateError; + } + + const options = options_ orelse RegisterToolOptions{}; + + // Per spec: a pre-aborted signal makes registration a silent no-op. + if (options.signal) |signal| { + if (signal._aborted) { + return; + } + } + + // Reject duplicate names. The spec says `InvalidStateError`. We compact + // the list lazily here so a tool whose signal already aborted doesn't + // block re-registering under the same name. + self.compactAborted(); + for (self._tools.items) |existing| { + if (std.mem.eql(u8, existing.name, tool.name)) { + return error.InvalidStateError; + } + } + + const arena = frame.arena; + const entry = try arena.create(Tool); + entry.* = .{ + .name = try arena.dupe(u8, tool.name), + .title = if (tool.title) |t| try arena.dupe(u8, t) else null, + .description = try arena.dupe(u8, tool.description), + .input_schema = tool.inputSchema, + .execute = tool.execute, + .annotations = tool.annotations orelse .{}, + .signal = options.signal, + }; + + try self._tools.append(arena, entry); +} + +/// Snapshot of currently-registered tools, with aborted entries filtered. +/// Used by the (not-yet-implemented) CDP `WebMCP.enable` replay and the +/// native MCP forwarder. +pub fn tools(self: *ModelContext) []const *Tool { + self.compactAborted(); + return self._tools.items; +} + +/// Look up a tool by name. Returns null if not found or if its signal has +/// fired. Used by the (not-yet-implemented) CDP `WebMCP.invokeTool`. +pub fn findTool(self: *ModelContext, name: []const u8) ?*Tool { + self.compactAborted(); + for (self._tools.items) |t| { + if (std.mem.eql(u8, t.name, name)) return t; + } + return null; +} + +fn compactAborted(self: *ModelContext) void { + var i: usize = 0; + while (i < self._tools.items.len) { + const t = self._tools.items[i]; + if (t.signal) |signal| { + if (signal._aborted) { + _ = self._tools.swapRemove(i); + continue; + } + } + i += 1; + } +} + +fn validateName(name: []const u8) !void { + if (name.len == 0 or name.len > 128) { + return error.InvalidStateError; + } + for (name) |c| { + const ok = (c >= 'a' and c <= 'z') or + (c >= 'A' and c <= 'Z') or + (c >= '0' and c <= '9') or + c == '_' or c == '-' or c == '.'; + if (!ok) return error.InvalidStateError; + } +} + +// ModelContextClient — passed as the second argument to an `execute` +// callback. Today its only method is `requestUserInteraction`, which the +// spec leaves implementation-defined; for a headless browser, the closest +// faithful behaviour is to run the user-supplied callback directly and +// resolve with its return value. +pub const ModelContextClient = struct { + _pad: bool = false, + + pub fn requestUserInteraction( + _: *ModelContextClient, + callback: js.Function, + frame: *Frame, + ) !js.Promise { + const local = frame.js.local.?; + const resolver = local.createPromiseResolver(); + + var caught: js.TryCatch.Caught = undefined; + if (callback.tryCall(js.Value, .{}, &caught)) |result| { + // The callback may itself return a thenable; resolving with its + // value lets V8's promise resolution machinery unwrap it. + resolver.resolve("requestUserInteraction", result); + } else |_| { + const ex_msg = caught.exception orelse "requestUserInteraction callback threw"; + resolver.rejectError("requestUserInteraction", .{ .generic_error = ex_msg }); + } + return resolver.promise(); + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(ModelContextClient); + + pub const Meta = struct { + pub const name = "ModelContextClient"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; + }; + + pub const requestUserInteraction = bridge.function( + ModelContextClient.requestUserInteraction, + .{}, + ); + }; +}; + +pub const JsApi = struct { + pub const bridge = js.Bridge(ModelContext); + + pub const Meta = struct { + pub const name = "ModelContext"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const registerTool = bridge.function( + ModelContext.registerTool, + .{ .dom_exception = true }, + ); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: ModelContext" { + try testing.htmlRunner("webmcp/model_context.html", .{}); +} diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig index 89a919d1..1f0e39f4 100644 --- a/src/browser/webapi/Navigator.zig +++ b/src/browser/webapi/Navigator.zig @@ -27,6 +27,7 @@ const PluginArray = @import("PluginArray.zig"); const Permissions = @import("Permissions.zig"); const StorageManager = @import("StorageManager.zig"); const NavigatorUAData = @import("NavigatorUAData.zig"); +const ModelContext = @import("ModelContext.zig"); const log = lp.log; @@ -78,6 +79,10 @@ pub fn getUserAgentData(self: *Navigator) *NavigatorUAData { return &self._ua_data; } +pub fn getModelContext(_: *const Navigator, frame: *Frame) *ModelContext { + return &frame.window._model_context; +} + pub fn getBattery(_: *const Navigator, frame: *Frame) !js.Promise { log.info(.not_implemented, "navigator.getBattery", .{}); return frame.js.local.?.rejectErrorPromise(.{ .dom_exception = .{ .err = error.NotSupported } }); @@ -189,6 +194,7 @@ pub const JsApi = struct { pub const permissions = bridge.accessor(Navigator.getPermissions, null, .{}); pub const storage = bridge.accessor(Navigator.getStorage, null, .{}); pub const userAgentData = bridge.accessor(Navigator.getUserAgentData, null, .{}); + pub const modelContext = bridge.accessor(Navigator.getModelContext, null, .{}); }; const testing = @import("../../testing.zig"); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index d602fc1b..fab67781 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -28,6 +28,7 @@ const Navigation = @import("navigation/Navigation.zig"); const Crypto = @import("Crypto.zig"); const CSS = @import("CSS.zig"); const Navigator = @import("Navigator.zig"); +const ModelContext = @import("ModelContext.zig"); const Screen = @import("Screen.zig"); const VisualViewport = @import("VisualViewport.zig"); const Performance = @import("Performance.zig"); @@ -65,6 +66,7 @@ _css: CSS = .init, _crypto: Crypto = .init, _console: Console = .init, _navigator: Navigator = .init, +_model_context: ModelContext = ModelContext.init, _screen: *Screen, _visual_viewport: *VisualViewport, _performance: Performance, From c23d0f4f35326945368a484a4e12850cdceb77f6 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 12 May 2026 16:49:22 +0200 Subject: [PATCH 02/10] cdp: implement webMCP domain --- src/Notification.zig | 11 + src/browser/tests/cdp/webmcp_fixture.html | 24 + src/browser/webapi/ModelContext.zig | 28 +- src/browser/webapi/Window.zig | 6 +- src/cdp/CDP.zig | 27 + src/cdp/domains/webmcp.zig | 572 ++++++++++++++++++++++ 6 files changed, 658 insertions(+), 10 deletions(-) create mode 100644 src/browser/tests/cdp/webmcp_fixture.html create mode 100644 src/cdp/domains/webmcp.zig diff --git a/src/Notification.zig b/src/Notification.zig index f9d7b566..d98f3ecc 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -54,6 +54,8 @@ const Allocator = std.mem.Allocator; // that is shared across all Sessions (tabs) within that connection. This ensures // proper isolation between different CDP clients while allowing a single client // to receive events from all its tabs. +const ModelContextTool = @import("browser/webapi/ModelContext.zig").Tool; + const Notification = @This(); // Every event type (which are hard-coded), has a list of Listeners. // When the event happens, we dispatch to those listener. @@ -88,6 +90,8 @@ const EventListeners = struct { javascript_dialog_opening: List = .{}, console_message: List = .{}, runtime_console_message: List = .{}, + model_context_tool_added: List = .{}, + model_context_tool_removed: List = .{}, }; const Events = union(enum) { @@ -111,6 +115,8 @@ const Events = union(enum) { javascript_dialog_opening: *const JavascriptDialogOpening, console_message: *const ConsoleMessage, runtime_console_message: *const ConsoleMessage, + model_context_tool_added: *const ModelContextToolEvent, + model_context_tool_removed: *const ModelContextToolEvent, }; const EventType = std.meta.FieldEnum(Events); @@ -224,6 +230,11 @@ pub const JavascriptDialogOpening = struct { response: *DialogResponse, }; +pub const ModelContextToolEvent = struct { + frame: *Frame, + tool: *const ModelContextTool, +}; + pub const DialogResponse = struct { accept: bool = false, // Set when the CDP client sent a `promptText` with `accept: true`. Memory diff --git a/src/browser/tests/cdp/webmcp_fixture.html b/src/browser/tests/cdp/webmcp_fixture.html new file mode 100644 index 00000000..1d5d45b7 --- /dev/null +++ b/src/browser/tests/cdp/webmcp_fixture.html @@ -0,0 +1,24 @@ + + +WebMCP CDP fixture + + + + diff --git a/src/browser/webapi/ModelContext.zig b/src/browser/webapi/ModelContext.zig index 939fb651..be568196 100644 --- a/src/browser/webapi/ModelContext.zig +++ b/src/browser/webapi/ModelContext.zig @@ -32,6 +32,7 @@ const std = @import("std"); const js = @import("../js/js.zig"); const Frame = @import("../Frame.zig"); +const Notification = @import("../../Notification.zig"); const AbortSignal = @import("AbortSignal.zig"); @@ -103,7 +104,7 @@ pub fn registerTool( // Reject duplicate names. The spec says `InvalidStateError`. We compact // the list lazily here so a tool whose signal already aborted doesn't // block re-registering under the same name. - self.compactAborted(); + self.compactAborted(frame); for (self._tools.items) |existing| { if (std.mem.eql(u8, existing.name, tool.name)) { return error.InvalidStateError; @@ -123,33 +124,42 @@ pub fn registerTool( }; try self._tools.append(arena, entry); + + // Fire `model_context_tool_added` so observers (CDP `WebMCP` domain, + // native MCP forwarder) can surface the new tool. + const event: Notification.ModelContextToolEvent = .{ .frame = frame, .tool = entry }; + frame._session.notification.dispatch(.model_context_tool_added, &event); } /// Snapshot of currently-registered tools, with aborted entries filtered. -/// Used by the (not-yet-implemented) CDP `WebMCP.enable` replay and the -/// native MCP forwarder. -pub fn tools(self: *ModelContext) []const *Tool { - self.compactAborted(); +/// Used by the CDP `WebMCP.enable` replay and the native MCP forwarder. +pub fn tools(self: *ModelContext, frame: *Frame) []const *Tool { + self.compactAborted(frame); return self._tools.items; } /// Look up a tool by name. Returns null if not found or if its signal has -/// fired. Used by the (not-yet-implemented) CDP `WebMCP.invokeTool`. -pub fn findTool(self: *ModelContext, name: []const u8) ?*Tool { - self.compactAborted(); +/// fired. Used by CDP `WebMCP.invokeTool`. +pub fn findTool(self: *ModelContext, frame: *Frame, name: []const u8) ?*Tool { + self.compactAborted(frame); for (self._tools.items) |t| { if (std.mem.eql(u8, t.name, name)) return t; } return null; } -fn compactAborted(self: *ModelContext) void { +/// Walk the tool list and remove any whose `AbortSignal` has fired, +/// dispatching `model_context_tool_removed` for each. Cheap when no +/// signals fired (which is the common case). +fn compactAborted(self: *ModelContext, frame: *Frame) void { var i: usize = 0; while (i < self._tools.items.len) { const t = self._tools.items[i]; if (t.signal) |signal| { if (signal._aborted) { _ = self._tools.swapRemove(i); + const event: Notification.ModelContextToolEvent = .{ .frame = frame, .tool = t }; + frame._session.notification.dispatch(.model_context_tool_removed, &event); continue; } } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index fab67781..6e8be504 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -66,7 +66,7 @@ _css: CSS = .init, _crypto: Crypto = .init, _console: Console = .init, _navigator: Navigator = .init, -_model_context: ModelContext = ModelContext.init, +_model_context: ModelContext = .init, _screen: *Screen, _visual_viewport: *VisualViewport, _performance: Performance, @@ -172,6 +172,10 @@ pub fn getNavigator(self: *Window) *Navigator { return &self._navigator; } +pub fn getModelContext(self: *Window) *ModelContext { + return &self._model_context; +} + pub fn getScreen(self: *Window) *Screen { return self._screen; } diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index d9ba670c..e5f595ea 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -306,6 +306,7 @@ fn dispatchCommand(command: *Command, method: []const u8) !void { 6 => switch (@as(u48, @bitCast(domain[0..6].*))) { asUint(u48, "Target") => return @import("domains/target.zig").processMessage(command), asUint(u48, "Audits") => return @import("domains/audits.zig").processMessage(command), + asUint(u48, "WebMCP") => return @import("domains/webmcp.zig").processMessage(command), else => {}, }, 7 => switch (@as(u56, @bitCast(domain[0..7].*))) { @@ -489,6 +490,10 @@ pub const BrowserContext = struct { // own message arena. pending_dialog_response: ?Notification.DialogResponse = null, + // WebMCP domain state. Populated when `WebMCP.enable` is received. + webmcp_next_invocation_id: u32 = 0, + webmcp_invocations: std.AutoHashMapUnmanaged(u32, *@import("domains/webmcp.zig").Invocation) = .empty, + fn init(self: *BrowserContext, id: []const u8, cdp: *CDP) !void { const allocator = cdp.allocator; @@ -729,6 +734,28 @@ pub const BrowserContext = struct { self.notification.unregister(.runtime_console_message, self); } + pub fn webmcpEnable(self: *BrowserContext) !void { + try self.notification.register(.model_context_tool_added, self, onModelContextToolAdded); + try self.notification.register(.model_context_tool_removed, self, onModelContextToolRemoved); + } + + pub fn webmcpDisable(self: *BrowserContext) void { + self.notification.unregister(.model_context_tool_added, self); + self.notification.unregister(.model_context_tool_removed, self); + } + + pub fn onModelContextToolAdded(ctx: *anyopaque, event: *const Notification.ModelContextToolEvent) !void { + const self: *BrowserContext = @ptrCast(@alignCast(ctx)); + defer self.resetNotificationArena(); + return @import("domains/webmcp.zig").onToolAdded(self.notification_arena, self, event); + } + + pub fn onModelContextToolRemoved(ctx: *anyopaque, event: *const Notification.ModelContextToolEvent) !void { + const self: *BrowserContext = @ptrCast(@alignCast(ctx)); + defer self.resetNotificationArena(); + return @import("domains/webmcp.zig").onToolRemoved(self.notification_arena, self, event); + } + pub fn onFrameRemove(ctx: *anyopaque, _: Notification.FrameRemove) !void { const self: *BrowserContext = @ptrCast(@alignCast(ctx)); @import("domains/page.zig").frameRemove(self); diff --git a/src/cdp/domains/webmcp.zig b/src/cdp/domains/webmcp.zig new file mode 100644 index 00000000..a7fbe6ce --- /dev/null +++ b/src/cdp/domains/webmcp.zig @@ -0,0 +1,572 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// CDP WebMCP domain. +// https://chromedevtools.github.io/devtools-protocol/tot/WebMCP/ +// +// Bridges the page-side `navigator.modelContext` surface (see +// browser/webapi/ModelContext.zig) to a CDP client. The client `enable`s +// the domain, then receives: +// - `toolsAdded` whenever the page registers tools. +// - `toolsRemoved` whenever an AbortSignal-bound tool's signal fires +// (lazy — detected on the next compaction). +// - `toolInvoked` immediately after the client sends `invokeTool`. +// - `toolResponded` once the tool's execute promise settles, or +// immediately on `cancelInvocation`. + +const std = @import("std"); +const lp = @import("lightpanda"); + +const id_mod = @import("../id.zig"); +const CDP = @import("../CDP.zig"); + +const ModelContext = @import("../../browser/webapi/ModelContext.zig"); +const Frame = @import("../../browser/Frame.zig"); +const Notification = @import("../../Notification.zig"); +const js = @import("../../browser/js/js.zig"); +const ModelContextClient = ModelContext.ModelContextClient; + +const log = lp.log; +const Allocator = std.mem.Allocator; + +const INVOCATION_PREFIX = "INV-"; + +pub const Invocation = struct { + id: u32, + bc: *CDP.BrowserContext, + frame_id: u32, + name: []const u8, + canceled: bool = false, +}; + +pub fn processMessage(cmd: *CDP.Command) !void { + const action = std.meta.stringToEnum(enum { + enable, + disable, + invokeTool, + cancelInvocation, + }, cmd.input.action) orelse return error.UnknownMethod; + + switch (action) { + .enable => return enable(cmd), + .disable => return disable(cmd), + .invokeTool => return invokeTool(cmd), + .cancelInvocation => return cancelInvocation(cmd), + } +} + +fn enable(cmd: *CDP.Command) !void { + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + try bc.webmcpEnable(); + + // Replay any tools registered before enable. We walk the current + // frame only; subframes will be added when they register. + if (bc.session.currentFrame()) |frame| { + const mc = frame.window.getModelContext(); + const tools = mc.tools(frame); + if (tools.len > 0) { + try sendToolsAdded(cmd.cdp, bc, frame, tools); + } + } + + return cmd.sendResult(null, .{}); +} + +fn disable(cmd: *CDP.Command) !void { + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + bc.webmcpDisable(); + return cmd.sendResult(null, .{}); +} + +const InvokeToolParams = struct { + frameId: []const u8, + toolName: []const u8, + input: std.json.Value = .null, +}; + +fn invokeTool(cmd: *CDP.Command) !void { + const params = (try cmd.params(InvokeToolParams)) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const frame_id = try id_mod.parseFrameId(params.frameId); + const frame = bc.session.findFrameByFrameId(frame_id) orelse return error.FrameNotFound; + const mc = frame.window.getModelContext(); + const tool = mc.findTool(frame, params.toolName) orelse return error.NotFound; + + // Stringify the input once. We send it back to the client via + // `toolInvoked.input` and pass the parsed form into the JS callback. + const input_str = try std.json.Stringify.valueAlloc(cmd.arena, params.input, .{}); + + bc.webmcp_next_invocation_id +%= 1; + const inv_id = bc.webmcp_next_invocation_id; + const inv_id_str = try std.fmt.allocPrint(cmd.arena, INVOCATION_PREFIX ++ "{d}", .{inv_id}); + + const invocation = try bc.arena.create(Invocation); + invocation.* = .{ + .id = inv_id, + .bc = bc, + .frame_id = frame_id, + .name = try bc.arena.dupe(u8, tool.name), + }; + try bc.webmcp_invocations.put(bc.arena, inv_id, invocation); + + // Send toolInvoked event before we run the JS, so the client sees + // them in order even if the tool resolves synchronously. + const session_id = bc.session_id; + const frame_id_str = id_mod.toFrameId(frame_id); + try cmd.sendEvent("WebMCP.toolInvoked", .{ + .toolName = tool.name, + .frameId = &frame_id_str, + .invocationId = inv_id_str, + .input = input_str, + }, .{ .session_id = session_id }); + + // Enter the frame's V8 context to invoke the stored callback. + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + const local = &ls.local; + + const input_value = local.parseJSON(input_str) catch { + try respondError(cmd.cdp, bc, invocation, "failed to parse input JSON"); + return cmd.sendResult(.{ .invocationId = inv_id_str }, .{}); + }; + + const callback = local.toLocal(tool.execute); + + // ModelContextClient has no per-instance state today (see + // ModelContext.zig). We still build a fresh wrapper so the page-side + // signature `execute(input, client)` works as documented. + var client_inst = ModelContextClient{}; + const client_value = try local.zigValueToJs(&client_inst, .{}); + + var caught: js.TryCatch.Caught = undefined; + const result = callback.tryCall(js.Value, .{ input_value, client_value }, &caught) catch { + const msg = caught.exception orelse "tool threw"; + try respondError(cmd.cdp, bc, invocation, msg); + return cmd.sendResult(.{ .invocationId = inv_id_str }, .{}); + }; + + // If the tool returned a non-promise value, settle immediately. + if (!result.isPromise()) { + try respondCompleted(cmd.cdp, bc, invocation, result); + return cmd.sendResult(.{ .invocationId = inv_id_str }, .{}); + } + + const promise = js.Promise{ .local = local, .handle = @ptrCast(result.handle) }; + const on_fulfilled = local.newCallback(onPromiseFulfilled, invocation); + const on_rejected = local.newCallback(onPromiseRejected, invocation); + _ = promise.thenAndCatch(on_fulfilled, on_rejected) catch { + // If we couldn't chain, settle as error. Map entry will be + // cleaned up below. + try respondError(cmd.cdp, bc, invocation, "promise chain failed"); + return cmd.sendResult(.{ .invocationId = inv_id_str }, .{}); + }; + + return cmd.sendResult(.{ .invocationId = inv_id_str }, .{}); +} + +fn cancelInvocation(cmd: *CDP.Command) !void { + const params = (try cmd.params(struct { + invocationId: []const u8, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + + const inv_id = parseInvocationId(params.invocationId) orelse return error.InvalidParams; + const entry = bc.webmcp_invocations.fetchRemove(inv_id) orelse return error.NotFound; + entry.value.canceled = true; + + const inv_id_str = try std.fmt.allocPrint(cmd.arena, INVOCATION_PREFIX ++ "{d}", .{inv_id}); + try cmd.cdp.sendEvent("WebMCP.toolResponded", .{ + .invocationId = inv_id_str, + .status = "Canceled", + }, .{ .session_id = bc.session_id }); + + return cmd.sendResult(null, .{}); +} + +fn parseInvocationId(s: []const u8) ?u32 { + if (!std.mem.startsWith(u8, s, INVOCATION_PREFIX)) return null; + return std.fmt.parseInt(u32, s[INVOCATION_PREFIX.len..], 10) catch null; +} + +// === Promise resolution callbacks === + +fn onPromiseFulfilled(invocation: *Invocation, value: js.Value) anyerror!void { + // The map is the source of truth for "still active". cancelInvocation + // removes it from the map and sends Canceled; we drop the late result. + if (invocation.bc.webmcp_invocations.fetchRemove(invocation.id) == null) return; + respondCompleted(invocation.bc.cdp, invocation.bc, invocation, value) catch |err| { + log.err(.cdp, "WebMCP fulfilled", .{ .err = err }); + }; +} + +fn onPromiseRejected(invocation: *Invocation, reason: js.Value) anyerror!void { + if (invocation.bc.webmcp_invocations.fetchRemove(invocation.id) == null) return; + const msg = reason.toStringSliceWithAlloc(invocation.bc.notification_arena) catch "tool rejected"; + respondError(invocation.bc.cdp, invocation.bc, invocation, msg) catch |err| { + log.err(.cdp, "WebMCP rejected", .{ .err = err }); + }; +} + +fn respondCompleted( + cdp: *CDP, + bc: *CDP.BrowserContext, + invocation: *Invocation, + value: js.Value, +) !void { + const arena = bc.notification_arena; + const output_json = value.toJson(arena) catch "null"; + const inv_id_str = try std.fmt.allocPrint(arena, INVOCATION_PREFIX ++ "{d}", .{invocation.id}); + try cdp.sendEvent("WebMCP.toolResponded", .{ + .invocationId = inv_id_str, + .status = "Completed", + .output = RawJson{ .raw = output_json }, + }, .{ .session_id = bc.session_id }); + _ = bc.webmcp_invocations.remove(invocation.id); +} + +// Embeds a pre-stringified JSON value into the outer payload. +const RawJson = struct { + raw: []const u8, + + pub fn jsonStringify(self: RawJson, w: anytype) !void { + try w.print("{s}", .{self.raw}); + } +}; + +fn respondError( + cdp: *CDP, + bc: *CDP.BrowserContext, + invocation: *Invocation, + err_text: []const u8, +) !void { + const arena = bc.notification_arena; + const inv_id_str = try std.fmt.allocPrint(arena, INVOCATION_PREFIX ++ "{d}", .{invocation.id}); + try cdp.sendEvent("WebMCP.toolResponded", .{ + .invocationId = inv_id_str, + .status = "Error", + .errorText = err_text, + }, .{ .session_id = bc.session_id }); + _ = bc.webmcp_invocations.remove(invocation.id); +} + +// === Tool added / removed dispatch (called from BrowserContext) === + +pub fn onToolAdded( + arena: Allocator, + bc: *CDP.BrowserContext, + event: *const Notification.ModelContextToolEvent, +) !void { + var ls: js.Local.Scope = undefined; + event.frame.js.localScope(&ls); + defer ls.deinit(); + + const writer = ToolWriter{ + .frame_id = id_mod.toFrameId(event.frame._frame_id), + .tools = &.{event.tool}, + .local = &ls.local, + .arena = arena, + }; + try bc.cdp.sendEvent("WebMCP.toolsAdded", .{ + .tools = writer, + }, .{ .session_id = bc.session_id }); +} + +pub fn onToolRemoved( + arena: Allocator, + bc: *CDP.BrowserContext, + event: *const Notification.ModelContextToolEvent, +) !void { + _ = arena; + const frame_id_str = id_mod.toFrameId(event.frame._frame_id); + try bc.cdp.sendEvent("WebMCP.toolsRemoved", .{ + .tools = &.{ + .{ .name = event.tool.name, .frameId = &frame_id_str }, + }, + }, .{ .session_id = bc.session_id }); +} + +fn sendToolsAdded( + cdp: *CDP, + bc: *CDP.BrowserContext, + frame: *Frame, + tools: []const *const ModelContext.Tool, +) !void { + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + const writer = ToolWriter{ + .frame_id = id_mod.toFrameId(frame._frame_id), + .tools = tools, + .local = &ls.local, + .arena = bc.notification_arena, + }; + try cdp.sendEvent("WebMCP.toolsAdded", .{ .tools = writer }, .{ .session_id = bc.session_id }); +} + +const testing = @import("../testing.zig"); + +test "cdp.WebMCP: enable replays existing tools" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ + .id = "BID-M", + .session_id = "SID-M", + .target_id = "TID-000000000M".*, + .url = "cdp/webmcp_fixture.html", + }); + _ = bc; + + try ctx.processMessage(.{ + .id = 1, + .method = "WebMCP.enable", + .session_id = "SID-M", + }); + try ctx.expectSentResult(null, .{ .id = 1 }); + + // The fixture registered `greet` before enable — should be replayed. + try ctx.expectSentEvent("WebMCP.toolsAdded", .{ + .tools = &.{ + .{ + .name = "greet", + .description = "Returns a greeting for the given person", + .annotations = .{ + .readOnly = true, + .untrustedContent = false, + .autosubmit = false, + }, + }, + }, + }, .{ .session_id = "SID-M" }); +} + +test "cdp.WebMCP: register fires toolsAdded after enable" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ + .id = "BID-M", + .session_id = "SID-M", + .target_id = "TID-000000000M".*, + .url = "cdp/webmcp_fixture.html", + }); + + try ctx.processMessage(.{ .id = 1, .method = "WebMCP.enable", .session_id = "SID-M" }); + try ctx.expectSentResult(null, .{ .id = 1 }); + + // Drain the initial replay. + try ctx.expectSentEvent("WebMCP.toolsAdded", .{ + .tools = &.{.{ .name = "greet" }}, + }, .{ .session_id = "SID-M" }); + + // Register a fresh tool from JS, expect a new toolsAdded event. + var ls: @import("../../browser/js/js.zig").Local.Scope = undefined; + bc.session.currentFrame().?.js.localScope(&ls); + defer ls.deinit(); + _ = try ls.local.exec( + \\navigator.modelContext.registerTool({ + \\ name: 'echo', + \\ description: 'echo input back', + \\ execute: async (input) => input, + \\}); + , "register-echo"); + + try ctx.expectSentEvent("WebMCP.toolsAdded", .{ + .tools = &.{.{ .name = "echo", .description = "echo input back" }}, + }, .{ .session_id = "SID-M" }); +} + +test "cdp.WebMCP: invokeTool fires toolInvoked + toolResponded" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ + .id = "BID-M", + .session_id = "SID-M", + .target_id = "TID-000000000M".*, + .url = "cdp/webmcp_fixture.html", + }); + const frame_id = id_mod.toFrameId(bc.session.currentFrame().?._frame_id); + + try ctx.processMessage(.{ .id = 1, .method = "WebMCP.enable", .session_id = "SID-M" }); + try ctx.expectSentResult(null, .{ .id = 1 }); + try ctx.expectSentEvent("WebMCP.toolsAdded", null, .{ .session_id = "SID-M" }); + + try ctx.processMessage(.{ + .id = 2, + .method = "WebMCP.invokeTool", + .session_id = "SID-M", + .params = .{ + .frameId = &frame_id, + .toolName = "greet", + .input = .{ .who = "world" }, + }, + }); + try ctx.expectSentResult(.{ .invocationId = "INV-1" }, .{ .id = 2 }); + + try ctx.expectSentEvent("WebMCP.toolInvoked", .{ + .toolName = "greet", + .frameId = &frame_id, + .invocationId = "INV-1", + }, .{ .session_id = "SID-M" }); + + try ctx.expectSentEvent("WebMCP.toolResponded", .{ + .invocationId = "INV-1", + .status = "Completed", + }, .{ .session_id = "SID-M" }); +} + +test "cdp.WebMCP: invokeTool unknown name" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ + .id = "BID-M", + .session_id = "SID-M", + .target_id = "TID-000000000M".*, + .url = "cdp/webmcp_fixture.html", + }); + const frame_id = id_mod.toFrameId(bc.session.currentFrame().?._frame_id); + + try ctx.processMessage(.{ .id = 1, .method = "WebMCP.enable", .session_id = "SID-M" }); + try ctx.expectSentResult(null, .{ .id = 1 }); + try ctx.expectSentEvent("WebMCP.toolsAdded", null, .{ .session_id = "SID-M" }); + + try ctx.processMessage(.{ + .id = 2, + .method = "WebMCP.invokeTool", + .session_id = "SID-M", + .params = .{ + .frameId = &frame_id, + .toolName = "does_not_exist", + .input = .{}, + }, + }); + try ctx.expectSentError(-31998, "NotFound", .{ .id = 2 }); +} + +test "cdp.WebMCP: cancelInvocation" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ + .id = "BID-M", + .session_id = "SID-M", + .target_id = "TID-000000000M".*, + .url = "cdp/webmcp_fixture.html", + }); + + try ctx.processMessage(.{ .id = 1, .method = "WebMCP.enable", .session_id = "SID-M" }); + try ctx.expectSentResult(null, .{ .id = 1 }); + try ctx.expectSentEvent("WebMCP.toolsAdded", null, .{ .session_id = "SID-M" }); + + // Register a never-settling tool so we have an invocation to cancel. + var ls: @import("../../browser/js/js.zig").Local.Scope = undefined; + bc.session.currentFrame().?.js.localScope(&ls); + defer ls.deinit(); + _ = try ls.local.exec( + \\navigator.modelContext.registerTool({ + \\ name: 'hang', + \\ description: 'never settles', + \\ execute: () => new Promise(() => {}), + \\}); + , "register-hang"); + try ctx.expectSentEvent("WebMCP.toolsAdded", .{ + .tools = &.{.{ .name = "hang" }}, + }, .{ .session_id = "SID-M" }); + + const frame_id = id_mod.toFrameId(bc.session.currentFrame().?._frame_id); + try ctx.processMessage(.{ + .id = 2, + .method = "WebMCP.invokeTool", + .session_id = "SID-M", + .params = .{ + .frameId = &frame_id, + .toolName = "hang", + .input = .{}, + }, + }); + try ctx.expectSentResult(.{ .invocationId = "INV-1" }, .{ .id = 2 }); + try ctx.expectSentEvent("WebMCP.toolInvoked", .{ .invocationId = "INV-1" }, .{ .session_id = "SID-M" }); + + try ctx.processMessage(.{ + .id = 3, + .method = "WebMCP.cancelInvocation", + .session_id = "SID-M", + .params = .{ .invocationId = "INV-1" }, + }); + try ctx.expectSentResult(null, .{ .id = 3 }); + try ctx.expectSentEvent("WebMCP.toolResponded", .{ + .invocationId = "INV-1", + .status = "Canceled", + }, .{ .session_id = "SID-M" }); +} + +// Serializes a slice of `*const ModelContext.Tool` as the +// `WebMCP.toolsAdded.params.tools` array. Each tool's `inputSchema` is +// an arbitrary JS object — we round-trip it through `JSON.stringify` and +// embed the raw JSON. +const ToolWriter = struct { + frame_id: [14]u8, + tools: []const *const ModelContext.Tool, + local: *const js.Local, + arena: Allocator, + + pub fn jsonStringify(self: *const ToolWriter, w: anytype) !void { + try w.beginArray(); + for (self.tools) |t| { + try w.beginObject(); + + try w.objectField("name"); + try w.write(t.name); + + try w.objectField("description"); + try w.write(t.description); + + try w.objectField("inputSchema"); + if (t.input_schema) |schema_global| { + const schema_obj = schema_global.local(self.local); + const schema_json = schema_obj.toValue().toJson(self.arena) catch "{}"; + try w.print("{s}", .{schema_json}); + } else { + try w.beginObject(); + try w.endObject(); + } + + try w.objectField("annotations"); + try w.beginObject(); + try w.objectField("readOnly"); + try w.write(t.annotations.readOnlyHint); + try w.objectField("untrustedContent"); + try w.write(t.annotations.untrustedContentHint); + try w.objectField("autosubmit"); + try w.write(t.annotations.autoSubmitHint); + try w.endObject(); + + try w.objectField("frameId"); + try w.write(&self.frame_id); + + try w.endObject(); + } + try w.endArray(); + } +}; From 3ef6e57d58a91e552edca05eb5efc371c3c0f3ae Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 13 May 2026 11:13:51 +0200 Subject: [PATCH 03/10] cdp: adjust invocation id usage for webmcp --- src/cdp/CDP.zig | 5 ++- src/cdp/domains/webmcp.zig | 71 ++++++++++++-------------------------- src/cdp/id.zig | 13 ++++++- 3 files changed, 38 insertions(+), 51 deletions(-) diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index e5f595ea..1a9f34df 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -46,6 +46,8 @@ const IS_DEBUG = @import("builtin").mode == .Debug; const SessionIdGen = Incrementing(u32, "SID"); const BrowserContextIdGen = Incrementing(u32, "BID"); +// webmcp tool invocation +pub const InvocationIdGen = Incrementing(u32, "INV"); // Generic so that we can inject mocks into it. const CDP = @This(); @@ -490,8 +492,9 @@ pub const BrowserContext = struct { // own message arena. pending_dialog_response: ?Notification.DialogResponse = null, + // webmcp tool invocation + invocation_id_gen: InvocationIdGen = .{}, // WebMCP domain state. Populated when `WebMCP.enable` is received. - webmcp_next_invocation_id: u32 = 0, webmcp_invocations: std.AutoHashMapUnmanaged(u32, *@import("domains/webmcp.zig").Invocation) = .empty, fn init(self: *BrowserContext, id: []const u8, cdp: *CDP) !void { diff --git a/src/cdp/domains/webmcp.zig b/src/cdp/domains/webmcp.zig index a7fbe6ce..844c6d5f 100644 --- a/src/cdp/domains/webmcp.zig +++ b/src/cdp/domains/webmcp.zig @@ -18,21 +18,10 @@ // CDP WebMCP domain. // https://chromedevtools.github.io/devtools-protocol/tot/WebMCP/ -// -// Bridges the page-side `navigator.modelContext` surface (see -// browser/webapi/ModelContext.zig) to a CDP client. The client `enable`s -// the domain, then receives: -// - `toolsAdded` whenever the page registers tools. -// - `toolsRemoved` whenever an AbortSignal-bound tool's signal fires -// (lazy — detected on the next compaction). -// - `toolInvoked` immediately after the client sends `invokeTool`. -// - `toolResponded` once the tool's execute promise settles, or -// immediately on `cancelInvocation`. - const std = @import("std"); const lp = @import("lightpanda"); -const id_mod = @import("../id.zig"); +const id = @import("../id.zig"); const CDP = @import("../CDP.zig"); const ModelContext = @import("../../browser/webapi/ModelContext.zig"); @@ -44,8 +33,6 @@ const ModelContextClient = ModelContext.ModelContextClient; const log = lp.log; const Allocator = std.mem.Allocator; -const INVOCATION_PREFIX = "INV-"; - pub const Invocation = struct { id: u32, bc: *CDP.BrowserContext, @@ -103,7 +90,7 @@ fn invokeTool(cmd: *CDP.Command) !void { const params = (try cmd.params(InvokeToolParams)) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const frame_id = try id_mod.parseFrameId(params.frameId); + const frame_id = try id.parseFrameId(params.frameId); const frame = bc.session.findFrameByFrameId(frame_id) orelse return error.FrameNotFound; const mc = frame.window.getModelContext(); const tool = mc.findTool(frame, params.toolName) orelse return error.NotFound; @@ -112,9 +99,8 @@ fn invokeTool(cmd: *CDP.Command) !void { // `toolInvoked.input` and pass the parsed form into the JS callback. const input_str = try std.json.Stringify.valueAlloc(cmd.arena, params.input, .{}); - bc.webmcp_next_invocation_id +%= 1; - const inv_id = bc.webmcp_next_invocation_id; - const inv_id_str = try std.fmt.allocPrint(cmd.arena, INVOCATION_PREFIX ++ "{d}", .{inv_id}); + const inv_id = bc.invocation_id_gen.incr(); + const inv_id_str = &id.toInvocationId(inv_id); const invocation = try bc.arena.create(Invocation); invocation.* = .{ @@ -128,7 +114,7 @@ fn invokeTool(cmd: *CDP.Command) !void { // Send toolInvoked event before we run the JS, so the client sees // them in order even if the tool resolves synchronously. const session_id = bc.session_id; - const frame_id_str = id_mod.toFrameId(frame_id); + const frame_id_str = id.toFrameId(frame_id); try cmd.sendEvent("WebMCP.toolInvoked", .{ .toolName = tool.name, .frameId = &frame_id_str, @@ -188,26 +174,18 @@ fn cancelInvocation(cmd: *CDP.Command) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const inv_id = parseInvocationId(params.invocationId) orelse return error.InvalidParams; + const inv_id = CDP.InvocationIdGen.parse(params.invocationId) catch return error.InvalidParams; const entry = bc.webmcp_invocations.fetchRemove(inv_id) orelse return error.NotFound; entry.value.canceled = true; - const inv_id_str = try std.fmt.allocPrint(cmd.arena, INVOCATION_PREFIX ++ "{d}", .{inv_id}); try cmd.cdp.sendEvent("WebMCP.toolResponded", .{ - .invocationId = inv_id_str, + .invocationId = &id.toInvocationId(inv_id), .status = "Canceled", }, .{ .session_id = bc.session_id }); return cmd.sendResult(null, .{}); } -fn parseInvocationId(s: []const u8) ?u32 { - if (!std.mem.startsWith(u8, s, INVOCATION_PREFIX)) return null; - return std.fmt.parseInt(u32, s[INVOCATION_PREFIX.len..], 10) catch null; -} - -// === Promise resolution callbacks === - fn onPromiseFulfilled(invocation: *Invocation, value: js.Value) anyerror!void { // The map is the source of truth for "still active". cancelInvocation // removes it from the map and sends Canceled; we drop the late result. @@ -233,9 +211,8 @@ fn respondCompleted( ) !void { const arena = bc.notification_arena; const output_json = value.toJson(arena) catch "null"; - const inv_id_str = try std.fmt.allocPrint(arena, INVOCATION_PREFIX ++ "{d}", .{invocation.id}); try cdp.sendEvent("WebMCP.toolResponded", .{ - .invocationId = inv_id_str, + .invocationId = &id.toInvocationId(invocation.id), .status = "Completed", .output = RawJson{ .raw = output_json }, }, .{ .session_id = bc.session_id }); @@ -257,18 +234,14 @@ fn respondError( invocation: *Invocation, err_text: []const u8, ) !void { - const arena = bc.notification_arena; - const inv_id_str = try std.fmt.allocPrint(arena, INVOCATION_PREFIX ++ "{d}", .{invocation.id}); try cdp.sendEvent("WebMCP.toolResponded", .{ - .invocationId = inv_id_str, + .invocationId = &id.toInvocationId(invocation.id), .status = "Error", .errorText = err_text, }, .{ .session_id = bc.session_id }); _ = bc.webmcp_invocations.remove(invocation.id); } -// === Tool added / removed dispatch (called from BrowserContext) === - pub fn onToolAdded( arena: Allocator, bc: *CDP.BrowserContext, @@ -279,7 +252,7 @@ pub fn onToolAdded( defer ls.deinit(); const writer = ToolWriter{ - .frame_id = id_mod.toFrameId(event.frame._frame_id), + .frame_id = id.toFrameId(event.frame._frame_id), .tools = &.{event.tool}, .local = &ls.local, .arena = arena, @@ -295,7 +268,7 @@ pub fn onToolRemoved( event: *const Notification.ModelContextToolEvent, ) !void { _ = arena; - const frame_id_str = id_mod.toFrameId(event.frame._frame_id); + const frame_id_str = id.toFrameId(event.frame._frame_id); try bc.cdp.sendEvent("WebMCP.toolsRemoved", .{ .tools = &.{ .{ .name = event.tool.name, .frameId = &frame_id_str }, @@ -314,7 +287,7 @@ fn sendToolsAdded( defer ls.deinit(); const writer = ToolWriter{ - .frame_id = id_mod.toFrameId(frame._frame_id), + .frame_id = id.toFrameId(frame._frame_id), .tools = tools, .local = &ls.local, .arena = bc.notification_arena, @@ -405,7 +378,7 @@ test "cdp.WebMCP: invokeTool fires toolInvoked + toolResponded" { .target_id = "TID-000000000M".*, .url = "cdp/webmcp_fixture.html", }); - const frame_id = id_mod.toFrameId(bc.session.currentFrame().?._frame_id); + const frame_id = id.toFrameId(bc.session.currentFrame().?._frame_id); try ctx.processMessage(.{ .id = 1, .method = "WebMCP.enable", .session_id = "SID-M" }); try ctx.expectSentResult(null, .{ .id = 1 }); @@ -421,16 +394,16 @@ test "cdp.WebMCP: invokeTool fires toolInvoked + toolResponded" { .input = .{ .who = "world" }, }, }); - try ctx.expectSentResult(.{ .invocationId = "INV-1" }, .{ .id = 2 }); + try ctx.expectSentResult(.{ .invocationId = "INV-0000000001" }, .{ .id = 2 }); try ctx.expectSentEvent("WebMCP.toolInvoked", .{ .toolName = "greet", .frameId = &frame_id, - .invocationId = "INV-1", + .invocationId = "INV-0000000001", }, .{ .session_id = "SID-M" }); try ctx.expectSentEvent("WebMCP.toolResponded", .{ - .invocationId = "INV-1", + .invocationId = "INV-0000000001", .status = "Completed", }, .{ .session_id = "SID-M" }); } @@ -445,7 +418,7 @@ test "cdp.WebMCP: invokeTool unknown name" { .target_id = "TID-000000000M".*, .url = "cdp/webmcp_fixture.html", }); - const frame_id = id_mod.toFrameId(bc.session.currentFrame().?._frame_id); + const frame_id = id.toFrameId(bc.session.currentFrame().?._frame_id); try ctx.processMessage(.{ .id = 1, .method = "WebMCP.enable", .session_id = "SID-M" }); try ctx.expectSentResult(null, .{ .id = 1 }); @@ -494,7 +467,7 @@ test "cdp.WebMCP: cancelInvocation" { .tools = &.{.{ .name = "hang" }}, }, .{ .session_id = "SID-M" }); - const frame_id = id_mod.toFrameId(bc.session.currentFrame().?._frame_id); + const frame_id = id.toFrameId(bc.session.currentFrame().?._frame_id); try ctx.processMessage(.{ .id = 2, .method = "WebMCP.invokeTool", @@ -505,18 +478,18 @@ test "cdp.WebMCP: cancelInvocation" { .input = .{}, }, }); - try ctx.expectSentResult(.{ .invocationId = "INV-1" }, .{ .id = 2 }); - try ctx.expectSentEvent("WebMCP.toolInvoked", .{ .invocationId = "INV-1" }, .{ .session_id = "SID-M" }); + try ctx.expectSentResult(.{ .invocationId = "INV-0000000001" }, .{ .id = 2 }); + try ctx.expectSentEvent("WebMCP.toolInvoked", .{ .invocationId = "INV-0000000001" }, .{ .session_id = "SID-M" }); try ctx.processMessage(.{ .id = 3, .method = "WebMCP.cancelInvocation", .session_id = "SID-M", - .params = .{ .invocationId = "INV-1" }, + .params = .{ .invocationId = "INV-0000000001" }, }); try ctx.expectSentResult(null, .{ .id = 3 }); try ctx.expectSentEvent("WebMCP.toolResponded", .{ - .invocationId = "INV-1", + .invocationId = "INV-0000000001", .status = "Canceled", }, .{ .session_id = "SID-M" }); } diff --git a/src/cdp/id.zig b/src/cdp/id.zig index a2e01786..94f222b3 100644 --- a/src/cdp/id.zig +++ b/src/cdp/id.zig @@ -57,6 +57,12 @@ pub fn toInterceptId(id: u32) [14]u8 { return buf; } +pub fn toInvocationId(id: u32) [14]u8 { + var buf: [14]u8 = undefined; + _ = std.fmt.bufPrint(&buf, "INV-{d:0>10}", .{id}) catch unreachable; + return buf; +} + // Generates incrementing prefixed integers, i.e. CTX-1, CTX-2, CTX-3. // Wraps to 0 on overflow. // Many caveats for using this: @@ -97,11 +103,16 @@ pub fn Incrementing(comptime T: type, comptime prefix: []const u8) type { const Self = @This(); - pub fn next(self: *Self) []const u8 { + pub fn incr(self: *Self) T { const counter = self.counter; const n = counter +% 1; defer self.counter = n; + return n; + } + + pub fn next(self: *Self) []const u8 { + const n = self.incr(); const size = std.fmt.printInt(self.buffer[NUMERIC_START..], n, 10, .lower, .{}); return self.buffer[0 .. NUMERIC_START + size]; } From 5e0901aaf78f5c824fc02c991c181cb2d1087572 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 13 May 2026 15:14:47 +0200 Subject: [PATCH 04/10] cdp: fix invalid arena usage in webmcp --- src/cdp/CDP.zig | 3 +-- src/cdp/domains/webmcp.zig | 48 +++++++++++--------------------------- 2 files changed, 14 insertions(+), 37 deletions(-) diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 1a9f34df..869c01db 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -755,8 +755,7 @@ pub const BrowserContext = struct { pub fn onModelContextToolRemoved(ctx: *anyopaque, event: *const Notification.ModelContextToolEvent) !void { const self: *BrowserContext = @ptrCast(@alignCast(ctx)); - defer self.resetNotificationArena(); - return @import("domains/webmcp.zig").onToolRemoved(self.notification_arena, self, event); + return @import("domains/webmcp.zig").onToolRemoved(self, event); } pub fn onFrameRemove(ctx: *anyopaque, _: Notification.FrameRemove) !void { diff --git a/src/cdp/domains/webmcp.zig b/src/cdp/domains/webmcp.zig index 844c6d5f..55cf450a 100644 --- a/src/cdp/domains/webmcp.zig +++ b/src/cdp/domains/webmcp.zig @@ -67,7 +67,17 @@ fn enable(cmd: *CDP.Command) !void { const mc = frame.window.getModelContext(); const tools = mc.tools(frame); if (tools.len > 0) { - try sendToolsAdded(cmd.cdp, bc, frame, tools); + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + const writer = ToolWriter{ + .frame_id = id.toFrameId(frame._frame_id), + .tools = tools, + .local = &ls.local, + .arena = cmd.arena, + }; + try bc.cdp.sendEvent("WebMCP.toolsAdded", .{ .tools = writer }, .{ .session_id = bc.session_id }); } } @@ -197,7 +207,7 @@ fn onPromiseFulfilled(invocation: *Invocation, value: js.Value) anyerror!void { fn onPromiseRejected(invocation: *Invocation, reason: js.Value) anyerror!void { if (invocation.bc.webmcp_invocations.fetchRemove(invocation.id) == null) return; - const msg = reason.toStringSliceWithAlloc(invocation.bc.notification_arena) catch "tool rejected"; + const msg = reason.toStringSlice() catch "tool rejected"; respondError(invocation.bc.cdp, invocation.bc, invocation, msg) catch |err| { log.err(.cdp, "WebMCP rejected", .{ .err = err }); }; @@ -209,25 +219,14 @@ fn respondCompleted( invocation: *Invocation, value: js.Value, ) !void { - const arena = bc.notification_arena; - const output_json = value.toJson(arena) catch "null"; try cdp.sendEvent("WebMCP.toolResponded", .{ .invocationId = &id.toInvocationId(invocation.id), .status = "Completed", - .output = RawJson{ .raw = output_json }, + .output = value, }, .{ .session_id = bc.session_id }); _ = bc.webmcp_invocations.remove(invocation.id); } -// Embeds a pre-stringified JSON value into the outer payload. -const RawJson = struct { - raw: []const u8, - - pub fn jsonStringify(self: RawJson, w: anytype) !void { - try w.print("{s}", .{self.raw}); - } -}; - fn respondError( cdp: *CDP, bc: *CDP.BrowserContext, @@ -263,11 +262,9 @@ pub fn onToolAdded( } pub fn onToolRemoved( - arena: Allocator, bc: *CDP.BrowserContext, event: *const Notification.ModelContextToolEvent, ) !void { - _ = arena; const frame_id_str = id.toFrameId(event.frame._frame_id); try bc.cdp.sendEvent("WebMCP.toolsRemoved", .{ .tools = &.{ @@ -276,25 +273,6 @@ pub fn onToolRemoved( }, .{ .session_id = bc.session_id }); } -fn sendToolsAdded( - cdp: *CDP, - bc: *CDP.BrowserContext, - frame: *Frame, - tools: []const *const ModelContext.Tool, -) !void { - var ls: js.Local.Scope = undefined; - frame.js.localScope(&ls); - defer ls.deinit(); - - const writer = ToolWriter{ - .frame_id = id.toFrameId(frame._frame_id), - .tools = tools, - .local = &ls.local, - .arena = bc.notification_arena, - }; - try cdp.sendEvent("WebMCP.toolsAdded", .{ .tools = writer }, .{ .session_id = bc.session_id }); -} - const testing = @import("../testing.zig"); test "cdp.WebMCP: enable replays existing tools" { From 19fd9a6e3544b90df606060e51ee28f8e188d9ff Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 13 May 2026 15:32:16 +0200 Subject: [PATCH 05/10] cdp: adjust inv_id address usage --- src/cdp/domains/webmcp.zig | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/cdp/domains/webmcp.zig b/src/cdp/domains/webmcp.zig index 55cf450a..8fd813bb 100644 --- a/src/cdp/domains/webmcp.zig +++ b/src/cdp/domains/webmcp.zig @@ -110,7 +110,7 @@ fn invokeTool(cmd: *CDP.Command) !void { const input_str = try std.json.Stringify.valueAlloc(cmd.arena, params.input, .{}); const inv_id = bc.invocation_id_gen.incr(); - const inv_id_str = &id.toInvocationId(inv_id); + const inv_id_str = id.toInvocationId(inv_id); const invocation = try bc.arena.create(Invocation); invocation.* = .{ @@ -124,11 +124,10 @@ fn invokeTool(cmd: *CDP.Command) !void { // Send toolInvoked event before we run the JS, so the client sees // them in order even if the tool resolves synchronously. const session_id = bc.session_id; - const frame_id_str = id.toFrameId(frame_id); try cmd.sendEvent("WebMCP.toolInvoked", .{ .toolName = tool.name, - .frameId = &frame_id_str, - .invocationId = inv_id_str, + .frameId = id.toFrameId(frame_id), + .invocationId = &inv_id_str, .input = input_str, }, .{ .session_id = session_id }); @@ -140,7 +139,7 @@ fn invokeTool(cmd: *CDP.Command) !void { const input_value = local.parseJSON(input_str) catch { try respondError(cmd.cdp, bc, invocation, "failed to parse input JSON"); - return cmd.sendResult(.{ .invocationId = inv_id_str }, .{}); + return cmd.sendResult(.{ .invocationId = &inv_id_str }, .{}); }; const callback = local.toLocal(tool.execute); @@ -155,13 +154,13 @@ fn invokeTool(cmd: *CDP.Command) !void { const result = callback.tryCall(js.Value, .{ input_value, client_value }, &caught) catch { const msg = caught.exception orelse "tool threw"; try respondError(cmd.cdp, bc, invocation, msg); - return cmd.sendResult(.{ .invocationId = inv_id_str }, .{}); + return cmd.sendResult(.{ .invocationId = &inv_id_str }, .{}); }; // If the tool returned a non-promise value, settle immediately. if (!result.isPromise()) { try respondCompleted(cmd.cdp, bc, invocation, result); - return cmd.sendResult(.{ .invocationId = inv_id_str }, .{}); + return cmd.sendResult(.{ .invocationId = &inv_id_str }, .{}); } const promise = js.Promise{ .local = local, .handle = @ptrCast(result.handle) }; @@ -171,10 +170,10 @@ fn invokeTool(cmd: *CDP.Command) !void { // If we couldn't chain, settle as error. Map entry will be // cleaned up below. try respondError(cmd.cdp, bc, invocation, "promise chain failed"); - return cmd.sendResult(.{ .invocationId = inv_id_str }, .{}); + return cmd.sendResult(.{ .invocationId = &inv_id_str }, .{}); }; - return cmd.sendResult(.{ .invocationId = inv_id_str }, .{}); + return cmd.sendResult(.{ .invocationId = &inv_id_str }, .{}); } fn cancelInvocation(cmd: *CDP.Command) !void { @@ -265,10 +264,9 @@ pub fn onToolRemoved( bc: *CDP.BrowserContext, event: *const Notification.ModelContextToolEvent, ) !void { - const frame_id_str = id.toFrameId(event.frame._frame_id); try bc.cdp.sendEvent("WebMCP.toolsRemoved", .{ .tools = &.{ - .{ .name = event.tool.name, .frameId = &frame_id_str }, + .{ .name = event.tool.name, .frameId = id.toFrameId(event.frame._frame_id) }, }, }, .{ .session_id = bc.session_id }); } From 7c5a3b211f43f72992e7f64cdc9528f0e1e7375d Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 13 May 2026 15:51:56 +0200 Subject: [PATCH 06/10] cdp: cancel inflight webmcp invocation on bc deinit --- src/cdp/CDP.zig | 5 +++++ src/cdp/domains/webmcp.zig | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 869c01db..65810917 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -578,6 +578,11 @@ pub const BrowserContext = struct { } } + // Notify any CDP client waiting on an in-flight WebMCP invocation + // before the V8 context (and its promise callbacks) get torn down + // by browser.closeSession below. + @import("domains/webmcp.zig").cancelAllPending(self); + for (self.isolated_worlds.items) |world| { world.deinit(); } diff --git a/src/cdp/domains/webmcp.zig b/src/cdp/domains/webmcp.zig index 8fd813bb..00f13a77 100644 --- a/src/cdp/domains/webmcp.zig +++ b/src/cdp/domains/webmcp.zig @@ -195,6 +195,25 @@ fn cancelInvocation(cmd: *CDP.Command) !void { return cmd.sendResult(null, .{}); } +// Called from BrowserContext.deinit. Sends `toolResponded { status: "Canceled" }` +// for every pending invocation so a CDP client waiting on `invokeTool` doesn't +// see the invocation go silent on teardown. The V8 promise callbacks become +// unreachable once the JS context is torn down by browser.closeSession, so +// we don't need to clear them ourselves. +pub fn cancelAllPending(bc: *CDP.BrowserContext) void { + var it = bc.webmcp_invocations.iterator(); + while (it.next()) |entry| { + const invocation = entry.value_ptr.*; + bc.cdp.sendEvent("WebMCP.toolResponded", .{ + .invocationId = &id.toInvocationId(invocation.id), + .status = "Canceled", + }, .{ .session_id = bc.session_id }) catch |err| { + log.err(.cdp, "WebMCP cancelAllPending", .{ .err = err }); + }; + } + bc.webmcp_invocations.clearRetainingCapacity(); +} + fn onPromiseFulfilled(invocation: *Invocation, value: js.Value) anyerror!void { // The map is the source of truth for "still active". cancelInvocation // removes it from the map and sends Canceled; we drop the late result. From dbb9b31061716603b41978f57d39e60c59ffcdb1 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 15 May 2026 11:00:56 +0200 Subject: [PATCH 07/10] webmcp: fix invoke callback with correct ModelContextClient param --- src/cdp/domains/webmcp.zig | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/cdp/domains/webmcp.zig b/src/cdp/domains/webmcp.zig index 00f13a77..e80ec93c 100644 --- a/src/cdp/domains/webmcp.zig +++ b/src/cdp/domains/webmcp.zig @@ -144,14 +144,8 @@ fn invokeTool(cmd: *CDP.Command) !void { const callback = local.toLocal(tool.execute); - // ModelContextClient has no per-instance state today (see - // ModelContext.zig). We still build a fresh wrapper so the page-side - // signature `execute(input, client)` works as documented. - var client_inst = ModelContextClient{}; - const client_value = try local.zigValueToJs(&client_inst, .{}); - var caught: js.TryCatch.Caught = undefined; - const result = callback.tryCall(js.Value, .{ input_value, client_value }, &caught) catch { + const result = callback.tryCall(js.Value, .{ input_value, ModelContextClient{} }, &caught) catch { const msg = caught.exception orelse "tool threw"; try respondError(cmd.cdp, bc, invocation, msg); return cmd.sendResult(.{ .invocationId = &inv_id_str }, .{}); From 3803a1f8c6db8416489c593cdabab29013135d82 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 15 May 2026 11:17:53 +0200 Subject: [PATCH 08/10] webmcp: use value.jsonStringify for JSON write --- src/cdp/CDP.zig | 3 +-- src/cdp/domains/webmcp.zig | 10 ++-------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 65810917..318df63c 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -754,8 +754,7 @@ pub const BrowserContext = struct { pub fn onModelContextToolAdded(ctx: *anyopaque, event: *const Notification.ModelContextToolEvent) !void { const self: *BrowserContext = @ptrCast(@alignCast(ctx)); - defer self.resetNotificationArena(); - return @import("domains/webmcp.zig").onToolAdded(self.notification_arena, self, event); + return @import("domains/webmcp.zig").onToolAdded(self, event); } pub fn onModelContextToolRemoved(ctx: *anyopaque, event: *const Notification.ModelContextToolEvent) !void { diff --git a/src/cdp/domains/webmcp.zig b/src/cdp/domains/webmcp.zig index e80ec93c..d35fe26a 100644 --- a/src/cdp/domains/webmcp.zig +++ b/src/cdp/domains/webmcp.zig @@ -75,7 +75,6 @@ fn enable(cmd: *CDP.Command) !void { .frame_id = id.toFrameId(frame._frame_id), .tools = tools, .local = &ls.local, - .arena = cmd.arena, }; try bc.cdp.sendEvent("WebMCP.toolsAdded", .{ .tools = writer }, .{ .session_id = bc.session_id }); } @@ -254,7 +253,6 @@ fn respondError( } pub fn onToolAdded( - arena: Allocator, bc: *CDP.BrowserContext, event: *const Notification.ModelContextToolEvent, ) !void { @@ -266,7 +264,6 @@ pub fn onToolAdded( .frame_id = id.toFrameId(event.frame._frame_id), .tools = &.{event.tool}, .local = &ls.local, - .arena = arena, }; try bc.cdp.sendEvent("WebMCP.toolsAdded", .{ .tools = writer, @@ -491,7 +488,6 @@ const ToolWriter = struct { frame_id: [14]u8, tools: []const *const ModelContext.Tool, local: *const js.Local, - arena: Allocator, pub fn jsonStringify(self: *const ToolWriter, w: anytype) !void { try w.beginArray(); @@ -505,10 +501,8 @@ const ToolWriter = struct { try w.write(t.description); try w.objectField("inputSchema"); - if (t.input_schema) |schema_global| { - const schema_obj = schema_global.local(self.local); - const schema_json = schema_obj.toValue().toJson(self.arena) catch "{}"; - try w.print("{s}", .{schema_json}); + if (t.input_schema) |is| { + try w.write(is.local(self.local).toValue()); } else { try w.beginObject(); try w.endObject(); From f00c0ab276bb80a9bb4f783010f2f85ee96b39ba Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 15 May 2026 13:11:59 +0200 Subject: [PATCH 09/10] webmcp: implement abortSignal with _dependent --- src/Notification.zig | 3 +- src/browser/webapi/AbortSignal.zig | 34 ++++++++++++++--- src/browser/webapi/ModelContext.zig | 57 ++++++++++++++++++----------- src/cdp/domains/webmcp.zig | 19 +++++++--- 4 files changed, 81 insertions(+), 32 deletions(-) diff --git a/src/Notification.zig b/src/Notification.zig index d98f3ecc..ccfd886f 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -25,6 +25,7 @@ const Transfer = @import("browser/HttpClient.zig").Transfer; const Response = @import("browser/HttpClient.zig").Response; const log = lp.log; +const Execution = js.Execution; const List = std.DoublyLinkedList; const Allocator = std.mem.Allocator; @@ -231,7 +232,7 @@ pub const JavascriptDialogOpening = struct { }; pub const ModelContextToolEvent = struct { - frame: *Frame, + exec: *const Execution, tool: *const ModelContextTool, }; diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig index 31a3f6a5..f6bd450f 100644 --- a/src/browser/webapi/AbortSignal.zig +++ b/src/browser/webapi/AbortSignal.zig @@ -24,18 +24,43 @@ const js = @import("../js/js.zig"); const Event = @import("Event.zig"); const EventTarget = @import("EventTarget.zig"); const DOMException = @import("DOMException.zig"); +const ModelContextTool = @import("ModelContext.zig").Tool; const log = lp.log; const Execution = js.Execution; const AbortSignal = @This(); +const Dependend = union(enum) { + signal: *AbortSignal, + model_context_tool: *ModelContextTool, + + fn markAborted(self: Dependend, reason_: ?Reason, exec: *const Execution) !void { + switch (self) { + .signal => |dep| { + if (dep._aborted) return; + try dep.markAborted(reason_, exec); + }, + .model_context_tool => |dep| { + try dep.markAborted(exec); + }, + } + } + + fn dispatchAbortEvent(self: Dependend, exec: *const Execution) !void { + switch (self) { + .signal => |dep| try dep.dispatchAbortEvent(exec), + .model_context_tool => {}, + } + } +}; + _proto: *EventTarget, _aborted: bool = false, _is_dependent: bool = false, _reason: Reason = .undefined, _on_abort: ?js.Function.Global = null, -_dependents: std.ArrayList(*AbortSignal) = .{}, +_dependents: std.ArrayList(Dependend) = .{}, _source_signals: std.ArrayList(*AbortSignal) = .{}, pub fn init(exec: *const Execution) !*AbortSignal { @@ -74,9 +99,8 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, exec: *const Execution) !void // Per spec: mark all direct dependents aborted (with this signal's reason) // BEFORE firing any abort events. The graph is flattened at any() creation, // so we never need to recurse here. - var to_dispatch: std.ArrayList(*AbortSignal) = .{}; + var to_dispatch: std.ArrayList(Dependend) = .{}; for (self._dependents.items) |dep| { - if (dep._aborted) continue; try dep.markAborted(self._reason, exec); try to_dispatch.append(exec.arena, dep); } @@ -136,11 +160,11 @@ pub fn createAny(signals: []const *AbortSignal, exec: *const Execution) !*AbortS for (signals) |source| { if (!source._is_dependent) { - try source._dependents.append(exec.arena, result); + try source._dependents.append(exec.arena, .{ .signal = result }); try result._source_signals.append(exec.arena, source); } else { for (source._source_signals.items) |s| { - try s._dependents.append(exec.arena, result); + try s._dependents.append(exec.arena, .{ .signal = result }); try result._source_signals.append(exec.arena, s); } } diff --git a/src/browser/webapi/ModelContext.zig b/src/browser/webapi/ModelContext.zig index be568196..c4890fe9 100644 --- a/src/browser/webapi/ModelContext.zig +++ b/src/browser/webapi/ModelContext.zig @@ -31,10 +31,10 @@ const std = @import("std"); const js = @import("../js/js.zig"); -const Frame = @import("../Frame.zig"); const Notification = @import("../../Notification.zig"); const AbortSignal = @import("AbortSignal.zig"); +const Execution = js.Execution; pub fn registerTypes() []const type { return &.{ ModelContext, ModelContextClient }; @@ -56,6 +56,7 @@ pub const Annotations = struct { }; pub const Tool = struct { + ctx: *ModelContext, name: []const u8, title: ?[]const u8, description: []const u8, @@ -66,6 +67,10 @@ pub const Tool = struct { // fires. Checked lazily on each `tools()` / `findTool()` call — fine // for headless usage where there's no synchronous observer to notify. signal: ?*AbortSignal, + + pub fn markAborted(self: *Tool, exec: *const Execution) !void { + try self.ctx.markAborted(self, exec); + } }; const ToolDict = struct { @@ -85,7 +90,7 @@ pub fn registerTool( self: *ModelContext, tool: ToolDict, options_: ?RegisterToolOptions, - frame: *Frame, + exec: *const Execution, ) !void { try validateName(tool.name); if (tool.description.len == 0) { @@ -104,16 +109,16 @@ pub fn registerTool( // Reject duplicate names. The spec says `InvalidStateError`. We compact // the list lazily here so a tool whose signal already aborted doesn't // block re-registering under the same name. - self.compactAborted(frame); for (self._tools.items) |existing| { if (std.mem.eql(u8, existing.name, tool.name)) { return error.InvalidStateError; } } - const arena = frame.arena; + const arena = exec.arena; const entry = try arena.create(Tool); entry.* = .{ + .ctx = self, .name = try arena.dupe(u8, tool.name), .title = if (tool.title) |t| try arena.dupe(u8, t) else null, .description = try arena.dupe(u8, tool.description), @@ -123,25 +128,31 @@ pub fn registerTool( .signal = options.signal, }; + if (entry.signal) |s| { + try s._dependents.append(arena, .{ .model_context_tool = entry }); + } try self._tools.append(arena, entry); // Fire `model_context_tool_added` so observers (CDP `WebMCP` domain, // native MCP forwarder) can surface the new tool. - const event: Notification.ModelContextToolEvent = .{ .frame = frame, .tool = entry }; - frame._session.notification.dispatch(.model_context_tool_added, &event); + const event: Notification.ModelContextToolEvent = .{ .exec = exec, .tool = entry }; + + const session = switch (exec.context.global) { + inline else => |g| g._session, + }; + + session.notification.dispatch(.model_context_tool_added, &event); } /// Snapshot of currently-registered tools, with aborted entries filtered. /// Used by the CDP `WebMCP.enable` replay and the native MCP forwarder. -pub fn tools(self: *ModelContext, frame: *Frame) []const *Tool { - self.compactAborted(frame); +pub fn tools(self: *ModelContext) []const *Tool { return self._tools.items; } /// Look up a tool by name. Returns null if not found or if its signal has /// fired. Used by CDP `WebMCP.invokeTool`. -pub fn findTool(self: *ModelContext, frame: *Frame, name: []const u8) ?*Tool { - self.compactAborted(frame); +pub fn findTool(self: *ModelContext, name: []const u8) ?*Tool { for (self._tools.items) |t| { if (std.mem.eql(u8, t.name, name)) return t; } @@ -151,17 +162,19 @@ pub fn findTool(self: *ModelContext, frame: *Frame, name: []const u8) ?*Tool { /// Walk the tool list and remove any whose `AbortSignal` has fired, /// dispatching `model_context_tool_removed` for each. Cheap when no /// signals fired (which is the common case). -fn compactAborted(self: *ModelContext, frame: *Frame) void { +fn markAborted(self: *ModelContext, tool: *Tool, exec: *const Execution) !void { + const session = switch (exec.context.global) { + inline else => |g| g._session, + }; + var i: usize = 0; while (i < self._tools.items.len) { const t = self._tools.items[i]; - if (t.signal) |signal| { - if (signal._aborted) { - _ = self._tools.swapRemove(i); - const event: Notification.ModelContextToolEvent = .{ .frame = frame, .tool = t }; - frame._session.notification.dispatch(.model_context_tool_removed, &event); - continue; - } + if (t == tool) { + _ = self._tools.swapRemove(i); + const event: Notification.ModelContextToolEvent = .{ .exec = exec, .tool = t }; + session.notification.dispatch(.model_context_tool_removed, &event); + return; } i += 1; } @@ -191,10 +204,12 @@ pub const ModelContextClient = struct { pub fn requestUserInteraction( _: *ModelContextClient, callback: js.Function, - frame: *Frame, + exec: *const Execution, ) !js.Promise { - const local = frame.js.local.?; - const resolver = local.createPromiseResolver(); + var ls: js.Local.Scope = undefined; + exec.context.global.getJs().localScope(&ls); + defer ls.deinit(); + const resolver = ls.local.createPromiseResolver(); var caught: js.TryCatch.Caught = undefined; if (callback.tryCall(js.Value, .{}, &caught)) |result| { diff --git a/src/cdp/domains/webmcp.zig b/src/cdp/domains/webmcp.zig index d35fe26a..eb591b12 100644 --- a/src/cdp/domains/webmcp.zig +++ b/src/cdp/domains/webmcp.zig @@ -65,7 +65,7 @@ fn enable(cmd: *CDP.Command) !void { // frame only; subframes will be added when they register. if (bc.session.currentFrame()) |frame| { const mc = frame.window.getModelContext(); - const tools = mc.tools(frame); + const tools = mc.tools(); if (tools.len > 0) { var ls: js.Local.Scope = undefined; frame.js.localScope(&ls); @@ -102,7 +102,7 @@ fn invokeTool(cmd: *CDP.Command) !void { const frame_id = try id.parseFrameId(params.frameId); const frame = bc.session.findFrameByFrameId(frame_id) orelse return error.FrameNotFound; const mc = frame.window.getModelContext(); - const tool = mc.findTool(frame, params.toolName) orelse return error.NotFound; + const tool = mc.findTool(params.toolName) orelse return error.NotFound; // Stringify the input once. We send it back to the client via // `toolInvoked.input` and pass the parsed form into the JS callback. @@ -256,12 +256,18 @@ pub fn onToolAdded( bc: *CDP.BrowserContext, event: *const Notification.ModelContextToolEvent, ) !void { + const global = event.exec.context.global; + var ls: js.Local.Scope = undefined; - event.frame.js.localScope(&ls); + global.getJs().localScope(&ls); defer ls.deinit(); + const frame_id = switch (global) { + inline else => |g| g._frame_id, + }; + const writer = ToolWriter{ - .frame_id = id.toFrameId(event.frame._frame_id), + .frame_id = id.toFrameId(frame_id), .tools = &.{event.tool}, .local = &ls.local, }; @@ -274,9 +280,12 @@ pub fn onToolRemoved( bc: *CDP.BrowserContext, event: *const Notification.ModelContextToolEvent, ) !void { + const frame_id = switch (event.exec.context.global) { + inline else => |g| g._frame_id, + }; try bc.cdp.sendEvent("WebMCP.toolsRemoved", .{ .tools = &.{ - .{ .name = event.tool.name, .frameId = id.toFrameId(event.frame._frame_id) }, + .{ .name = event.tool.name, .frameId = id.toFrameId(frame_id) }, }, }, .{ .session_id = bc.session_id }); } From 60e3d48dbd90148d483ff3d04bd99b70259acc77 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 15 May 2026 15:12:27 +0200 Subject: [PATCH 10/10] webmcp: update comments --- src/browser/webapi/ModelContext.zig | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/src/browser/webapi/ModelContext.zig b/src/browser/webapi/ModelContext.zig index c4890fe9..19323a3f 100644 --- a/src/browser/webapi/ModelContext.zig +++ b/src/browser/webapi/ModelContext.zig @@ -17,17 +17,6 @@ // along with this program. If not, see . // WebMCP — https://webmachinelearning.github.io/webmcp/ -// -// Exposes `navigator.modelContext`, the page-side surface for declaring MCP -// tools to a browser agent. Lightpanda doesn't ship an agent yet; the -// follow-ups will wire the registered tools through: -// 1. CDP `WebMCP` domain (https://chromedevtools.github.io/devtools-protocol/tot/WebMCP/) -// 2. Lightpanda's own MCP server forwarding tools to an external LLM. -// -// Both consumers reach into `tools()` / `findTool()` from Zig; the JS-side -// surface (`registerTool`, `requestUserInteraction`) is the only part shipped -// today. - const std = @import("std"); const js = @import("../js/js.zig"); @@ -63,9 +52,6 @@ pub const Tool = struct { input_schema: ?js.Object.Global, execute: js.Function.Global, annotations: Annotations, - // When present, the tool is considered unregistered once the signal - // fires. Checked lazily on each `tools()` / `findTool()` call — fine - // for headless usage where there's no synchronous observer to notify. signal: ?*AbortSignal, pub fn markAborted(self: *Tool, exec: *const Execution) !void { @@ -106,9 +92,7 @@ pub fn registerTool( } } - // Reject duplicate names. The spec says `InvalidStateError`. We compact - // the list lazily here so a tool whose signal already aborted doesn't - // block re-registering under the same name. + // Reject duplicate names. The spec says `InvalidStateError`. for (self._tools.items) |existing| { if (std.mem.eql(u8, existing.name, tool.name)) { return error.InvalidStateError; @@ -144,7 +128,7 @@ pub fn registerTool( session.notification.dispatch(.model_context_tool_added, &event); } -/// Snapshot of currently-registered tools, with aborted entries filtered. +/// Snapshot of currently-registered tools. /// Used by the CDP `WebMCP.enable` replay and the native MCP forwarder. pub fn tools(self: *ModelContext) []const *Tool { return self._tools.items;