From 9c8fe9b20faaf7ffb506716c6def30b50b973432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 30 Mar 2026 16:26:02 +0200 Subject: [PATCH 1/2] SemanticTree: Add nodeDetails tool Adds a tool to retrieve detailed node metadata and updates the semantic tree to track and display the disabled state of elements. --- src/SemanticTree.zig | 184 +++++++++++++++++++++++++++++++++++++++++ src/cdp/domains/lp.zig | 20 +++++ src/mcp/tools.zig | 42 ++++++++++ 3 files changed, 246 insertions(+) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 6366890f..bc602f8a 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -97,6 +97,7 @@ const NodeData = struct { options: ?[]OptionData = null, xpath: []const u8, is_interactive: bool, + is_disabled: bool, node_name: []const u8, }; @@ -148,6 +149,7 @@ fn walk( const role = try axn.getRole(); var is_interactive = false; + var is_disabled = false; var value: ?[]const u8 = null; var options: ?[]OptionData = null; var node_name: []const u8 = "text"; @@ -172,6 +174,8 @@ fn walk( is_interactive = true; } } + + is_disabled = el.isDisabled(); } else if (node._type == .document or node._type == .document_fragment) { node_name = "root"; } @@ -236,6 +240,7 @@ fn walk( .options = options, .xpath = xpath, .is_interactive = is_interactive, + .is_disabled = is_disabled, .node_name = node_name, }; @@ -347,6 +352,11 @@ const JsonVisitor = struct { try self.jw.objectField("isInteractive"); try self.jw.write(data.is_interactive); + if (data.is_disabled) { + try self.jw.objectField("isDisabled"); + try self.jw.write(true); + } + try self.jw.objectField("role"); try self.jw.write(data.role); @@ -459,6 +469,9 @@ const TextVisitor = struct { const is_text_only = std.mem.eql(u8, data.role, "StaticText") or std.mem.eql(u8, data.role, "none") or std.mem.eql(u8, data.role, "generic"); try self.writer.print("{d}", .{data.id}); + if (data.is_interactive) { + try self.writer.writeAll(if (data.is_disabled) " [i:disabled]" else " [i]"); + } if (!is_text_only) { try self.writer.print(" {s}", .{data.role}); } @@ -509,6 +522,177 @@ const TextVisitor = struct { } }; +pub const NodeDetails = struct { + backendNodeId: CDPNode.Id, + tag_name: []const u8, + role: []const u8, + name: ?[]const u8, + is_interactive: bool, + is_disabled: bool, + value: ?[]const u8 = null, + input_type: ?[]const u8 = null, + placeholder: ?[]const u8 = null, + href: ?[]const u8 = null, + id: ?[]const u8 = null, + class: ?[]const u8 = null, + checked: ?bool = null, + options: ?[]OptionData = null, + + pub fn jsonStringify(self: *const NodeDetails, jw: anytype) !void { + try jw.beginObject(); + + try jw.objectField("backendNodeId"); + try jw.write(self.backendNodeId); + + try jw.objectField("tagName"); + try jw.write(self.tag_name); + + try jw.objectField("role"); + try jw.write(self.role); + + if (self.name) |n| { + try jw.objectField("name"); + try jw.write(n); + } + + try jw.objectField("isInteractive"); + try jw.write(self.is_interactive); + + if (self.is_disabled) { + try jw.objectField("isDisabled"); + try jw.write(true); + } + + if (self.value) |v| { + try jw.objectField("value"); + try jw.write(v); + } + + if (self.input_type) |v| { + try jw.objectField("inputType"); + try jw.write(v); + } + + if (self.placeholder) |v| { + try jw.objectField("placeholder"); + try jw.write(v); + } + + if (self.href) |v| { + try jw.objectField("href"); + try jw.write(v); + } + + if (self.id) |v| { + try jw.objectField("id"); + try jw.write(v); + } + + if (self.class) |v| { + try jw.objectField("class"); + try jw.write(v); + } + + if (self.checked) |c| { + try jw.objectField("checked"); + try jw.write(c); + } + + if (self.options) |opts| { + try jw.objectField("options"); + try jw.beginArray(); + for (opts) |opt| { + try jw.beginObject(); + try jw.objectField("value"); + try jw.write(opt.value); + try jw.objectField("text"); + try jw.write(opt.text); + if (opt.selected) { + try jw.objectField("selected"); + try jw.write(true); + } + try jw.endObject(); + } + try jw.endArray(); + } + + try jw.endObject(); + } +}; + +pub fn getNodeDetails(node: *Node, registry: *CDPNode.Registry, page: *Page, arena: std.mem.Allocator) !NodeDetails { + const cdp_node = try registry.register(node); + const axn = AXNode.fromNode(node); + const role = try axn.getRole(); + const name = try axn.getName(page, arena); + + var is_interactive_val = false; + var is_disabled = false; + var tag_name: []const u8 = "text"; + var value: ?[]const u8 = null; + var input_type: ?[]const u8 = null; + var placeholder: ?[]const u8 = null; + var href: ?[]const u8 = null; + var id_attr: ?[]const u8 = null; + var class_attr: ?[]const u8 = null; + var checked: ?bool = null; + var options: ?[]OptionData = null; + + if (node.is(Element)) |el| { + tag_name = el.getTagNameLower(); + is_disabled = el.isDisabled(); + id_attr = el.getAttributeSafe(comptime lp.String.wrap("id")); + class_attr = el.getAttributeSafe(comptime lp.String.wrap("class")); + placeholder = el.getAttributeSafe(comptime lp.String.wrap("placeholder")); + + if (el.getAttributeSafe(comptime lp.String.wrap("href"))) |h| { + const URL = lp.URL; + href = URL.resolve(arena, page.base(), h, .{ .encode = true }) catch h; + } + + if (el.is(Element.Html.Input)) |input| { + value = input.getValue(); + input_type = input._input_type.toString(); + if (input._input_type == .checkbox or input._input_type == .radio) { + checked = input.getChecked(); + } + if (el.getAttributeSafe(comptime lp.String.wrap("list"))) |list_id| { + options = try extractDataListOptions(list_id, page, arena); + } + } else if (el.is(Element.Html.TextArea)) |textarea| { + value = textarea.getValue(); + } else if (el.is(Element.Html.Select)) |select| { + value = select.getValue(page); + options = try extractSelectOptions(el.asNode(), page, arena); + } + + if (el.is(Element.Html)) |html_el| { + const listener_targets = try interactive.buildListenerTargetMap(page, arena); + var pointer_events_cache: Element.PointerEventsCache = .empty; + if (interactive.classifyInteractivity(page, el, html_el, listener_targets, &pointer_events_cache) != null) { + is_interactive_val = true; + } + } + } + + return .{ + .backendNodeId = cdp_node.id, + .tag_name = tag_name, + .role = role, + .name = name, + .is_interactive = is_interactive_val, + .is_disabled = is_disabled, + .value = value, + .input_type = input_type, + .placeholder = placeholder, + .href = href, + .id = id_attr, + .class = class_attr, + .checked = checked, + .options = options, + }; +} + const testing = @import("testing.zig"); test "SemanticTree backendDOMNodeId" { diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 72eb6ee8..87cbce3e 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -30,6 +30,7 @@ pub fn processMessage(cmd: anytype) !void { getMarkdown, getSemanticTree, getInteractiveElements, + getNodeDetails, getStructuredData, detectForms, clickNode, @@ -42,6 +43,7 @@ pub fn processMessage(cmd: anytype) !void { .getMarkdown => return getMarkdown(cmd), .getSemanticTree => return getSemanticTree(cmd), .getInteractiveElements => return getInteractiveElements(cmd), + .getNodeDetails => return getNodeDetails(cmd), .getStructuredData => return getStructuredData(cmd), .detectForms => return detectForms(cmd), .clickNode => return clickNode(cmd), @@ -141,6 +143,24 @@ fn getInteractiveElements(cmd: anytype) !void { }, .{}); } +fn getNodeDetails(cmd: anytype) !void { + const Params = struct { + backendNodeId: Node.Id, + }; + const params = (try cmd.params(Params)) orelse return error.InvalidParam; + + const bc = cmd.browser_context orelse return error.NoBrowserContext; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + const node = (bc.node_registry.lookup_by_id.get(params.backendNodeId) orelse return error.InvalidNodeId).dom; + + const details = SemanticTree.getNodeDetails(node, &bc.node_registry, page, cmd.arena) catch return error.InternalError; + + return cmd.sendResult(.{ + .nodeDetails = details, + }, .{}); +} + fn getStructuredData(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 1be7e04c..b98d2cef 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -75,6 +75,19 @@ pub const tool_list = [_]protocol.Tool{ \\} ), }, + .{ + .name = "nodeDetails", + .description = "Get detailed information about a specific node by its backend node ID. Returns tag, role, name, interactivity, disabled state, value, input type, placeholder, href, checked state, and select options.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the element to inspect." } + \\ }, + \\ "required": ["backendNodeId"] + \\} + ), + }, .{ .name = "interactiveElements", .description = "Extract interactive elements from the opened page. If a url is provided, it navigates to that url first.", @@ -256,6 +269,7 @@ const ToolAction = enum { navigate, markdown, links, + nodeDetails, interactiveElements, structuredData, detectForms, @@ -272,6 +286,7 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ .{ "navigate", .navigate }, .{ "markdown", .markdown }, .{ "links", .links }, + .{ "nodeDetails", .nodeDetails }, .{ "interactiveElements", .interactiveElements }, .{ "structuredData", .structuredData }, .{ "detectForms", .detectForms }, @@ -305,6 +320,7 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque .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), @@ -373,6 +389,32 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va }; } +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"); + + _ = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; + + const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse { + return server.sendError(id, .InvalidParams, "Node not found"); + }; + + const page = server.session.currentPage().?; + const details = lp.SemanticTree.getNodeDetails(node.dom, &server.node_registry, page, arena) 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); From 367d20d39f07b544ba08630e9cf525107ed4d4b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 31 Mar 2026 05:20:32 +0200 Subject: [PATCH 2/2] SemanticTree: simplify lp.String.wrap calls --- src/SemanticTree.zig | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index bc602f8a..42e5335c 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -159,7 +159,7 @@ fn walk( if (el.is(Element.Html.Input)) |input| { value = input.getValue(); - if (el.getAttributeSafe(comptime lp.String.wrap("list"))) |list_id| { + if (el.getAttributeSafe(comptime .wrap("list"))) |list_id| { options = try extractDataListOptions(list_id, self.page, self.arena); } } else if (el.is(Element.Html.TextArea)) |textarea| { @@ -641,11 +641,11 @@ pub fn getNodeDetails(node: *Node, registry: *CDPNode.Registry, page: *Page, are if (node.is(Element)) |el| { tag_name = el.getTagNameLower(); is_disabled = el.isDisabled(); - id_attr = el.getAttributeSafe(comptime lp.String.wrap("id")); - class_attr = el.getAttributeSafe(comptime lp.String.wrap("class")); - placeholder = el.getAttributeSafe(comptime lp.String.wrap("placeholder")); + id_attr = el.getAttributeSafe(comptime .wrap("id")); + class_attr = el.getAttributeSafe(comptime .wrap("class")); + placeholder = el.getAttributeSafe(comptime .wrap("placeholder")); - if (el.getAttributeSafe(comptime lp.String.wrap("href"))) |h| { + if (el.getAttributeSafe(comptime .wrap("href"))) |h| { const URL = lp.URL; href = URL.resolve(arena, page.base(), h, .{ .encode = true }) catch h; } @@ -656,7 +656,7 @@ pub fn getNodeDetails(node: *Node, registry: *CDPNode.Registry, page: *Page, are if (input._input_type == .checkbox or input._input_type == .radio) { checked = input.getChecked(); } - if (el.getAttributeSafe(comptime lp.String.wrap("list"))) |list_id| { + if (el.getAttributeSafe(comptime .wrap("list"))) |list_id| { options = try extractDataListOptions(list_id, page, arena); } } else if (el.is(Element.Html.TextArea)) |textarea| {