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.
This commit is contained in:
Adrià Arrufat
2026-04-07 08:05:28 +02:00
parent 9a442ac5f1
commit 048cb46950
6 changed files with 573 additions and 1203 deletions

View File

@@ -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;

View File

@@ -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" {

View File

@@ -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 <select> element";
return "Error: failed to select option";
};
const page_title = page.getTitle() catch null;
return std.fmt.allocPrint(arena, "Selected option '{s}' (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.value,
args.backendNodeId,
page.url,
page_title orelse "(none)",
}) catch "Selected option.";
}
fn execSetChecked(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
const Params = struct {
backendNodeId: CDPNode.Id,
checked: bool,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing backendNodeId or checked";
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.setChecked(node.dom, args.checked, page) catch |err| {
if (err == error.InvalidNodeType) return "Error: node is not a checkbox or radio input";
return "Error: failed to set checked state";
};
const state_str = if (args.checked) "checked" else "unchecked";
const page_title = page.getTitle() catch null;
return std.fmt.allocPrint(arena, "Set element (backendNodeId: {d}) to {s}. Page url: {s}, title: {s}", .{
args.backendNodeId,
state_str,
page.url,
page_title orelse "(none)",
}) catch "Set checked state.";
}
fn execFindElement(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
const Params = struct {
role: ?[]const u8 = null,
name: ?[]const u8 = null,
};
const args = parseArgsOrDefault(Params, arena, arguments);
if (args.role == null and args.name == null) return "Error: at least one of 'role' or 'name' must be provided";
const page = self.session.currentPage() orelse return "Error: page not loaded";
const elements = lp.interactive.collectInteractiveElements(page.window._document.asNode(), arena, page) catch
return "Error: failed to collect interactive elements";
var matches: std.ArrayListUnmanaged(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;
}
matches.append(arena, el) catch return "Error: allocation failed";
}
const matched = matches.toOwnedSlice(arena) catch return "Error: allocation failed";
lp.interactive.registerNodes(matched, &self.node_registry) catch
return "Error: failed to register element nodes";
var aw: std.Io.Writer.Allocating = .init(arena);
std.json.Stringify.value(matched, .{}, &aw.writer) catch return "Error: serialization failed";
return aw.written();
}
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;
}
fn ensurePage(self: *Self, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !*lp.Page {
if (url) |u| {
try self.performGoto(u, timeout, waitUntil);
}
return self.session.currentPage() orelse error.PageNotLoaded;
}
fn performGoto(self: *Self, url: [:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !void {
const session = self.session;
if (session.page != null) {
session.removePage();
}
const page = try session.createPage();
_ = try page.navigate(url, .{
.reason = .address_bar,
.kind = .{ .push = null },
});
var runner = try session.runner(.{});
try runner.wait(.{
.ms = timeout orelse 10000,
.until = waitUntil orelse .done,
});
}
fn parseArgsOrDefault(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) T {
const args_raw = arguments orelse return .{};
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch .{};
}
fn parseArgsOrErr(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) ?T {
const args_raw = arguments orelse return null;
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch null;
}

549
src/browser/tools.zig Normal file
View File

@@ -0,0 +1,549 @@
const std = @import("std");
const lp = @import("lightpanda");
const DOMNode = @import("webapi/Node.zig");
const CDPNode = @import("../cdp/Node.zig");
pub const ToolError = error{
PageNotLoaded,
InvalidParams,
NodeNotFound,
NavigationFailed,
InternalError,
};
/// Result from evaluate that may represent a JS error (not a tool failure).
pub const EvalResult = struct {
text: []const u8,
is_error: bool = false,
};
pub const GotoParams = struct {
url: [:0]const u8,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
pub const UrlParams = struct {
url: ?[:0]const u8 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const NodeAndPage = struct { node: *DOMNode, page: *lp.Page };
// --- Tool dispatch ---
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 },
});
/// Execute a tool by name. Returns the result text.
/// For `evaluate`/`eval`, use `callEval` to distinguish JS errors from tool errors.
pub fn call(
session: *lp.Session,
registry: *CDPNode.Registry,
arena: std.mem.Allocator,
tool_name: []const u8,
arguments: ?std.json.Value,
) ToolError![]const u8 {
const action = action_map.get(tool_name) orelse return ToolError.InvalidParams;
return switch (action) {
.goto, .navigate => execGoto(session, arena, arguments),
.markdown => execMarkdown(session, arena, arguments),
.links => execLinks(session, arena, arguments),
.nodeDetails => execNodeDetails(session, registry, arena, arguments),
.interactiveElements => execInteractiveElements(session, registry, arena, arguments),
.structuredData => execStructuredData(session, arena, arguments),
.detectForms => execDetectForms(session, registry, arena, arguments),
.evaluate, .eval => blk: {
const result = execEvaluate(session, arena, arguments);
break :blk result.text;
},
.semantic_tree => execSemanticTree(session, registry, arena, arguments),
.click => execClick(session, registry, arena, arguments),
.fill => execFill(session, registry, arena, arguments),
.scroll => execScroll(session, registry, arena, arguments),
.waitForSelector => execWaitForSelector(session, registry, arena, arguments),
.hover => execHover(session, registry, arena, arguments),
.press => execPress(session, registry, arena, arguments),
.selectOption => execSelectOption(session, registry, arena, arguments),
.setChecked => execSetChecked(session, registry, arena, arguments),
.findElement => execFindElement(session, registry, arena, arguments),
};
}
/// Like `call`, but for evaluate/eval returns the full EvalResult with is_error flag.
pub fn callEval(
session: *lp.Session,
arena: std.mem.Allocator,
arguments: ?std.json.Value,
) EvalResult {
return execEvaluate(session, arena, arguments);
}
/// Check if a tool name is recognized.
pub fn isKnownTool(tool_name: []const u8) bool {
return action_map.get(tool_name) != null;
}
// --- Tool implementations ---
fn execGoto(session: *lp.Session, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrErr(GotoParams, arena, arguments) orelse return ToolError.InvalidParams;
try performGoto(session, args.url, args.timeout, args.waitUntil);
return "Navigated successfully.";
}
fn execMarkdown(session: *lp.Session, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const page = try ensurePage(session, args.url, args.timeout, args.waitUntil);
var aw: std.Io.Writer.Allocating = .init(arena);
lp.markdown.dump(page.document.asNode(), .{}, &aw.writer, page) catch
return ToolError.InternalError;
return aw.written();
}
fn execLinks(session: *lp.Session, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const page = try ensurePage(session, args.url, args.timeout, args.waitUntil);
const links_list = lp.links.collectLinks(arena, page.document.asNode(), page) catch
return ToolError.InternalError;
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 execSemanticTree(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]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 = try ensurePage(session, args.url, args.timeout, args.waitUntil);
var root_node = page.document.asNode();
if (args.backendNodeId) |node_id| {
if (registry.lookup_by_id.get(node_id)) |n| {
root_node = n.dom;
}
}
const st = lp.SemanticTree{
.dom_node = root_node,
.registry = 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 ToolError.InternalError;
return aw.written();
}
fn execNodeDetails(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct { backendNodeId: CDPNode.Id };
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
_ = session.currentPage() orelse return ToolError.PageNotLoaded;
const node = registry.lookup_by_id.get(args.backendNodeId) orelse
return ToolError.NodeNotFound;
const page = session.currentPage().?;
const details = lp.SemanticTree.getNodeDetails(arena, node.dom, registry, page) catch
return ToolError.InternalError;
var aw: std.Io.Writer.Allocating = .init(arena);
std.json.Stringify.value(&details, .{}, &aw.writer) catch return ToolError.InternalError;
return aw.written();
}
fn execInteractiveElements(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const page = try ensurePage(session, args.url, args.timeout, args.waitUntil);
const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch
return ToolError.InternalError;
lp.interactive.registerNodes(elements, registry) catch
return ToolError.InternalError;
var aw: std.Io.Writer.Allocating = .init(arena);
std.json.Stringify.value(elements, .{}, &aw.writer) catch return ToolError.InternalError;
return aw.written();
}
fn execStructuredData(session: *lp.Session, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const page = try ensurePage(session, args.url, args.timeout, args.waitUntil);
const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch
return ToolError.InternalError;
var aw: std.Io.Writer.Allocating = .init(arena);
std.json.Stringify.value(data, .{}, &aw.writer) catch return ToolError.InternalError;
return aw.written();
}
fn execDetectForms(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const page = try ensurePage(session, args.url, args.timeout, args.waitUntil);
const forms_data = lp.forms.collectForms(arena, page.document.asNode(), page) catch
return ToolError.InternalError;
lp.forms.registerNodes(forms_data, registry) catch
return ToolError.InternalError;
var aw: std.Io.Writer.Allocating = .init(arena);
std.json.Stringify.value(forms_data, .{}, &aw.writer) catch return ToolError.InternalError;
return aw.written();
}
fn execEvaluate(session: *lp.Session, arena: std.mem.Allocator, arguments: ?std.json.Value) EvalResult {
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 .{ .text = "Error: missing 'script' argument", .is_error = true };
const page = ensurePage(session, args.url, args.timeout, args.waitUntil) catch return .{ .text = "Error: page not loaded", .is_error = true };
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 .{ .text = aw.written(), .is_error = true };
};
return .{ .text = js_result.toStringSliceWithAlloc(arena) catch "undefined" };
}
fn execClick(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct { backendNodeId: CDPNode.Id };
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const resolved = try resolveNodeAndPage(session, registry, args.backendNodeId);
lp.actions.click(resolved.node, resolved.page) catch |err| {
if (err == error.InvalidNodeType) return ToolError.InvalidParams;
return ToolError.InternalError;
};
const page_title = resolved.page.getTitle() catch null;
return std.fmt.allocPrint(arena, "Clicked element (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.backendNodeId,
resolved.page.url,
page_title orelse "(none)",
}) catch return ToolError.InternalError;
}
fn execFill(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct {
backendNodeId: CDPNode.Id,
text: []const u8,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const resolved = try resolveNodeAndPage(session, registry, args.backendNodeId);
lp.actions.fill(resolved.node, args.text, resolved.page) catch |err| {
if (err == error.InvalidNodeType) return ToolError.InvalidParams;
return ToolError.InternalError;
};
const page_title = resolved.page.getTitle() catch null;
return std.fmt.allocPrint(arena, "Filled element (backendNodeId: {d}) with \"{s}\". Page url: {s}, title: {s}", .{
args.backendNodeId,
args.text,
resolved.page.url,
page_title orelse "(none)",
}) catch return ToolError.InternalError;
}
fn execScroll(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct {
backendNodeId: ?CDPNode.Id = null,
x: ?i32 = null,
y: ?i32 = null,
};
const args = parseArgsOrDefault(Params, arena, arguments);
const page = session.currentPage() orelse return ToolError.PageNotLoaded;
var target_node: ?*DOMNode = null;
if (args.backendNodeId) |node_id| {
const node = registry.lookup_by_id.get(node_id) orelse return ToolError.NodeNotFound;
target_node = node.dom;
}
lp.actions.scroll(target_node, args.x, args.y, page) catch |err| {
if (err == error.InvalidNodeType) return ToolError.InvalidParams;
return ToolError.InternalError;
};
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 return ToolError.InternalError;
}
fn execWaitForSelector(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct {
selector: [:0]const u8,
timeout: ?u32 = null,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
_ = session.currentPage() orelse return ToolError.PageNotLoaded;
const timeout_ms = args.timeout orelse 5000;
const node = lp.actions.waitForSelector(args.selector, timeout_ms, session) catch |err| {
if (err == error.InvalidSelector) return ToolError.InvalidParams;
return ToolError.InternalError;
};
const registered = registry.register(node) catch return ToolError.InternalError;
return std.fmt.allocPrint(arena, "Element found. backendNodeId: {d}", .{registered.id}) catch return ToolError.InternalError;
}
fn execHover(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct { backendNodeId: CDPNode.Id };
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const resolved = try resolveNodeAndPage(session, registry, args.backendNodeId);
lp.actions.hover(resolved.node, resolved.page) catch |err| {
if (err == error.InvalidNodeType) return ToolError.InvalidParams;
return ToolError.InternalError;
};
const page_title = resolved.page.getTitle() catch null;
return std.fmt.allocPrint(arena, "Hovered element (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.backendNodeId,
resolved.page.url,
page_title orelse "(none)",
}) catch return ToolError.InternalError;
}
fn execPress(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct {
key: []const u8,
backendNodeId: ?CDPNode.Id = null,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const page = session.currentPage() orelse return ToolError.PageNotLoaded;
var target_node: ?*DOMNode = null;
if (args.backendNodeId) |node_id| {
const node = registry.lookup_by_id.get(node_id) orelse return ToolError.NodeNotFound;
target_node = node.dom;
}
lp.actions.press(target_node, args.key, page) catch |err| {
if (err == error.InvalidNodeType) return ToolError.InvalidParams;
return ToolError.InternalError;
};
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 return ToolError.InternalError;
}
fn execSelectOption(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct {
backendNodeId: CDPNode.Id,
value: []const u8,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const resolved = try resolveNodeAndPage(session, registry, args.backendNodeId);
lp.actions.selectOption(resolved.node, args.value, resolved.page) catch |err| {
if (err == error.InvalidNodeType) return ToolError.InvalidParams;
return ToolError.InternalError;
};
const page_title = resolved.page.getTitle() catch null;
return 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)",
}) catch return ToolError.InternalError;
}
fn execSetChecked(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct {
backendNodeId: CDPNode.Id,
checked: bool,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const resolved = try resolveNodeAndPage(session, registry, args.backendNodeId);
lp.actions.setChecked(resolved.node, args.checked, resolved.page) catch |err| {
if (err == error.InvalidNodeType) return ToolError.InvalidParams;
return ToolError.InternalError;
};
const state_str = if (args.checked) "checked" else "unchecked";
const page_title = resolved.page.getTitle() catch null;
return 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)",
}) catch return ToolError.InternalError;
}
fn execFindElement(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct {
role: ?[]const u8 = null,
name: ?[]const u8 = null,
};
const args = parseArgsOrDefault(Params, arena, arguments);
if (args.role == null and args.name == null) return ToolError.InvalidParams;
const page = session.currentPage() orelse return ToolError.PageNotLoaded;
const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch
return ToolError.InternalError;
var matches: std.ArrayListUnmanaged(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;
}
matches.append(arena, el) catch return ToolError.InternalError;
}
const matched = matches.toOwnedSlice(arena) catch return ToolError.InternalError;
lp.interactive.registerNodes(matched, registry) catch
return ToolError.InternalError;
var aw: std.Io.Writer.Allocating = .init(arena);
std.json.Stringify.value(matched, .{}, &aw.writer) catch return ToolError.InternalError;
return aw.written();
}
// --- Shared helpers ---
fn ensurePage(session: *lp.Session, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) ToolError!*lp.Page {
if (url) |u| {
try performGoto(session, u, timeout, waitUntil);
}
return session.currentPage() orelse ToolError.PageNotLoaded;
}
fn performGoto(session: *lp.Session, url: [:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) ToolError!void {
if (session.page != null) {
session.removePage();
}
const page = session.createPage() catch return ToolError.NavigationFailed;
_ = page.navigate(url, .{
.reason = .address_bar,
.kind = .{ .push = null },
}) catch return ToolError.NavigationFailed;
var runner = session.runner(.{}) catch return ToolError.NavigationFailed;
runner.wait(.{
.ms = timeout orelse 10000,
.until = waitUntil orelse .done,
}) catch return ToolError.NavigationFailed;
}
fn resolveNodeAndPage(session: *lp.Session, registry: *CDPNode.Registry, node_id: CDPNode.Id) ToolError!NodeAndPage {
const page = session.currentPage() orelse return ToolError.PageNotLoaded;
const node = registry.lookup_by_id.get(node_id) orelse return ToolError.NodeNotFound;
return .{ .node = node.dom, .page = page };
}
fn parseArgsOrDefault(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) T {
const args_raw = arguments orelse return .{};
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch .{};
}
fn parseArgsOrErr(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) ?T {
const args_raw = arguments orelse return null;
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch null;
}
pub 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;
}

