Merge pull request #1951 from mvanhorn/osc/feat-mcp-detect-forms
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled

mcp: add detectForms tool for structured form discovery
This commit is contained in:
Adrià Arrufat
2026-03-25 14:23:09 +09:00
committed by GitHub
9 changed files with 562 additions and 77 deletions

460
src/browser/forms.zig Normal file
View File

@@ -0,0 +1,460 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Page = @import("Page.zig");
const TreeWalker = @import("webapi/TreeWalker.zig");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
const Allocator = std.mem.Allocator;
pub const SelectOption = struct {
value: []const u8,
text: []const u8,
pub fn jsonStringify(self: *const SelectOption, jw: anytype) !void {
try jw.beginObject();
try jw.objectField("value");
try jw.write(self.value);
try jw.objectField("text");
try jw.write(self.text);
try jw.endObject();
}
};
pub const FormField = struct {
backendNodeId: ?u32 = null,
node: *Node,
tag_name: []const u8,
name: ?[]const u8,
input_type: ?[]const u8,
required: bool,
disabled: bool,
value: ?[]const u8,
placeholder: ?[]const u8,
options: []SelectOption,
pub fn jsonStringify(self: *const FormField, jw: anytype) !void {
try jw.beginObject();
if (self.backendNodeId) |id| {
try jw.objectField("backendNodeId");
try jw.write(id);
}
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);
}
try jw.objectField("required");
try jw.write(self.required);
try jw.objectField("disabled");
try jw.write(self.disabled);
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();
}
};
pub const FormInfo = struct {
backendNodeId: ?u32 = null,
node: *Node,
action: ?[]const u8,
method: ?[]const u8,
fields: []FormField,
pub fn jsonStringify(self: *const FormInfo, jw: anytype) !void {
try jw.beginObject();
if (self.backendNodeId) |id| {
try jw.objectField("backendNodeId");
try jw.write(id);
}
if (self.action) |v| {
try jw.objectField("action");
try jw.write(v);
}
if (self.method) |v| {
try jw.objectField("method");
try jw.write(v);
}
try jw.objectField("fields");
try jw.beginArray();
for (self.fields) |field| {
try field.jsonStringify(jw);
}
try jw.endArray();
try jw.endObject();
}
};
/// Populate backendNodeId on each form and its fields by registering
/// their nodes in the given registry. Works with both CDP and MCP registries.
pub fn registerNodes(forms_data: []FormInfo, registry: anytype) !void {
for (forms_data) |*form| {
const form_registered = try registry.register(form.node);
form.backendNodeId = form_registered.id;
for (form.fields) |*field| {
const field_registered = try registry.register(field.node);
field.backendNodeId = field_registered.id;
}
}
}
/// Collect all forms and their fields under `root`.
/// Uses Form.getElements() to include fields outside the <form> that
/// reference it via the form="id" attribute, matching browser behavior.
/// `arena` must be an arena allocator — returned slices borrow its memory.
pub fn collectForms(
arena: Allocator,
root: *Node,
page: *Page,
) ![]FormInfo {
var forms: std.ArrayList(FormInfo) = .empty;
var tw = TreeWalker.Full.init(root, .{});
while (tw.next()) |node| {
const form = node.is(Element.Html.Form) orelse continue;
const el = form.asElement();
const fields = try collectFormFields(arena, form, page);
if (fields.len == 0) continue;
const action_attr = el.getAttributeSafe(comptime .wrap("action"));
const method_str = form.getMethod();
try forms.append(arena, .{
.node = node,
.action = if (action_attr) |a| if (a.len > 0) a else null else null,
.method = method_str,
.fields = fields,
});
}
return forms.items;
}
fn collectFormFields(
arena: Allocator,
form: *Element.Html.Form,
page: *Page,
) ![]FormField {
var fields: std.ArrayList(FormField) = .empty;
var elements = try form.getElements(page);
var it = try elements.iterator();
while (it.next()) |el| {
const node = el.asNode();
const is_disabled = el.isDisabled();
if (el.is(Element.Html.Input)) |input| {
if (input._input_type == .hidden) continue;
if (input._input_type == .submit or input._input_type == .button or input._input_type == .image) continue;
try fields.append(arena, .{
.node = node,
.tag_name = "input",
.name = el.getAttributeSafe(comptime .wrap("name")),
.input_type = input._input_type.toString(),
.required = el.getAttributeSafe(comptime .wrap("required")) != null,
.disabled = is_disabled,
.value = input.getValue(),
.placeholder = el.getAttributeSafe(comptime .wrap("placeholder")),
.options = &.{},
});
continue;
}
if (el.is(Element.Html.TextArea)) |textarea| {
try fields.append(arena, .{
.node = node,
.tag_name = "textarea",
.name = el.getAttributeSafe(comptime .wrap("name")),
.input_type = null,
.required = el.getAttributeSafe(comptime .wrap("required")) != null,
.disabled = is_disabled,
.value = textarea.getValue(),
.placeholder = el.getAttributeSafe(comptime .wrap("placeholder")),
.options = &.{},
});
continue;
}
if (el.is(Element.Html.Select)) |select| {
const options = try collectSelectOptions(arena, node, page);
try fields.append(arena, .{
.node = node,
.tag_name = "select",
.name = el.getAttributeSafe(comptime .wrap("name")),
.input_type = null,
.required = el.getAttributeSafe(comptime .wrap("required")) != null,
.disabled = is_disabled,
.value = select.getValue(page),
.placeholder = null,
.options = options,
});
continue;
}
// Button elements from getElements() - skip (not fillable)
}
return fields.items;
}
fn collectSelectOptions(
arena: Allocator,
select_node: *Node,
page: *Page,
) ![]SelectOption {
var options: std.ArrayList(SelectOption) = .empty;
const Option = Element.Html.Option;
var tw = TreeWalker.Full.init(select_node, .{});
while (tw.next()) |node| {
const el = node.is(Element) orelse continue;
const option = el.is(Option) orelse continue;
try options.append(arena, .{
.value = option.getValue(page),
.text = option.getText(page),
});
}
return options.items;
}
const testing = @import("../testing.zig");
fn testForms(html: []const u8) ![]FormInfo {
const page = try testing.test_session.createPage();
const doc = page.window._document;
const div = try doc.createElement("div", null, page);
try page.parseHtmlAsChildren(div.asNode(), html);
return collectForms(page.call_arena, div.asNode(), page);
}
test "browser.forms: login form" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form action="/login" method="POST">
\\ <input type="email" name="email" required placeholder="Email">
\\ <input type="password" name="password" required>
\\ <input type="submit" value="Log In">
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual("/login", forms[0].action.?);
try testing.expectEqual("post", forms[0].method.?);
try testing.expectEqual(2, forms[0].fields.len);
try testing.expectEqual("email", forms[0].fields[0].name.?);
try testing.expectEqual("email", forms[0].fields[0].input_type.?);
try testing.expect(forms[0].fields[0].required);
try testing.expect(!forms[0].fields[0].disabled);
try testing.expectEqual("password", forms[0].fields[1].name.?);
}
test "browser.forms: form with select" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <select name="color">
\\ <option value="red">Red</option>
\\ <option value="blue">Blue</option>
\\ </select>
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(1, forms[0].fields.len);
try testing.expectEqual("select", forms[0].fields[0].tag_name);
try testing.expectEqual(2, forms[0].fields[0].options.len);
try testing.expectEqual("red", forms[0].fields[0].options[0].value);
try testing.expectEqual("Red", forms[0].fields[0].options[0].text);
}
test "browser.forms: form with textarea" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form method="POST">
\\ <textarea name="message" placeholder="Your message"></textarea>
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(1, forms[0].fields.len);
try testing.expectEqual("textarea", forms[0].fields[0].tag_name);
try testing.expectEqual("Your message", forms[0].fields[0].placeholder.?);
}
test "browser.forms: empty form skipped" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form action="/empty">
\\ <p>No fields here</p>
\\</form>
);
try testing.expectEqual(0, forms.len);
}
test "browser.forms: hidden inputs excluded" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <input type="hidden" name="csrf" value="token123">
\\ <input type="text" name="username">
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(1, forms[0].fields.len);
try testing.expectEqual("username", forms[0].fields[0].name.?);
}
test "browser.forms: multiple forms" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form action="/search" method="GET">
\\ <input type="text" name="q" placeholder="Search">
\\</form>
\\<form action="/login" method="POST">
\\ <input type="email" name="email">
\\ <input type="password" name="pass">
\\</form>
);
try testing.expectEqual(2, forms.len);
try testing.expectEqual(1, forms[0].fields.len);
try testing.expectEqual(2, forms[1].fields.len);
}
test "browser.forms: disabled fields flagged" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <input type="text" name="enabled_field">
\\ <input type="text" name="disabled_field" disabled>
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(2, forms[0].fields.len);
try testing.expect(!forms[0].fields[0].disabled);
try testing.expect(forms[0].fields[1].disabled);
}
test "browser.forms: disabled fieldset" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <fieldset disabled>
\\ <input type="text" name="in_disabled_fieldset">
\\ </fieldset>
\\ <input type="text" name="outside_fieldset">
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(2, forms[0].fields.len);
try testing.expect(forms[0].fields[0].disabled);
try testing.expect(!forms[0].fields[1].disabled);
}
test "browser.forms: external field via form attribute" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<input type="text" name="external" form="myform">
\\<form id="myform" action="/submit">
\\ <input type="text" name="internal">
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(2, forms[0].fields.len);
}
test "browser.forms: checkbox and radio return value attribute" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <input type="checkbox" name="agree" value="yes" checked>
\\ <input type="radio" name="color" value="red">
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(2, forms[0].fields.len);
try testing.expectEqual("checkbox", forms[0].fields[0].input_type.?);
try testing.expectEqual("yes", forms[0].fields[0].value.?);
try testing.expectEqual("radio", forms[0].fields[1].input_type.?);
try testing.expectEqual("red", forms[0].fields[1].value.?);
}
test "browser.forms: form without action or method" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <input type="text" name="q">
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(null, forms[0].action);
try testing.expectEqual("get", forms[0].method.?);
try testing.expectEqual(1, forms[0].fields.len);
}

