mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-28 15:40:04 +00:00
mcp: add detectForms tool for structured form discovery
Add a detectForms MCP tool and lp.detectForms CDP command that return structured form metadata from the current page. Each form includes its action URL, HTTP method, and fields with names, types, required status, values, select options, and backendNodeIds for use with the fill tool. This lets AI agents discover and fill forms in a single step instead of calling interactiveElements, filtering for form fields, and guessing which fields belong to which form. New files: - src/browser/forms.zig: FormInfo/FormField structs, collectForms() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -101,6 +101,18 @@ pub const tool_list = [_]protocol.Tool{
|
||||
\\}
|
||||
),
|
||||
},
|
||||
.{
|
||||
.name = "detectForms",
|
||||
.description = "Detect all forms on the page and return their structure including fields, types, and required status. If a url is provided, it navigates to that url first.",
|
||||
.inputSchema = protocol.minify(
|
||||
\\{
|
||||
\\ "type": "object",
|
||||
\\ "properties": {
|
||||
\\ "url": { "type": "string", "description": "Optional URL to navigate to before detecting forms." }
|
||||
\\ }
|
||||
\\}
|
||||
),
|
||||
},
|
||||
.{
|
||||
.name = "click",
|
||||
.description = "Click on an interactive element.",
|
||||
@@ -252,6 +264,7 @@ const ToolAction = enum {
|
||||
links,
|
||||
interactiveElements,
|
||||
structuredData,
|
||||
detectForms,
|
||||
evaluate,
|
||||
semantic_tree,
|
||||
click,
|
||||
@@ -267,6 +280,7 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
|
||||
.{ "links", .links },
|
||||
.{ "interactiveElements", .interactiveElements },
|
||||
.{ "structuredData", .structuredData },
|
||||
.{ "detectForms", .detectForms },
|
||||
.{ "evaluate", .evaluate },
|
||||
.{ "semantic_tree", .semantic_tree },
|
||||
.{ "click", .click },
|
||||
@@ -299,6 +313,7 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
|
||||
.links => try handleLinks(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),
|
||||
.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),
|
||||
@@ -435,6 +450,137 @@ fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.
|
||||
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
|
||||
}
|
||||
|
||||
const FormWithId = struct {
|
||||
backendNodeId: CDPNode.Id,
|
||||
action: ?[]const u8,
|
||||
method: ?[]const u8,
|
||||
fields: []const FormFieldWithId,
|
||||
|
||||
pub fn jsonStringify(self: *const FormWithId, jw: anytype) !void {
|
||||
try jw.beginObject();
|
||||
try jw.objectField("backendNodeId");
|
||||
try jw.write(self.backendNodeId);
|
||||
if (self.action) |a| {
|
||||
try jw.objectField("action");
|
||||
try jw.write(a);
|
||||
}
|
||||
if (self.method) |m| {
|
||||
try jw.objectField("method");
|
||||
try jw.write(m);
|
||||
}
|
||||
try jw.objectField("fields");
|
||||
try jw.beginArray();
|
||||
for (self.fields) |field| {
|
||||
try field.jsonStringify(jw);
|
||||
}
|
||||
try jw.endArray();
|
||||
try jw.endObject();
|
||||
}
|
||||
};
|
||||
|
||||
const FormFieldWithId = struct {
|
||||
backendNodeId: CDPNode.Id,
|
||||
tag_name: []const u8,
|
||||
name: ?[]const u8,
|
||||
input_type: ?[]const u8,
|
||||
required: bool,
|
||||
value: ?[]const u8,
|
||||
placeholder: ?[]const u8,
|
||||
options: []const lp.forms.SelectOption,
|
||||
|
||||
pub fn jsonStringify(self: *const FormFieldWithId, 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);
|
||||
if (self.name) |v| {
|
||||
try jw.objectField("name");
|
||||
try jw.write(v);
|
||||
}
|
||||
if (self.input_type) |v| {
|
||||
try jw.objectField("inputType");
|
||||
try jw.write(v);
|
||||
}
|
||||
if (self.required) {
|
||||
try jw.objectField("required");
|
||||
try jw.write(true);
|
||||
}
|
||||
if (self.value) |v| {
|
||||
try jw.objectField("value");
|
||||
try jw.write(v);
|
||||
}
|
||||
if (self.placeholder) |v| {
|
||||
try jw.objectField("placeholder");
|
||||
try jw.write(v);
|
||||
}
|
||||
if (self.options.len > 0) {
|
||||
try jw.objectField("options");
|
||||
try jw.beginArray();
|
||||
for (self.options) |opt| {
|
||||
try opt.jsonStringify(jw);
|
||||
}
|
||||
try jw.endArray();
|
||||
}
|
||||
try jw.endObject();
|
||||
}
|
||||
};
|
||||
|
||||
fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
||||
const Params = struct {
|
||||
url: ?[:0]const u8 = null,
|
||||
};
|
||||
if (arguments) |args_raw| {
|
||||
if (std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
|
||||
if (args.url) |u| {
|
||||
try performGoto(server, u, id);
|
||||
}
|
||||
} else |_| {}
|
||||
}
|
||||
const page = server.session.currentPage() orelse {
|
||||
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
||||
};
|
||||
|
||||
const forms_data = lp.forms.collectForms(page.document.asNode(), arena, page) catch |err| {
|
||||
log.err(.mcp, "form collection failed", .{ .err = err });
|
||||
return server.sendError(id, .InternalError, "Failed to collect forms");
|
||||
};
|
||||
|
||||
// Build output with backendNodeIds
|
||||
var results: std.ArrayList(FormWithId) = .empty;
|
||||
for (forms_data) |form| {
|
||||
const form_registered = try server.node_registry.register(form.node);
|
||||
|
||||
var fields_with_ids: std.ArrayList(FormFieldWithId) = .empty;
|
||||
for (form.fields) |field| {
|
||||
const field_registered = try server.node_registry.register(field.node);
|
||||
try fields_with_ids.append(arena, .{
|
||||
.backendNodeId = field_registered.id,
|
||||
.tag_name = field.tag_name,
|
||||
.name = field.name,
|
||||
.input_type = field.input_type,
|
||||
.required = field.required,
|
||||
.value = field.value,
|
||||
.placeholder = field.placeholder,
|
||||
.options = field.options,
|
||||
});
|
||||
}
|
||||
|
||||
try results.append(arena, .{
|
||||
.backendNodeId = form_registered.id,
|
||||
.action = form.action,
|
||||
.method = form.method,
|
||||
.fields = fields_with_ids.items,
|
||||
});
|
||||
}
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(arena);
|
||||
try std.json.Stringify.value(results.items, .{}, &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 parseArguments(EvaluateParams, arena, arguments, server, id, "evaluate");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user