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:
Matt Van Horn
2026-03-21 08:40:50 -07:00
parent fdc79af55c
commit 78c6def2b1
4 changed files with 514 additions and 0 deletions

View File

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