From 048cb4695060b14d6facdec6fcb0087109441f9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 7 Apr 2026 08:05:28 +0200 Subject: [PATCH] Refactor: extract shared tool execution layer from MCP and agent Both MCP tools.zig and agent ToolExecutor.zig duplicated the same 20 tool handlers. This extracts the shared logic into src/browser/tools.zig so new tools only need to be added once. Reduces total code by ~620 lines while keeping all 465 tests passing. --- src/agent/Command.zig | 2 +- src/agent/Recorder.zig | 10 +- src/agent/ToolExecutor.zig | 520 +-------------------------- src/browser/tools.zig | 549 +++++++++++++++++++++++++++++ src/lightpanda.zig | 1 + src/mcp/tools.zig | 694 +------------------------------------ 6 files changed, 573 insertions(+), 1203 deletions(-) create mode 100644 src/browser/tools.zig diff --git a/src/agent/Command.zig b/src/agent/Command.zig index 956cc1c7..61ac8c7c 100644 --- a/src/agent/Command.zig +++ b/src/agent/Command.zig @@ -211,7 +211,7 @@ fn extractQuoted(s: []const u8) ?[]const u8 { return result.value; } -fn eqlIgnoreCase(a: []const u8, comptime upper: []const u8) bool { +pub fn eqlIgnoreCase(a: []const u8, comptime upper: []const u8) bool { if (a.len != upper.len) return false; for (a, upper) |ac, uc| { if (std.ascii.toUpper(ac) != uc) return false; diff --git a/src/agent/Recorder.zig b/src/agent/Recorder.zig index 94759262..8a7f3904 100644 --- a/src/agent/Recorder.zig +++ b/src/agent/Recorder.zig @@ -56,19 +56,11 @@ pub fn recordComment(self: *Self, comment: []const u8) void { fn isNonRecordedCommand(cmd_word: []const u8) bool { const non_recorded = [_][]const u8{ "WAIT", "TREE", "MARKDOWN", "MD" }; inline for (non_recorded) |skip| { - if (eqlIgnoreCase(cmd_word, skip)) return true; + if (Command.eqlIgnoreCase(cmd_word, skip)) return true; } return false; } -fn eqlIgnoreCase(a: []const u8, comptime upper: []const u8) bool { - if (a.len != upper.len) return false; - for (a, upper) |ac, uc| { - if (std.ascii.toUpper(ac) != uc) return false; - } - return true; -} - // --- Tests --- test "isNonRecordedCommand" { diff --git a/src/agent/ToolExecutor.zig b/src/agent/ToolExecutor.zig index edeecfbb..b9d8d703 100644 --- a/src/agent/ToolExecutor.zig +++ b/src/agent/ToolExecutor.zig @@ -6,7 +6,7 @@ const App = @import("../App.zig"); const HttpClient = @import("../browser/HttpClient.zig"); const CDPNode = @import("../cdp/Node.zig"); const mcp_tools = @import("../mcp/tools.zig"); -const protocol = @import("../mcp/protocol.zig"); +const browser_tools = lp.tools; const Self = @This(); @@ -84,521 +84,7 @@ pub fn call(self: *Self, arena: std.mem.Allocator, tool_name: []const u8, argume else null; - const Action = enum { - goto, - navigate, - markdown, - links, - nodeDetails, - interactiveElements, - structuredData, - detectForms, - evaluate, - eval, - semantic_tree, - click, - fill, - scroll, - waitForSelector, - hover, - press, - selectOption, - setChecked, - findElement, - }; - - const action_map = std.StaticStringMap(Action).initComptime(.{ - .{ "goto", .goto }, - .{ "navigate", .navigate }, - .{ "markdown", .markdown }, - .{ "links", .links }, - .{ "nodeDetails", .nodeDetails }, - .{ "interactiveElements", .interactiveElements }, - .{ "structuredData", .structuredData }, - .{ "detectForms", .detectForms }, - .{ "evaluate", .evaluate }, - .{ "eval", .eval }, - .{ "semantic_tree", .semantic_tree }, - .{ "click", .click }, - .{ "fill", .fill }, - .{ "scroll", .scroll }, - .{ "waitForSelector", .waitForSelector }, - .{ "hover", .hover }, - .{ "press", .press }, - .{ "selectOption", .selectOption }, - .{ "setChecked", .setChecked }, - .{ "findElement", .findElement }, - }); - - const action = action_map.get(tool_name) orelse return "Error: unknown tool"; - - return switch (action) { - .goto, .navigate => self.execGoto(arena, arguments), - .markdown => self.execMarkdown(arena, arguments), - .links => self.execLinks(arena, arguments), - .nodeDetails => self.execNodeDetails(arena, arguments), - .interactiveElements => self.execInteractiveElements(arena, arguments), - .structuredData => self.execStructuredData(arena, arguments), - .detectForms => self.execDetectForms(arena, arguments), - .evaluate, .eval => self.execEvaluate(arena, arguments), - .semantic_tree => self.execSemanticTree(arena, arguments), - .click => self.execClick(arena, arguments), - .fill => self.execFill(arena, arguments), - .scroll => self.execScroll(arena, arguments), - .waitForSelector => self.execWaitForSelector(arena, arguments), - .hover => self.execHover(arena, arguments), - .press => self.execPress(arena, arguments), - .selectOption => self.execSelectOption(arena, arguments), - .setChecked => self.execSetChecked(arena, arguments), - .findElement => self.execFindElement(arena, arguments), + return browser_tools.call(self.session, &self.node_registry, arena, tool_name, arguments) catch |err| { + return std.fmt.allocPrint(arena, "Error: {s}", .{@errorName(err)}) catch "Error: tool execution failed"; }; } - -fn execGoto(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 { - const GotoParams = struct { - url: [:0]const u8, - timeout: ?u32 = null, - waitUntil: ?lp.Config.WaitUntil = null, - }; - const args = parseArgsOrErr(GotoParams, arena, arguments) orelse return "Error: missing or invalid 'url' argument"; - self.performGoto(args.url, args.timeout, args.waitUntil) catch return "Error: navigation failed"; - return "Navigated successfully."; -} - -fn execMarkdown(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 { - const UrlParams = struct { - url: ?[:0]const u8 = null, - timeout: ?u32 = null, - waitUntil: ?lp.Config.WaitUntil = null, - }; - const args = parseArgsOrDefault(UrlParams, arena, arguments); - const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded"; - - var aw: std.Io.Writer.Allocating = .init(arena); - lp.markdown.dump(page.window._document.asNode(), .{}, &aw.writer, page) catch return "Error: failed to generate markdown"; - return aw.written(); -} - -fn execLinks(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 { - const UrlParams = struct { - url: ?[:0]const u8 = null, - timeout: ?u32 = null, - waitUntil: ?lp.Config.WaitUntil = null, - }; - const args = parseArgsOrDefault(UrlParams, arena, arguments); - const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded"; - - const links_list = lp.links.collectLinks(arena, page.window._document.asNode(), page) catch - return "Error: failed to collect links"; - - var aw: std.Io.Writer.Allocating = .init(arena); - for (links_list, 0..) |href, i| { - if (i > 0) aw.writer.writeByte('\n') catch {}; - aw.writer.writeAll(href) catch {}; - } - return aw.written(); -} - -fn execNodeDetails(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 { - const Params = struct { backendNodeId: CDPNode.Id }; - const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing backendNodeId"; - - _ = self.session.currentPage() orelse return "Error: page not loaded"; - - const node = self.node_registry.lookup_by_id.get(args.backendNodeId) orelse - return "Error: node not found"; - - const page = self.session.currentPage().?; - const details = lp.SemanticTree.getNodeDetails(arena, node.dom, &self.node_registry, page) catch - return "Error: failed to get node details"; - - var aw: std.Io.Writer.Allocating = .init(arena); - std.json.Stringify.value(&details, .{}, &aw.writer) catch return "Error: serialization failed"; - return aw.written(); -} - -fn execInteractiveElements(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 { - const UrlParams = struct { - url: ?[:0]const u8 = null, - timeout: ?u32 = null, - waitUntil: ?lp.Config.WaitUntil = null, - }; - const args = parseArgsOrDefault(UrlParams, arena, arguments); - const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded"; - - const elements = lp.interactive.collectInteractiveElements(page.window._document.asNode(), arena, page) catch - return "Error: failed to collect interactive elements"; - lp.interactive.registerNodes(elements, &self.node_registry) catch - return "Error: failed to register nodes"; - - var aw: std.Io.Writer.Allocating = .init(arena); - std.json.Stringify.value(elements, .{}, &aw.writer) catch return "Error: serialization failed"; - return aw.written(); -} - -fn execStructuredData(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 { - const UrlParams = struct { - url: ?[:0]const u8 = null, - timeout: ?u32 = null, - waitUntil: ?lp.Config.WaitUntil = null, - }; - const args = parseArgsOrDefault(UrlParams, arena, arguments); - const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded"; - - const data = lp.structured_data.collectStructuredData(page.window._document.asNode(), arena, page) catch - return "Error: failed to collect structured data"; - var aw: std.Io.Writer.Allocating = .init(arena); - std.json.Stringify.value(data, .{}, &aw.writer) catch return "Error: serialization failed"; - return aw.written(); -} - -fn execDetectForms(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 { - const UrlParams = struct { - url: ?[:0]const u8 = null, - timeout: ?u32 = null, - waitUntil: ?lp.Config.WaitUntil = null, - }; - const args = parseArgsOrDefault(UrlParams, arena, arguments); - const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded"; - - const forms_data = lp.forms.collectForms(arena, page.window._document.asNode(), page) catch - return "Error: failed to collect forms"; - lp.forms.registerNodes(forms_data, &self.node_registry) catch - return "Error: failed to register form nodes"; - - var aw: std.Io.Writer.Allocating = .init(arena); - std.json.Stringify.value(forms_data, .{}, &aw.writer) catch return "Error: serialization failed"; - return aw.written(); -} - -fn execEvaluate(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 { - const Params = struct { - script: [:0]const u8, - url: ?[:0]const u8 = null, - timeout: ?u32 = null, - waitUntil: ?lp.Config.WaitUntil = null, - }; - const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing 'script' argument"; - const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded"; - - var ls: lp.js.Local.Scope = undefined; - page.js.localScope(&ls); - defer ls.deinit(); - - var try_catch: lp.js.TryCatch = undefined; - try_catch.init(&ls.local); - defer try_catch.deinit(); - - const js_result = ls.local.compileAndRun(args.script, null) catch |err| { - const caught = try_catch.caughtOrError(arena, err); - var aw: std.Io.Writer.Allocating = .init(arena); - caught.format(&aw.writer) catch {}; - return aw.written(); - }; - - return js_result.toStringSliceWithAlloc(arena) catch "undefined"; -} - -fn execSemanticTree(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 { - const TreeParams = struct { - url: ?[:0]const u8 = null, - backendNodeId: ?u32 = null, - maxDepth: ?u32 = null, - timeout: ?u32 = null, - waitUntil: ?lp.Config.WaitUntil = null, - }; - const args = parseArgsOrDefault(TreeParams, arena, arguments); - const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded"; - - var root_node = page.window._document.asNode(); - if (args.backendNodeId) |node_id| { - if (self.node_registry.lookup_by_id.get(node_id)) |n| { - root_node = n.dom; - } - } - - const st = lp.SemanticTree{ - .dom_node = root_node, - .registry = &self.node_registry, - .page = page, - .arena = arena, - .prune = true, - .max_depth = args.maxDepth orelse std.math.maxInt(u32) - 1, - }; - - var aw: std.Io.Writer.Allocating = .init(arena); - st.textStringify(&aw.writer) catch return "Error: failed to generate semantic tree"; - return aw.written(); -} - -fn execClick(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 { - const Params = struct { backendNodeId: CDPNode.Id }; - const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing backendNodeId"; - - const page = self.session.currentPage() orelse return "Error: page not loaded"; - const node = self.node_registry.lookup_by_id.get(args.backendNodeId) orelse return "Error: node not found"; - - lp.actions.click(node.dom, page) catch |err| { - if (err == error.InvalidNodeType) return "Error: node is not an HTML element"; - return "Error: failed to click element"; - }; - - const page_title = page.getTitle() catch null; - return std.fmt.allocPrint(arena, "Clicked element (backendNodeId: {d}). Page url: {s}, title: {s}", .{ - args.backendNodeId, - page.url, - page_title orelse "(none)", - }) catch "Clicked element."; -} - -fn execFill(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 { - const Params = struct { - backendNodeId: CDPNode.Id, - text: []const u8, - }; - const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing backendNodeId or text"; - - const page = self.session.currentPage() orelse return "Error: page not loaded"; - const node = self.node_registry.lookup_by_id.get(args.backendNodeId) orelse return "Error: node not found"; - - lp.actions.fill(node.dom, args.text, page) catch |err| { - if (err == error.InvalidNodeType) return "Error: node is not an input, textarea or select"; - return "Error: failed to fill element"; - }; - - const page_title = page.getTitle() catch null; - return std.fmt.allocPrint(arena, "Filled element (backendNodeId: {d}) with \"{s}\". Page url: {s}, title: {s}", .{ - args.backendNodeId, - args.text, - page.url, - page_title orelse "(none)", - }) catch "Filled element."; -} - -fn execScroll(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 { - const Params = struct { - backendNodeId: ?CDPNode.Id = null, - x: ?i32 = null, - y: ?i32 = null, - }; - const args = parseArgsOrDefault(Params, arena, arguments); - const page = self.session.currentPage() orelse return "Error: page not loaded"; - - var target_node: ?*@import("../browser/webapi/Node.zig") = null; - if (args.backendNodeId) |node_id| { - const node = self.node_registry.lookup_by_id.get(node_id) orelse return "Error: node not found"; - target_node = node.dom; - } - - lp.actions.scroll(target_node, args.x, args.y, page) catch |err| { - if (err == error.InvalidNodeType) return "Error: node is not an element"; - return "Error: failed to scroll"; - }; - - const page_title = page.getTitle() catch null; - return std.fmt.allocPrint(arena, "Scrolled to x: {d}, y: {d}. Page url: {s}, title: {s}", .{ - args.x orelse 0, - args.y orelse 0, - page.url, - page_title orelse "(none)", - }) catch "Scrolled."; -} - -fn execWaitForSelector(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 { - const Params = struct { - selector: [:0]const u8, - timeout: ?u32 = null, - }; - const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing 'selector' argument"; - - _ = self.session.currentPage() orelse return "Error: page not loaded"; - - const timeout_ms = args.timeout orelse 5000; - - const node = lp.actions.waitForSelector(args.selector, timeout_ms, self.session) catch |err| { - if (err == error.InvalidSelector) return "Error: invalid selector"; - if (err == error.Timeout) return "Error: timeout waiting for selector"; - return "Error: failed waiting for selector"; - }; - - const registered = self.node_registry.register(node) catch return "Element found."; - return std.fmt.allocPrint(arena, "Element found. backendNodeId: {d}", .{registered.id}) catch "Element found."; -} - -fn execHover(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 { - const Params = struct { backendNodeId: CDPNode.Id }; - const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing backendNodeId"; - - const page = self.session.currentPage() orelse return "Error: page not loaded"; - const node = self.node_registry.lookup_by_id.get(args.backendNodeId) orelse return "Error: node not found"; - - lp.actions.hover(node.dom, page) catch |err| { - if (err == error.InvalidNodeType) return "Error: node is not an HTML element"; - return "Error: failed to hover element"; - }; - - const page_title = page.getTitle() catch null; - return std.fmt.allocPrint(arena, "Hovered element (backendNodeId: {d}). Page url: {s}, title: {s}", .{ - args.backendNodeId, - page.url, - page_title orelse "(none)", - }) catch "Hovered element."; -} - -fn execPress(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 { - const Params = struct { - key: []const u8, - backendNodeId: ?CDPNode.Id = null, - }; - const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing 'key' argument"; - - const page = self.session.currentPage() orelse return "Error: page not loaded"; - - var target_node: ?*@import("../browser/webapi/Node.zig") = null; - if (args.backendNodeId) |node_id| { - const node = self.node_registry.lookup_by_id.get(node_id) orelse return "Error: node not found"; - target_node = node.dom; - } - - lp.actions.press(target_node, args.key, page) catch |err| { - if (err == error.InvalidNodeType) return "Error: node is not an HTML element"; - return "Error: failed to press key"; - }; - - const page_title = page.getTitle() catch null; - return std.fmt.allocPrint(arena, "Pressed key '{s}'. Page url: {s}, title: {s}", .{ - args.key, - page.url, - page_title orelse "(none)", - }) catch "Pressed key."; -} - -fn execSelectOption(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 { - const Params = struct { - backendNodeId: CDPNode.Id, - value: []const u8, - }; - const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing backendNodeId or value"; - - const page = self.session.currentPage() orelse return "Error: page not loaded"; - const node = self.node_registry.lookup_by_id.get(args.backendNodeId) orelse return "Error: node not found"; - - lp.actions.selectOption(node.dom, args.value, page) catch |err| { - if (err == error.InvalidNodeType) return "Error: node is not a element"); - } - return server.sendError(id, .InternalError, "Failed to select option"); - }; - - const page_title = resolved.page.getTitle() catch null; - const result_text = try std.fmt.allocPrint(arena, "Selected option '{s}' (backendNodeId: {d}). Page url: {s}, title: {s}", .{ - args.value, - args.backendNodeId, - resolved.page.url, - page_title orelse "(none)", - }); - const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; - try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); -} - -fn handleSetChecked(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { - const Params = struct { - backendNodeId: CDPNode.Id, - checked: bool, - }; - const args = try parseArgs(Params, arena, arguments, server, id, "setChecked"); - const resolved = try resolveNodeAndPage(server, id, args.backendNodeId); - - lp.actions.setChecked(resolved.node, args.checked, resolved.page) catch |err| { - if (err == error.InvalidNodeType) { - return server.sendError(id, .InvalidParams, "Node is not a checkbox or radio input"); - } - return server.sendError(id, .InternalError, "Failed to set checked state"); - }; - - const state_str = if (args.checked) "checked" else "unchecked"; - const page_title = resolved.page.getTitle() catch null; - const result_text = try std.fmt.allocPrint(arena, "Set element (backendNodeId: {d}) to {s}. Page url: {s}, title: {s}", .{ - args.backendNodeId, - state_str, - resolved.page.url, - page_title orelse "(none)", - }); - const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; - try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); -} - -fn handleFindElement(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { - const Params = struct { - role: ?[]const u8 = null, - name: ?[]const u8 = null, - }; - const args = try parseArgsOrDefault(Params, arena, arguments, server, id); - - if (args.role == null and args.name == null) { - return server.sendError(id, .InvalidParams, "At least one of 'role' or 'name' must be provided"); - } - - const page = server.session.currentPage() orelse { - return server.sendError(id, .PageNotLoaded, "Page not loaded"); - }; - - const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| { - log.err(.mcp, "elements collection failed", .{ .err = err }); - return server.sendError(id, .InternalError, "Failed to collect interactive elements"); - }; - - var matches: std.ArrayList(lp.interactive.InteractiveElement) = .empty; - for (elements) |el| { - if (args.role) |role| { - const el_role = el.role orelse continue; - if (!std.ascii.eqlIgnoreCase(el_role, role)) continue; - } - if (args.name) |name| { - const el_name = el.name orelse continue; - if (!containsIgnoreCase(el_name, name)) continue; - } - try matches.append(arena, el); - } - - const matched = try matches.toOwnedSlice(arena); - lp.interactive.registerNodes(matched, &server.node_registry) catch |err| { - log.err(.mcp, "node registration failed", .{ .err = err }); - return server.sendError(id, .InternalError, "Failed to register element nodes"); - }; - - var aw: std.Io.Writer.Allocating = .init(arena); - try std.json.Stringify.value(matched, .{}, &aw.writer); - - const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; - try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); -} - -fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool { - if (needle.len > haystack.len) return false; - if (needle.len == 0) return true; - const end = haystack.len - needle.len + 1; - for (0..end) |i| { - if (std.ascii.eqlIgnoreCase(haystack[i..][0..needle.len], needle)) return true; - } - return false; -} - -const NodeAndPage = struct { node: *DOMNode, page: *lp.Page }; - -fn resolveNodeAndPage(server: *Server, id: std.json.Value, node_id: CDPNode.Id) !NodeAndPage { - const page = server.session.currentPage() orelse { - try server.sendError(id, .PageNotLoaded, "Page not loaded"); - return error.PageNotLoaded; - }; - const node = server.node_registry.lookup_by_id.get(node_id) orelse { - try server.sendError(id, .InvalidParams, "Node not found"); - return error.InvalidParams; - }; - return .{ .node = node.dom, .page = page }; -} - -fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !*lp.Page { - if (url) |u| { - try performGoto(server, u, id, timeout, waitUntil); - } - return server.session.currentPage() orelse { - try server.sendError(id, .PageNotLoaded, "Page not loaded"); - return error.PageNotLoaded; - }; -} - -/// Parses JSON arguments into a given struct type `T`. -/// If the arguments are missing, it returns a default-initialized `T` (e.g., `.{}`). -/// If the arguments are present but invalid, it sends an MCP error response and returns `error.InvalidParams`. -/// Use this for tools where all arguments are optional. -fn parseArgsOrDefault(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value) !T { - const args_raw = arguments orelse return .{}; - return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch { - try server.sendError(id, .InvalidParams, "Invalid arguments"); - return error.InvalidParams; - }; -} - -/// Parses JSON arguments into a given struct type `T`. -/// If the arguments are missing or invalid, it automatically sends an MCP error response to the client -/// and returns an `error.InvalidParams`. -/// Use this for tools that require strict validation or mandatory arguments. -fn parseArgs(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T { - const args_raw = arguments orelse { - try server.sendError(id, .InvalidParams, "Missing arguments"); - return error.InvalidParams; - }; - return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch { - const msg = std.fmt.allocPrint(arena, "Invalid arguments for {s}", .{tool_name}) catch "Invalid arguments"; - try server.sendError(id, .InvalidParams, msg); - return error.InvalidParams; - }; -} - -fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !void { - const session = server.session; - if (session.page != null) { - session.removePage(); - } - const page = session.createPage() catch { - try server.sendError(id, .InternalError, "Failed to create page"); - return error.NavigationFailed; - }; - page.navigate(url, .{ - .reason = .address_bar, - .kind = .{ .push = null }, - }) catch { - try server.sendError(id, .InternalError, "Internal error during navigation"); - return error.NavigationFailed; - }; - - var runner = session.runner(.{}) catch { - try server.sendError(id, .InternalError, "Failed to start page runner"); - return error.NavigationFailed; - }; - runner.wait(.{ - .ms = timeout orelse 10000, - .until = waitUntil orelse .done, - }) catch { - try server.sendError(id, .InternalError, "Error waiting for page load"); - return error.NavigationFailed; - }; -} - const router = @import("router.zig"); const testing = @import("../testing.zig");