View File

@@ -39,6 +39,7 @@ pub const links = @import("browser/links.zig");
pub const forms = @import("browser/forms.zig");
pub const actions = @import("browser/actions.zig");
pub const structured_data = @import("browser/structured_data.zig");
pub const tools = @import("browser/tools.zig");
pub const mcp = @import("mcp.zig");
pub const agent = @import("agent.zig");
pub const build_config = @import("build_config");

View File

@@ -1,13 +1,11 @@
const std = @import("std");
const lp = @import("lightpanda");
const log = lp.log;
const js = lp.js;
const browser_tools = lp.tools;
const DOMNode = @import("../browser/webapi/Node.zig");
const protocol = @import("protocol.zig");
const Server = @import("Server.zig");
const CDPNode = @import("../cdp/Node.zig");
const goto_schema = protocol.minify(
\\{
@@ -251,135 +249,6 @@ pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
try server.sendResult(id, .{ .tools = &tool_list });
}
const GotoParams = struct {
url: [:0]const u8,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const UrlParams = struct {
url: ?[:0]const u8 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const EvaluateParams = struct {
script: [:0]const u8,
url: ?[:0]const u8 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const ToolStreamingText = struct {
page: *lp.Page,
action: enum { markdown, links, semantic_tree },
registry: ?*CDPNode.Registry = null,
arena: ?std.mem.Allocator = null,
backendNodeId: ?u32 = null,
maxDepth: ?u32 = null,
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void {
try jw.beginWriteRaw();
try jw.writer.writeByte('"');
var escaped: protocol.JsonEscapingWriter = .init(jw.writer);
const w = &escaped.writer;
switch (self.action) {
.markdown => lp.markdown.dump(self.page.document.asNode(), .{}, w, self.page) catch |err| {
log.err(.mcp, "markdown dump failed", .{ .err = err });
return error.WriteFailed;
},
.links => {
const links = lp.links.collectLinks(self.page.call_arena, self.page.document.asNode(), self.page) catch |err| {
log.err(.mcp, "query links failed", .{ .err = err });
return error.WriteFailed;
};
var first = true;
for (links) |href| {
if (!first) try w.writeByte('\n');
try w.writeAll(href);
first = false;
}
},
.semantic_tree => {
var root_node = self.page.document.asNode();
if (self.backendNodeId) |node_id| {
if (self.registry) |registry| {
if (registry.lookup_by_id.get(node_id)) |n| {
root_node = n.dom;
} else {
log.warn(.mcp, "semantic_tree id missing", .{ .id = node_id });
}
}
}
const st = lp.SemanticTree{
.dom_node = root_node,
.registry = self.registry.?,
.page = self.page,
.arena = self.arena.?,
.prune = true,
.max_depth = self.maxDepth orelse std.math.maxInt(u32) - 1,
};
st.textStringify(w) catch |err| {
log.err(.mcp, "semantic tree dump failed", .{ .err = err });
return error.WriteFailed;
};
},
}
try jw.writer.writeByte('"');
jw.endWriteRaw();
}
};
const ToolAction = enum {
goto,
navigate,
markdown,
links,
nodeDetails,
interactiveElements,
structuredData,
detectForms,
evaluate,
eval,
semantic_tree,
click,
fill,
scroll,
waitForSelector,
hover,
press,
selectOption,
setChecked,
findElement,
};
const tool_map = std.StaticStringMap(ToolAction).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 },
});
pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
if (req.params == null or req.id == null) {
return server.sendError(req.id orelse .{ .integer = -1 }, .InvalidParams, "Missing params");
@@ -394,560 +263,33 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
return server.sendError(req.id.?, .InvalidParams, "Invalid params");
};
const action = tool_map.get(call_params.name) orelse {
return server.sendError(req.id.?, .MethodNotFound, "Tool not found");
};
const id = req.id.?;
switch (action) {
.goto, .navigate => try handleGoto(server, arena, req.id.?, call_params.arguments),
.markdown => try handleMarkdown(server, arena, req.id.?, call_params.arguments),
.links => try handleLinks(server, arena, req.id.?, call_params.arguments),
.nodeDetails => try handleNodeDetails(server, arena, req.id.?, call_params.arguments),
.interactiveElements => try handleInteractiveElements(server, arena, req.id.?, call_params.arguments),
.structuredData => try handleStructuredData(server, arena, req.id.?, call_params.arguments),
.detectForms => try handleDetectForms(server, arena, req.id.?, call_params.arguments),
.eval, .evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments),
.semantic_tree => try handleSemanticTree(server, arena, req.id.?, call_params.arguments),
.click => try handleClick(server, arena, req.id.?, call_params.arguments),
.fill => try handleFill(server, arena, req.id.?, call_params.arguments),
.scroll => try handleScroll(server, arena, req.id.?, call_params.arguments),
.waitForSelector => try handleWaitForSelector(server, arena, req.id.?, call_params.arguments),
.hover => try handleHover(server, arena, req.id.?, call_params.arguments),
.press => try handlePress(server, arena, req.id.?, call_params.arguments),
.selectOption => try handleSelectOption(server, arena, req.id.?, call_params.arguments),
.setChecked => try handleSetChecked(server, arena, req.id.?, call_params.arguments),
.findElement => try handleFindElement(server, arena, req.id.?, call_params.arguments),
if (!browser_tools.isKnownTool(call_params.name)) {
return server.sendError(id, .MethodNotFound, "Tool not found");
}
}
fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgs(GotoParams, arena, arguments, server, id, "goto");
try performGoto(server, args.url, id, args.timeout, args.waitUntil);
// Special handling for evaluate/eval: JS errors are returned as isError results, not protocol errors
if (std.mem.eql(u8, call_params.name, "evaluate") or std.mem.eql(u8, call_params.name, "eval")) {
const result = browser_tools.callEval(server.session, arena, call_params.arguments);
const content = [_]protocol.TextContent([]const u8){.{ .text = result.text }};
return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content, .isError = result.is_error });
}
const content = [_]protocol.TextContent([]const u8){.{ .text = "Navigated successfully." }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .markdown },
}};
server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch {
return server.sendError(id, .InternalError, "Failed to serialize markdown content");
};
}
fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .links },
}};
server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch {
return server.sendError(id, .InternalError, "Failed to serialize links content");
};
}
fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const TreeParams = struct {
url: ?[:0]const u8 = null,
backendNodeId: ?u32 = null,
maxDepth: ?u32 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const args = try parseArgsOrDefault(TreeParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{
.page = page,
.action = .semantic_tree,
.registry = &server.node_registry,
.arena = arena,
.backendNodeId = args.backendNodeId,
.maxDepth = args.maxDepth,
},
}};
server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch {
return server.sendError(id, .InternalError, "Failed to serialize semantic tree content");
};
}
fn handleNodeDetails(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
backendNodeId: CDPNode.Id,
};
const args = try parseArgs(Params, arena, arguments, server, id, "nodeDetails");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
const details = lp.SemanticTree.getNodeDetails(arena, resolved.node, &server.node_registry, resolved.page) catch {
return server.sendError(id, .InternalError, "Failed to get node details");
};
var aw: std.Io.Writer.Allocating = .init(arena);
try std.json.Stringify.value(&details, .{}, &aw.writer);
const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
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");
};
lp.interactive.registerNodes(elements, &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(elements, .{}, &aw.writer);
const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch |err| {
log.err(.mcp, "struct data collection failed", .{ .err = err });
return server.sendError(id, .InternalError, "Failed to collect structured data");
};
var aw: std.Io.Writer.Allocating = .init(arena);
try std.json.Stringify.value(data, .{}, &aw.writer);
const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const forms_data = lp.forms.collectForms(arena, page.document.asNode(), page) catch |err| {
log.err(.mcp, "form collection failed", .{ .err = err });
return server.sendError(id, .InternalError, "Failed to collect forms");
};
lp.forms.registerNodes(forms_data, &server.node_registry) catch |err| {
log.err(.mcp, "form node registration failed", .{ .err = err });
return server.sendError(id, .InternalError, "Failed to register form nodes");
};
var aw: std.Io.Writer.Allocating = .init(arena);
try std.json.Stringify.value(forms_data, .{}, &aw.writer);
const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgs(EvaluateParams, arena, arguments, server, id, "evaluate");
const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
var try_catch: 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);
try caught.format(&aw.writer);
const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }};
return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content, .isError = true });
};
const str_result = js_result.toStringSliceWithAlloc(arena) catch "undefined";
const content = [_]protocol.TextContent([]const u8){.{ .text = str_result }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const ClickParams = struct {
backendNodeId: CDPNode.Id,
};
const args = try parseArgs(ClickParams, arena, arguments, server, id, "click");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
lp.actions.click(resolved.node, resolved.page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an HTML element");
}
return server.sendError(id, .InternalError, "Failed to click element");
};
const page_title = resolved.page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Clicked element (backendNodeId: {d}). Page url: {s}, title: {s}", .{
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 handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const FillParams = struct {
backendNodeId: CDPNode.Id,
text: []const u8,
};
const args = try parseArgs(FillParams, arena, arguments, server, id, "fill");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
lp.actions.fill(resolved.node, args.text, resolved.page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an input, textarea or select");
}
return server.sendError(id, .InternalError, "Failed to fill element");
};
const page_title = resolved.page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Filled element (backendNodeId: {d}) with \"{s}\". Page url: {s}, title: {s}", .{
args.backendNodeId,
args.text,
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 handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const ScrollParams = struct {
backendNodeId: ?CDPNode.Id = null,
x: ?i32 = null,
y: ?i32 = null,
};
const args = try parseArgs(ScrollParams, arena, arguments, server, id, "scroll");
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
var target_node: ?*DOMNode = null;
if (args.backendNodeId) |node_id| {
const node = server.node_registry.lookup_by_id.get(node_id) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
const result = browser_tools.call(server.session, &server.node_registry, arena, call_params.name, call_params.arguments) catch |err| {
const code: protocol.ErrorCode = switch (err) {
error.PageNotLoaded => .PageNotLoaded,
error.NodeNotFound, error.InvalidParams => .InvalidParams,
error.NavigationFailed => .InternalError,
error.InternalError => .InternalError,
};
target_node = node.dom;
}
lp.actions.scroll(target_node, args.x, args.y, page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an element");
}
return server.sendError(id, .InternalError, "Failed to scroll");
return server.sendError(id, code, @errorName(err));
};
const page_title = page.getTitle() catch null;
const result_text = try 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)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
const content = [_]protocol.TextContent([]const u8){.{ .text = result }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const WaitParams = struct {
selector: [:0]const u8,
timeout: ?u32 = null,
};
const args = try parseArgs(WaitParams, arena, arguments, server, id, "waitForSelector");
_ = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const timeout_ms = args.timeout orelse 5000;
const node = lp.actions.waitForSelector(args.selector, timeout_ms, server.session) catch |err| {
if (err == error.InvalidSelector) {
return server.sendError(id, .InvalidParams, "Invalid selector");
} else if (err == error.Timeout) {
return server.sendError(id, .InternalError, "Timeout waiting for selector");
}
return server.sendError(id, .InternalError, "Failed waiting for selector");
};
const registered = try server.node_registry.register(node);
const msg = std.fmt.allocPrint(arena, "Element found. backendNodeId: {d}", .{registered.id}) catch "Element found.";
const content = [_]protocol.TextContent([]const u8){.{ .text = msg }};
return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleHover(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
backendNodeId: CDPNode.Id,
};
const args = try parseArgs(Params, arena, arguments, server, id, "hover");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
lp.actions.hover(resolved.node, resolved.page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an HTML element");
}
return server.sendError(id, .InternalError, "Failed to hover element");
};
const page_title = resolved.page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Hovered element (backendNodeId: {d}). Page url: {s}, title: {s}", .{
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 handlePress(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
key: []const u8,
backendNodeId: ?CDPNode.Id = null,
};
const args = try parseArgs(Params, arena, arguments, server, id, "press");
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
var target_node: ?*DOMNode = null;
if (args.backendNodeId) |node_id| {
const node = server.node_registry.lookup_by_id.get(node_id) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
target_node = node.dom;
}
lp.actions.press(target_node, args.key, page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an HTML element");
}
return server.sendError(id, .InternalError, "Failed to press key");
};
const page_title = page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Pressed key '{s}'. Page url: {s}, title: {s}", .{
args.key,
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 handleSelectOption(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
backendNodeId: CDPNode.Id,
value: []const u8,
};
const args = try parseArgs(Params, arena, arguments, server, id, "selectOption");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
lp.actions.selectOption(resolved.node, args.value, resolved.page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not a <select> 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");