diff --git a/src/browser/tests/window/window.html b/src/browser/tests/window/window.html index 8a024089..5506e327 100644 --- a/src/browser/tests/window/window.html +++ b/src/browser/tests/window/window.html @@ -82,7 +82,7 @@ testing.expectEqual('ceil', atob('Y2VpbA')); // 6 chars, len%4==2, needs '==' // length % 4 == 1 must still throw - testing.expectError('Error: InvalidCharacterError', () => { + testing.expectError('InvalidCharacterError: Invalid Character', () => { atob('Y'); }); diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig index e9ca0ebf..50c4457a 100644 --- a/src/browser/webapi/Performance.zig +++ b/src/browser/webapi/Performance.zig @@ -268,7 +268,7 @@ pub const JsApi = struct { pub const now = bridge.function(Performance.now, .{}); pub const mark = bridge.function(Performance.mark, .{}); - pub const measure = bridge.function(Performance.measure, .{}); + pub const measure = bridge.function(Performance.measure, .{ .dom_exception = true }); pub const clearMarks = bridge.function(Performance.clearMarks, .{}); pub const clearMeasures = bridge.function(Performance.clearMeasures, .{}); pub const getEntries = bridge.function(Performance.getEntries, .{}); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 2c3e3cd0..4d445e07 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -798,7 +798,7 @@ pub const JsApi = struct { pub const matchMedia = bridge.function(Window.matchMedia, .{}); pub const postMessage = bridge.function(Window.postMessage, .{}); pub const btoa = bridge.function(Window.btoa, .{}); - pub const atob = bridge.function(Window.atob, .{}); + pub const atob = bridge.function(Window.atob, .{ .dom_exception = true }); pub const reportError = bridge.function(Window.reportError, .{}); pub const getComputedStyle = bridge.function(Window.getComputedStyle, .{}); pub const getSelection = bridge.function(Window.getSelection, .{}); diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig index 447f5e00..6754c5da 100644 --- a/src/browser/webapi/navigation/Navigation.zig +++ b/src/browser/webapi/navigation/Navigation.zig @@ -474,12 +474,12 @@ pub const JsApi = struct { pub const canGoForward = bridge.accessor(Navigation.getCanGoForward, null, .{}); pub const currentEntry = bridge.accessor(Navigation.getCurrentEntry, null, .{}); pub const transition = bridge.accessor(Navigation.getTransition, null, .{}); - pub const back = bridge.function(Navigation.back, .{}); + pub const back = bridge.function(Navigation.back, .{ .dom_exception = true }); pub const entries = bridge.function(Navigation.entries, .{}); - pub const forward = bridge.function(Navigation.forward, .{}); - pub const navigate = bridge.function(Navigation.navigate, .{}); - pub const traverseTo = bridge.function(Navigation.traverseTo, .{}); - pub const updateCurrentEntry = bridge.function(Navigation.updateCurrentEntry, .{}); + pub const forward = bridge.function(Navigation.forward, .{ .dom_exception = true }); + pub const navigate = bridge.function(Navigation.navigate, .{ .dom_exception = true }); + pub const traverseTo = bridge.function(Navigation.traverseTo, .{ .dom_exception = true }); + pub const updateCurrentEntry = bridge.function(Navigation.updateCurrentEntry, .{ .dom_exception = true }); pub const oncurrententrychange = bridge.accessor( Navigation.getOnCurrentEntryChange, diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig index 3d56a37d..1e67792c 100644 --- a/src/mcp/Server.zig +++ b/src/mcp/Server.zig @@ -18,7 +18,6 @@ http_client: *HttpClient, notification: *lp.Notification, browser: lp.Browser, session: *lp.Session, -page: *lp.Page, node_registry: CDPNode.Registry, writer: *std.io.Writer, @@ -47,13 +46,10 @@ pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*S .http_client = http_client, .notification = notification, .session = undefined, - .page = undefined, .node_registry = CDPNode.Registry.init(allocator), }; self.session = try self.browser.newSession(self.notification); - self.page = try self.session.createPage(); - return self; } @@ -91,7 +87,7 @@ pub fn sendResult(self: *Self, id: std.json.Value, result: anytype) !void { } pub fn sendError(self: *Self, id: std.json.Value, code: protocol.ErrorCode, message: []const u8) !void { - try self.sendResponse(protocol.Response{ + try self.sendResponse(.{ .id = id, .@"error" = protocol.Error{ .code = @intFromEnum(code), diff --git a/src/mcp/protocol.zig b/src/mcp/protocol.zig index 5f5dc7f2..97035c0f 100644 --- a/src/mcp/protocol.zig +++ b/src/mcp/protocol.zig @@ -26,6 +26,7 @@ pub const ErrorCode = enum(i64) { MethodNotFound = -32601, InvalidParams = -32602, InternalError = -32603, + PageNotLoaded = -32604, }; pub const Notification = struct { diff --git a/src/mcp/resources.zig b/src/mcp/resources.zig index 7fd59dc3..dd6af972 100644 --- a/src/mcp/resources.zig +++ b/src/mcp/resources.zig @@ -37,7 +37,7 @@ const ResourceStreamingResult = struct { }, const StreamingText = struct { - server: *Server, + page: *lp.Page, format: enum { html, markdown }, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void { @@ -45,10 +45,10 @@ const ResourceStreamingResult = struct { try jw.writer.writeByte('"'); var escaped = protocol.JsonEscapingWriter.init(jw.writer); switch (self.format) { - .html => lp.dump.root(self.server.page.document, .{}, &escaped.writer, self.server.page) catch |err| { + .html => lp.dump.root(self.page.document, .{}, &escaped.writer, self.page) catch |err| { log.err(.mcp, "html dump failed", .{ .err = err }); }, - .markdown => lp.markdown.dump(self.server.page.document.asNode(), .{}, &escaped.writer, self.server.page) catch |err| { + .markdown => lp.markdown.dump(self.page.document.asNode(), .{}, &escaped.writer, self.page) catch |err| { log.err(.mcp, "markdown dump failed", .{ .err = err }); }, } @@ -69,16 +69,21 @@ const resource_map = std.StaticStringMap(ResourceUri).initComptime(.{ }); pub fn handleRead(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { - if (req.params == null) { - return server.sendError(req.id.?, .InvalidParams, "Missing params"); + if (req.params == null or req.id == null) { + return server.sendError(req.id orelse .{ .integer = -1 }, .InvalidParams, "Missing params"); } + const req_id = req.id.?; const params = std.json.parseFromValueLeaky(ReadParams, arena, req.params.?, .{ .ignore_unknown_fields = true }) catch { - return server.sendError(req.id.?, .InvalidParams, "Invalid params"); + return server.sendError(req_id, .InvalidParams, "Invalid params"); }; const uri = resource_map.get(params.uri) orelse { - return server.sendError(req.id.?, .InvalidRequest, "Resource not found"); + return server.sendError(req_id, .InvalidRequest, "Resource not found"); + }; + + const page = server.session.currentPage() orelse { + return server.sendError(req_id, .PageNotLoaded, "Page not loaded"); }; switch (uri) { @@ -87,20 +92,20 @@ pub fn handleRead(server: *Server, arena: std.mem.Allocator, req: protocol.Reque .contents = &.{.{ .uri = params.uri, .mimeType = "text/html", - .text = .{ .server = server, .format = .html }, + .text = .{ .page = page, .format = .html }, }}, }; - try server.sendResult(req.id.?, result); + try server.sendResult(req_id, result); }, .@"mcp://page/markdown" => { const result: ResourceStreamingResult = .{ .contents = &.{.{ .uri = params.uri, .mimeType = "text/markdown", - .text = .{ .server = server, .format = .markdown }, + .text = .{ .page = page, .format = .markdown }, }}, }; - try server.sendResult(req.id.?, result); + try server.sendResult(req_id, result); }, } } diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 8ab55f48..c3f03833 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -8,6 +8,7 @@ const Element = @import("../browser/webapi/Element.zig"); const Selector = @import("../browser/webapi/selector/Selector.zig"); const protocol = @import("protocol.zig"); const Server = @import("Server.zig"); +const CDPNode = @import("../cdp/Node.zig"); pub const tool_list = [_]protocol.Tool{ .{ @@ -90,17 +91,18 @@ const EvaluateParams = struct { }; const ToolStreamingText = struct { - server: *Server, + page: *lp.Page, action: enum { markdown, links, semantic_tree }, - arena: std.mem.Allocator, + registry: ?*CDPNode.Registry = null, + arena: ?std.mem.Allocator = null, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void { switch (self.action) { .markdown => { try jw.beginWriteRaw(); try jw.writer.writeByte('"'); - var escaped = protocol.JsonEscapingWriter.init(jw.writer); - lp.markdown.dump(self.server.page.document.asNode(), .{}, &escaped.writer, self.server.page) catch |err| { + var escaped: protocol.JsonEscapingWriter = .init(jw.writer); + lp.markdown.dump(self.page.document.asNode(), .{}, &escaped.writer, self.page) catch |err| { log.err(.mcp, "markdown dump failed", .{ .err = err }); }; try jw.writer.writeByte('"'); @@ -109,14 +111,14 @@ const ToolStreamingText = struct { .links => { try jw.beginWriteRaw(); try jw.writer.writeByte('"'); - var escaped = protocol.JsonEscapingWriter.init(jw.writer); + var escaped: protocol.JsonEscapingWriter = .init(jw.writer); const w = &escaped.writer; - if (Selector.querySelectorAll(self.server.page.document.asNode(), "a[href]", self.server.page)) |list| { - defer list.deinit(self.server.page); + if (Selector.querySelectorAll(self.page.document.asNode(), "a[href]", self.page)) |list| { + defer list.deinit(self.page); var first = true; for (list._nodes) |node| { if (node.is(Element.Html.Anchor)) |anchor| { - const href = anchor.getHref(self.server.page) catch |err| { + const href = anchor.getHref(self.page) catch |err| { log.err(.mcp, "resolve href failed", .{ .err = err }); continue; }; @@ -138,13 +140,13 @@ const ToolStreamingText = struct { // Return the highly compressed Stagehand-style text format for maximum token efficiency try jw.beginWriteRaw(); try jw.writer.writeByte('"'); - var escaped = protocol.JsonEscapingWriter.init(jw.writer); + var escaped: protocol.JsonEscapingWriter = .init(jw.writer); const st = lp.SemanticTree{ - .dom_node = self.server.page.document.asNode(), - .registry = &self.server.node_registry, - .page = self.server.page, - .arena = self.arena, + .dom_node = self.page.document.asNode(), + .registry = self.registry.?, + .page = self.page, + .arena = self.arena.?, }; st.textStringify(&escaped.writer) catch |err| { @@ -177,8 +179,8 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ }); pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { - if (req.params == null) { - return server.sendError(req.id.?, .InvalidParams, "Missing params"); + if (req.params == null or req.id == null) { + return server.sendError(req.id orelse .{ .integer = -1 }, .InvalidParams, "Missing params"); } const CallParams = struct { @@ -222,9 +224,12 @@ fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, } } else |_| {} } + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; const content = [_]protocol.TextContent(ToolStreamingText){.{ - .text = .{ .server = server, .action = .markdown, .arena = arena }, + .text = .{ .page = page, .action = .markdown }, }}; try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); } @@ -240,9 +245,12 @@ fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar } } else |_| {} } + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; const content = [_]protocol.TextContent(ToolStreamingText){.{ - .text = .{ .server = server, .action = .links, .arena = arena }, + .text = .{ .page = page, .action = .links }, }}; try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); } @@ -258,9 +266,12 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va } } else |_| {} } + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; const content = [_]protocol.TextContent(ToolStreamingText){.{ - .text = .{ .server = server, .action = .semantic_tree, .arena = arena }, + .text = .{ .page = page, .action = .semantic_tree, .registry = &server.node_registry, .arena = arena }, }}; try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); } @@ -271,9 +282,12 @@ fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, if (args.url) |url| { try performGoto(server, url, id); } + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; var ls: js.Local.Scope = undefined; - server.page.js.localScope(&ls); + page.js.localScope(&ls); defer ls.deinit(); var try_catch: js.TryCatch = undefined; @@ -308,7 +322,12 @@ fn parseArguments(comptime T: type, arena: std.mem.Allocator, arguments: ?std.js } fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void { - _ = server.page.navigate(url, .{ + const session = server.session; + if (session.page != null) { + session.removePage(); + } + const page = try session.createPage(); + page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null }, }) catch { @@ -332,6 +351,7 @@ test "MCP - evaluate error reporting" { var server = try Server.init(allocator, app, &out_alloc.writer); defer server.deinit(); + _ = try server.session.createPage(); const aa = testing.arena_allocator;