mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 17:46:32 -04:00
Merge pull request #2433 from lightpanda-io/webmcp
Implement webMCP API and webMCP cdp domain
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -54,6 +55,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 +91,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 +116,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 +231,11 @@ pub const JavascriptDialogOpening = struct {
|
||||
response: *DialogResponse,
|
||||
};
|
||||
|
||||
pub const ModelContextToolEvent = struct {
|
||||
exec: *const Execution,
|
||||
tool: *const ModelContextTool,
|
||||
};
|
||||
|
||||
pub const DialogResponse = struct {
|
||||
accept: bool = false,
|
||||
// Set when the CDP client sent a `promptText` with `accept: true`. Memory
|
||||
|
||||
@@ -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"),
|
||||
|
||||
24
src/browser/tests/cdp/webmcp_fixture.html
Normal file
24
src/browser/tests/cdp/webmcp_fixture.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>WebMCP CDP fixture</title></head>
|
||||
<body>
|
||||
<script>
|
||||
globalThis.__webmcp_calls = [];
|
||||
|
||||
navigator.modelContext.registerTool({
|
||||
name: 'greet',
|
||||
description: 'Returns a greeting for the given person',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { who: { type: 'string' } },
|
||||
required: ['who'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
execute: async (input) => {
|
||||
globalThis.__webmcp_calls.push(input);
|
||||
return { greeting: 'hello ' + input.who };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
176
src/browser/tests/webmcp/model_context.html
Normal file
176
src/browser/tests/webmcp/model_context.html
Normal file
@@ -0,0 +1,176 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<script id=model_context_exists>
|
||||
// navigator.modelContext exists and is SameObject (per spec)
|
||||
testing.expectEqual(true, navigator.modelContext !== undefined);
|
||||
testing.expectEqual(true, navigator.modelContext === navigator.modelContext);
|
||||
testing.expectEqual(false, navigator.modelContext === navigator);
|
||||
</script>
|
||||
|
||||
<script id=register_minimal>
|
||||
{
|
||||
// Minimum required fields: name, description, execute
|
||||
navigator.modelContext.registerTool({
|
||||
name: 'minimal',
|
||||
description: 'minimal tool',
|
||||
execute: async () => ({ ok: true }),
|
||||
});
|
||||
|
||||
// Same name re-registered -> InvalidStateError
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual('InvalidStateError', err.name);
|
||||
}, () => {
|
||||
navigator.modelContext.registerTool({
|
||||
name: 'minimal',
|
||||
description: 'minimal tool',
|
||||
execute: async () => ({ ok: true }),
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=register_full>
|
||||
// All optional fields populated
|
||||
navigator.modelContext.registerTool({
|
||||
name: 'search_products',
|
||||
title: 'Search Products',
|
||||
description: 'Search the product catalog',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
untrustedContentHint: false,
|
||||
},
|
||||
execute: async (input, client) => ({ results: [] }),
|
||||
});
|
||||
testing.expectTrue(true);
|
||||
</script>
|
||||
|
||||
<script id=name_validation_empty>
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual('InvalidStateError', err.name);
|
||||
}, () => {
|
||||
navigator.modelContext.registerTool({
|
||||
name: '',
|
||||
description: 'd',
|
||||
execute: async () => null,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=name_validation_too_long>
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual('InvalidStateError', err.name);
|
||||
}, () => {
|
||||
navigator.modelContext.registerTool({
|
||||
name: 'a'.repeat(129),
|
||||
description: 'd',
|
||||
execute: async () => null,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=name_validation_invalid_chars>
|
||||
for (const bad of ['has space', 'has@symbol', 'has/slash', 'has!bang']) {
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual('InvalidStateError', err.name);
|
||||
}, () => {
|
||||
navigator.modelContext.registerTool({
|
||||
name: bad,
|
||||
description: 'd',
|
||||
execute: async () => null,
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=name_validation_allowed_specials>
|
||||
// _, -, . are all allowed
|
||||
navigator.modelContext.registerTool({
|
||||
name: 'tool_with-various.chars123',
|
||||
description: 'd',
|
||||
execute: async () => null,
|
||||
});
|
||||
testing.expectTrue(true);
|
||||
</script>
|
||||
|
||||
<script id=description_validation_empty>
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual('InvalidStateError', err.name);
|
||||
}, () => {
|
||||
navigator.modelContext.registerTool({
|
||||
name: 'empty_desc',
|
||||
description: '',
|
||||
execute: async () => null,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=name_at_boundary>
|
||||
// 128 chars is allowed
|
||||
navigator.modelContext.registerTool({
|
||||
name: 'a'.repeat(128),
|
||||
description: 'd',
|
||||
execute: async () => null,
|
||||
});
|
||||
testing.expectTrue(true);
|
||||
</script>
|
||||
|
||||
<script id=abort_signal_unregisters>
|
||||
{
|
||||
const controller = new AbortController();
|
||||
navigator.modelContext.registerTool({
|
||||
name: 'abortable',
|
||||
description: 'd',
|
||||
execute: async () => null,
|
||||
}, { signal: controller.signal });
|
||||
|
||||
// While the signal is alive, re-registering the same name throws.
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual('InvalidStateError', err.name);
|
||||
}, () => {
|
||||
navigator.modelContext.registerTool({
|
||||
name: 'abortable',
|
||||
description: 'd',
|
||||
execute: async () => null,
|
||||
});
|
||||
});
|
||||
|
||||
// After abort, re-registering is allowed.
|
||||
controller.abort();
|
||||
navigator.modelContext.registerTool({
|
||||
name: 'abortable',
|
||||
description: 'd',
|
||||
execute: async () => null,
|
||||
});
|
||||
testing.expectTrue(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=pre_aborted_signal_noop>
|
||||
{
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
// Pre-aborted signal -> registration is a silent no-op, no throw.
|
||||
navigator.modelContext.registerTool({
|
||||
name: 'pre_aborted',
|
||||
description: 'd',
|
||||
execute: async () => null,
|
||||
}, { signal: controller.signal });
|
||||
|
||||
// Name was never actually registered, so we can register it fresh:
|
||||
navigator.modelContext.registerTool({
|
||||
name: 'pre_aborted',
|
||||
description: 'd',
|
||||
execute: async () => null,
|
||||
});
|
||||
testing.expectTrue(true);
|
||||
}
|
||||
</script>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
245
src/browser/webapi/ModelContext.zig
Normal file
245
src/browser/webapi/ModelContext.zig
Normal file
@@ -0,0 +1,245 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
// WebMCP — https://webmachinelearning.github.io/webmcp/
|
||||
const std = @import("std");
|
||||
|
||||
const js = @import("../js/js.zig");
|
||||
const Notification = @import("../../Notification.zig");
|
||||
|
||||
const AbortSignal = @import("AbortSignal.zig");
|
||||
const Execution = js.Execution;
|
||||
|
||||
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 {
|
||||
ctx: *ModelContext,
|
||||
name: []const u8,
|
||||
title: ?[]const u8,
|
||||
description: []const u8,
|
||||
input_schema: ?js.Object.Global,
|
||||
execute: js.Function.Global,
|
||||
annotations: Annotations,
|
||||
signal: ?*AbortSignal,
|
||||
|
||||
pub fn markAborted(self: *Tool, exec: *const Execution) !void {
|
||||
try self.ctx.markAborted(self, exec);
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
exec: *const Execution,
|
||||
) !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`.
|
||||
for (self._tools.items) |existing| {
|
||||
if (std.mem.eql(u8, existing.name, tool.name)) {
|
||||
return error.InvalidStateError;
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
.input_schema = tool.inputSchema,
|
||||
.execute = tool.execute,
|
||||
.annotations = tool.annotations orelse .{},
|
||||
.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 = .{ .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.
|
||||
/// Used by the CDP `WebMCP.enable` replay and the native MCP forwarder.
|
||||
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, name: []const u8) ?*Tool {
|
||||
for (self._tools.items) |t| {
|
||||
if (std.mem.eql(u8, t.name, name)) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 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 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 == tool) {
|
||||
_ = self._tools.swapRemove(i);
|
||||
const event: Notification.ModelContextToolEvent = .{ .exec = exec, .tool = t };
|
||||
session.notification.dispatch(.model_context_tool_removed, &event);
|
||||
return;
|
||||
}
|
||||
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,
|
||||
exec: *const Execution,
|
||||
) !js.Promise {
|
||||
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| {
|
||||
// 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", .{});
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = .init,
|
||||
_screen: *Screen,
|
||||
_visual_viewport: *VisualViewport,
|
||||
_performance: Performance,
|
||||
@@ -170,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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -306,6 +308,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 +492,11 @@ 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_invocations: std.AutoHashMapUnmanaged(u32, *@import("domains/webmcp.zig").Invocation) = .empty,
|
||||
|
||||
fn init(self: *BrowserContext, id: []const u8, cdp: *CDP) !void {
|
||||
const allocator = cdp.allocator;
|
||||
|
||||
@@ -570,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();
|
||||
}
|
||||
@@ -729,6 +742,26 @@ 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));
|
||||
return @import("domains/webmcp.zig").onToolAdded(self, event);
|
||||
}
|
||||
|
||||
pub fn onModelContextToolRemoved(ctx: *anyopaque, event: *const Notification.ModelContextToolEvent) !void {
|
||||
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
||||
return @import("domains/webmcp.zig").onToolRemoved(self, event);
|
||||
}
|
||||
|
||||
pub fn onFrameRemove(ctx: *anyopaque, _: Notification.FrameRemove) !void {
|
||||
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
||||
@import("domains/page.zig").frameRemove(self);
|
||||
|
||||
537
src/cdp/domains/webmcp.zig
Normal file
537
src/cdp/domains/webmcp.zig
Normal file
@@ -0,0 +1,537 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
// CDP WebMCP domain.
|
||||
// https://chromedevtools.github.io/devtools-protocol/tot/WebMCP/
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const id = @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;
|
||||
|
||||
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();
|
||||
if (tools.len > 0) {
|
||||
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,
|
||||
};
|
||||
try bc.cdp.sendEvent("WebMCP.toolsAdded", .{ .tools = writer }, .{ .session_id = bc.session_id });
|
||||
}
|
||||
}
|
||||
|
||||
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.parseFrameId(params.frameId);
|
||||
const frame = bc.session.findFrameByFrameId(frame_id) orelse return error.FrameNotFound;
|
||||
const mc = frame.window.getModelContext();
|
||||
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.
|
||||
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 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;
|
||||
try cmd.sendEvent("WebMCP.toolInvoked", .{
|
||||
.toolName = tool.name,
|
||||
.frameId = id.toFrameId(frame_id),
|
||||
.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);
|
||||
|
||||
var caught: js.TryCatch.Caught = undefined;
|
||||
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 }, .{});
|
||||
};
|
||||
|
||||
// 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 = 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;
|
||||
|
||||
try cmd.cdp.sendEvent("WebMCP.toolResponded", .{
|
||||
.invocationId = &id.toInvocationId(inv_id),
|
||||
.status = "Canceled",
|
||||
}, .{ .session_id = bc.session_id });
|
||||
|
||||
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.
|
||||
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.toStringSlice() 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 {
|
||||
try cdp.sendEvent("WebMCP.toolResponded", .{
|
||||
.invocationId = &id.toInvocationId(invocation.id),
|
||||
.status = "Completed",
|
||||
.output = value,
|
||||
}, .{ .session_id = bc.session_id });
|
||||
_ = bc.webmcp_invocations.remove(invocation.id);
|
||||
}
|
||||
|
||||
fn respondError(
|
||||
cdp: *CDP,
|
||||
bc: *CDP.BrowserContext,
|
||||
invocation: *Invocation,
|
||||
err_text: []const u8,
|
||||
) !void {
|
||||
try cdp.sendEvent("WebMCP.toolResponded", .{
|
||||
.invocationId = &id.toInvocationId(invocation.id),
|
||||
.status = "Error",
|
||||
.errorText = err_text,
|
||||
}, .{ .session_id = bc.session_id });
|
||||
_ = bc.webmcp_invocations.remove(invocation.id);
|
||||
}
|
||||
|
||||
pub fn onToolAdded(
|
||||
bc: *CDP.BrowserContext,
|
||||
event: *const Notification.ModelContextToolEvent,
|
||||
) !void {
|
||||
const global = event.exec.context.global;
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
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(frame_id),
|
||||
.tools = &.{event.tool},
|
||||
.local = &ls.local,
|
||||
};
|
||||
try bc.cdp.sendEvent("WebMCP.toolsAdded", .{
|
||||
.tools = writer,
|
||||
}, .{ .session_id = bc.session_id });
|
||||
}
|
||||
|
||||
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(frame_id) },
|
||||
},
|
||||
}, .{ .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.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-0000000001" }, .{ .id = 2 });
|
||||
|
||||
try ctx.expectSentEvent("WebMCP.toolInvoked", .{
|
||||
.toolName = "greet",
|
||||
.frameId = &frame_id,
|
||||
.invocationId = "INV-0000000001",
|
||||
}, .{ .session_id = "SID-M" });
|
||||
|
||||
try ctx.expectSentEvent("WebMCP.toolResponded", .{
|
||||
.invocationId = "INV-0000000001",
|
||||
.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.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.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-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-0000000001" },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 3 });
|
||||
try ctx.expectSentEvent("WebMCP.toolResponded", .{
|
||||
.invocationId = "INV-0000000001",
|
||||
.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,
|
||||
|
||||
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) |is| {
|
||||
try w.write(is.local(self.local).toValue());
|
||||
} 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();
|
||||
}
|
||||
};
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user