View File

@@ -162,7 +162,7 @@ pub fn collectInteractiveElements(
.name = try getAccessibleName(el, arena),
.interactivity_type = itype,
.listener_types = listener_types,
.disabled = isDisabled(el),
.disabled = el.isDisabled(),
.tab_index = html_el.getTabIndex(),
.id = el.getAttributeSafe(comptime .wrap("id")),
.class = el.getAttributeSafe(comptime .wrap("class")),
@@ -412,36 +412,6 @@ fn getTextContent(node: *Node, arena: Allocator) !?[]const u8 {
// strip out trailing space
return arr.items[0 .. arr.items.len - 1];
}
fn isDisabled(el: *Element) bool {
if (el.getAttributeSafe(comptime .wrap("disabled")) != null) return true;
return isDisabledByFieldset(el);
}
/// Check if an element is disabled by an ancestor <fieldset disabled>.
/// Per spec, elements inside the first <legend> child of a disabled fieldset
/// are NOT disabled by that fieldset.
fn isDisabledByFieldset(el: *Element) bool {
const element_node = el.asNode();
var current: ?*Node = element_node._parent;
while (current) |node| {
current = node._parent;
const ancestor = node.is(Element) orelse continue;
if (ancestor.getTag() == .fieldset and ancestor.getAttributeSafe(comptime .wrap("disabled")) != null) {
// Check if element is inside the first <legend> child of this fieldset
var child = ancestor.firstElementChild();
while (child) |c| {
if (c.getTag() == .legend) {
if (c.asNode().contains(element_node)) return false;
break;
}
child = c.nextElementSibling();
}
return true;
}
}
return false;
}
fn getInputType(el: *Element) ?[]const u8 {
if (el.is(Element.Html.Input)) |input| {

View File

@@ -120,9 +120,10 @@
<script id=link_click>
testing.async(async (restore) => {
let f6;
await new Promise((resolve) => {
let count = 0;
let f6 = document.createElement('iframe');
f6 = document.createElement('iframe');
f6.id = 'f6';
f6.addEventListener('load', () => {
if (++count == 2) {

View File

@@ -573,6 +573,32 @@ pub fn hasAttributeSafe(self: *const Element, name: String) bool {
return attributes.hasSafe(name);
}
pub fn isDisabled(self: *Element) bool {
if (self.getAttributeSafe(comptime .wrap("disabled")) != null) {
return true;
}
const element_node = self.asNode();
var current: ?*Node = element_node._parent;
while (current) |node| {
current = node._parent;
const ancestor = node.is(Element) orelse continue;
if (ancestor.getTag() == .fieldset and ancestor.getAttributeSafe(comptime .wrap("disabled")) != null) {
var child = ancestor.firstElementChild();
while (child) |c| {
if (c.getTag() == .legend) {
if (c.asNode().contains(element_node)) return false;
break;
}
child = c.nextElementSibling();
}
return true;
}
}
return false;
}
pub fn hasAttributes(self: *const Element) bool {
const attributes = self._attributes orelse return false;
return attributes.isEmpty() == false;

View File

@@ -125,15 +125,10 @@ fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, page: *Pa
var list: KeyValueList = .empty;
const form = form_ orelse return list;
const form_node = form.asNode();
var elements = try form.getElements(page);
var it = try elements.iterator();
while (it.next()) |element| {
if (element.getAttributeSafe(comptime .wrap("disabled")) != null) {
continue;
}
if (isDisabledByFieldset(element, form_node)) {
if (element.isDisabled()) {
continue;
}
@@ -202,41 +197,6 @@ fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, page: *Pa
return list;
}
// Returns true if `element` is disabled by an ancestor <fieldset disabled>,
// stopping the upward walk when the form node is reached.
// Per spec, elements inside the first <legend> child of a disabled fieldset
// are NOT disabled by that fieldset.
fn isDisabledByFieldset(element: *Element, form_node: *Node) bool {
const element_node = element.asNode();
var current: ?*Node = element_node._parent;
while (current) |node| {
// Stop at the form boundary (common case optimisation)
if (node == form_node) {
return false;
}
current = node._parent;
const el = node.is(Element) orelse continue;
if (el.getTag() == .fieldset and el.getAttributeSafe(comptime .wrap("disabled")) != null) {
// Check if `element` is inside the first <legend> child of this fieldset
var child = el.firstElementChild();
while (child) |c| {
if (c.getTag() == .legend) {
// Found the first legend; exempt if element is a descendant
if (c.asNode().contains(element_node)) {
return false;
}
break;
}
child = c.nextElementSibling();
}
return true;
}
}
return false;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(FormData);

View File

@@ -295,7 +295,7 @@ pub const Writer = struct {
},
.input => {
const input = el.as(DOMNode.Element.Html.Input);
const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled"));
const is_disabled = el.isDisabled();
switch (input._input_type) {
.text, .email, .tel, .url, .search, .password, .number => {
@@ -332,7 +332,7 @@ pub const Writer = struct {
}
},
.textarea => {
const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled"));
const is_disabled = el.isDisabled();
try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
if (!is_disabled) {
@@ -347,7 +347,7 @@ pub const Writer = struct {
try self.writeAXProperty(.{ .name = .required, .value = .{ .boolean = el.hasAttributeSafe(comptime .wrap("required")) } }, w);
},
.select => {
const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled"));
const is_disabled = el.isDisabled();
try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
if (!is_disabled) {
@@ -391,7 +391,7 @@ pub const Writer = struct {
}
},
.button => {
const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled"));
const is_disabled = el.isDisabled();
try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
if (!is_disabled) {
try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w);

View File

@@ -32,6 +32,7 @@ pub fn processMessage(cmd: anytype) !void {
getSemanticTree,
getInteractiveElements,
getStructuredData,
detectForms,
clickNode,
fillNode,
scrollNode,
@@ -43,6 +44,7 @@ pub fn processMessage(cmd: anytype) !void {
.getSemanticTree => return getSemanticTree(cmd),
.getInteractiveElements => return getInteractiveElements(cmd),
.getStructuredData => return getStructuredData(cmd),
.detectForms => return detectForms(cmd),
.clickNode => return clickNode(cmd),
.fillNode => return fillNode(cmd),
.scrollNode => return scrollNode(cmd),
@@ -162,6 +164,23 @@ fn getStructuredData(cmd: anytype) !void {
}, .{});
}
fn detectForms(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.NoBrowserContext;
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const forms_data = try lp.forms.collectForms(
cmd.arena,
page.document.asNode(),
page,
);
try lp.forms.registerNodes(forms_data, &bc.node_registry);
return cmd.sendResult(.{
.forms = forms_data,
}, .{});
}
fn clickNode(cmd: anytype) !void {
const Params = struct {
nodeId: ?Node.Id = null,

View File

@@ -35,6 +35,7 @@ pub const markdown = @import("browser/markdown.zig");
pub const SemanticTree = @import("SemanticTree.zig");
pub const CDPNode = @import("cdp/Node.zig");
pub const interactive = @import("browser/interactive.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 mcp = @import("mcp.zig");

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. Returns the current page URL and title after the click.",
@@ -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,39 @@ fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.
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 Params = struct {
url: ?[:0]const u8 = null,
};
if (arguments) |args_raw| {
const args = std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true }) catch {
return server.sendError(id, .InvalidParams, "Invalid arguments for detectForms");
};
if (args.url) |u| {
try performGoto(server, u, id);
}
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
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 parseArguments(EvaluateParams, arena, arguments, server, id, "evaluate");