mcp: extract parseOptionalAndGetPage helper

Deduplicate the repeated "parse optional URL, maybe navigate, get page"
pattern across 6 MCP tool handlers (markdown, links, semantic_tree,
interactiveElements, structuredData, detectForms).
This commit is contained in:
Adrià Arrufat
2026-03-26 17:13:32 +09:00
parent 0324d5c232
commit 96d24b5dc6

View File

@@ -179,6 +179,10 @@ const GotoParams = struct {
url: [:0]const u8, url: [:0]const u8,
}; };
const UrlParams = struct {
url: ?[:0]const u8 = null,
};
const EvaluateParams = struct { const EvaluateParams = struct {
script: [:0]const u8, script: [:0]const u8,
url: ?[:0]const u8 = null, url: ?[:0]const u8 = null,
@@ -324,7 +328,7 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
} }
fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArguments(GotoParams, arena, arguments, server, id, "goto"); const args = try parseArgs(GotoParams, arena, arguments, server, id, "goto");
try performGoto(server, args.url, id); try performGoto(server, args.url, id);
const content = [_]protocol.TextContent([]const u8){.{ .text = "Navigated successfully." }}; const content = [_]protocol.TextContent([]const u8){.{ .text = "Navigated successfully." }};
@@ -332,19 +336,8 @@ fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
} }
fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const MarkdownParams = struct { const args = parseArgsOrDefault(UrlParams, arena, arguments);
url: ?[:0]const u8 = null, const page = try ensurePage(server, id, args.url);
};
if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(MarkdownParams, 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 content = [_]protocol.TextContent(ToolStreamingText){.{ const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .markdown }, .text = .{ .page = page, .action = .markdown },
@@ -353,19 +346,8 @@ fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value,
} }
fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const LinksParams = struct { const args = parseArgsOrDefault(UrlParams, arena, arguments);
url: ?[:0]const u8 = null, const page = try ensurePage(server, id, args.url);
};
if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(LinksParams, 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 content = [_]protocol.TextContent(ToolStreamingText){.{ const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .links }, .text = .{ .page = page, .action = .links },
@@ -379,39 +361,25 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va
backendNodeId: ?u32 = null, backendNodeId: ?u32 = null,
maxDepth: ?u32 = null, maxDepth: ?u32 = null,
}; };
var tree_args: TreeParams = .{}; const args = parseArgsOrDefault(TreeParams, arena, arguments);
if (arguments) |args_raw| { const page = try ensurePage(server, id, args.url);
if (std.json.parseFromValueLeaky(TreeParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
tree_args = 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 content = [_]protocol.TextContent(ToolStreamingText){.{ const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .semantic_tree, .registry = &server.node_registry, .arena = arena, .backendNodeId = tree_args.backendNodeId, .maxDepth = tree_args.maxDepth }, .text = .{
.page = page,
.action = .semantic_tree,
.registry = &server.node_registry,
.arena = arena,
.backendNodeId = args.backendNodeId,
.maxDepth = args.maxDepth,
},
}}; }};
try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content });
} }
fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct { const args = parseArgsOrDefault(UrlParams, arena, arguments);
url: ?[:0]const u8 = null, const page = try ensurePage(server, id, args.url);
};
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 elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| { const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| {
log.err(.mcp, "elements collection failed", .{ .err = err }); log.err(.mcp, "elements collection failed", .{ .err = err });
@@ -425,19 +393,8 @@ fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.
} }
fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct { const args = parseArgsOrDefault(UrlParams, arena, arguments);
url: ?[:0]const u8 = null, const page = try ensurePage(server, id, args.url);
};
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 data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch |err| { const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch |err| {
log.err(.mcp, "struct data collection failed", .{ .err = err }); log.err(.mcp, "struct data collection failed", .{ .err = err });
@@ -451,20 +408,8 @@ fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.
} }
fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct { const args = parseArgsOrDefault(UrlParams, arena, arguments);
url: ?[:0]const u8 = null, const page = try ensurePage(server, id, args.url);
};
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| { const forms_data = lp.forms.collectForms(arena, page.document.asNode(), page) catch |err| {
log.err(.mcp, "form collection failed", .{ .err = err }); log.err(.mcp, "form collection failed", .{ .err = err });
@@ -484,14 +429,8 @@ fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Val
} }
fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { 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"); const args = try parseArgs(EvaluateParams, arena, arguments, server, id, "evaluate");
const page = try ensurePage(server, id, args.url);
if (args.url) |url| {
try performGoto(server, url, id);
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
var ls: js.Local.Scope = undefined; var ls: js.Local.Scope = undefined;
page.js.localScope(&ls); page.js.localScope(&ls);
@@ -520,7 +459,7 @@ fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar
const ClickParams = struct { const ClickParams = struct {
backendNodeId: CDPNode.Id, backendNodeId: CDPNode.Id,
}; };
const args = try parseArguments(ClickParams, arena, arguments, server, id, "click"); const args = try parseArgs(ClickParams, arena, arguments, server, id, "click");
const page = server.session.currentPage() orelse { const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded"); return server.sendError(id, .PageNotLoaded, "Page not loaded");
@@ -552,7 +491,7 @@ fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
backendNodeId: CDPNode.Id, backendNodeId: CDPNode.Id,
text: []const u8, text: []const u8,
}; };
const args = try parseArguments(FillParams, arena, arguments, server, id, "fill"); const args = try parseArgs(FillParams, arena, arguments, server, id, "fill");
const page = server.session.currentPage() orelse { const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded"); return server.sendError(id, .PageNotLoaded, "Page not loaded");
@@ -586,7 +525,7 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a
x: ?i32 = null, x: ?i32 = null,
y: ?i32 = null, y: ?i32 = null,
}; };
const args = try parseArguments(ScrollParams, arena, arguments, server, id, "scroll"); const args = try parseArgs(ScrollParams, arena, arguments, server, id, "scroll");
const page = server.session.currentPage() orelse { const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded"); return server.sendError(id, .PageNotLoaded, "Page not loaded");
@@ -623,7 +562,7 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json
selector: [:0]const u8, selector: [:0]const u8,
timeout: ?u32 = null, timeout: ?u32 = null,
}; };
const args = try parseArguments(WaitParams, arena, arguments, server, id, "waitForSelector"); const args = try parseArgs(WaitParams, arena, arguments, server, id, "waitForSelector");
_ = server.session.currentPage() orelse { _ = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded"); return server.sendError(id, .PageNotLoaded, "Page not loaded");
@@ -647,7 +586,31 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json
return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
} }
fn parseArguments(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T { fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8) !*lp.Page {
if (url) |u| {
try performGoto(server, u, id);
}
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 or invalid, it returns a default-initialized `T` (e.g., `.{}`).
/// Use this for tools where all arguments are optional and validation failures should be silently ignored.
fn parseArgsOrDefault(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) T {
if (arguments) |args_raw| {
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch .{};
}
return .{};
}
/// 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 {
if (arguments == null) { if (arguments == null) {
try server.sendError(id, .InvalidParams, "Missing arguments"); try server.sendError(id, .InvalidParams, "Missing arguments");
return error.InvalidParams; return error.InvalidParams;
@@ -664,7 +627,10 @@ fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void {
if (session.page != null) { if (session.page != null) {
session.removePage(); session.removePage();
} }
const page = try session.createPage(); const page = session.createPage() catch {
try server.sendError(id, .InternalError, "Failed to create page");
return error.NavigationFailed;
};
page.navigate(url, .{ page.navigate(url, .{
.reason = .address_bar, .reason = .address_bar,
.kind = .{ .push = null }, .kind = .{ .push = null },
@@ -673,8 +639,14 @@ fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void {
return error.NavigationFailed; return error.NavigationFailed;
}; };
var runner = try session.runner(.{}); var runner = session.runner(.{}) catch {
try runner.wait(.{ .ms = 2000 }); try server.sendError(id, .InternalError, "Failed to start page runner");
return error.NavigationFailed;
};
runner.wait(.{ .ms = 2000 }) catch {
try server.sendError(id, .InternalError, "Timeout waiting for page load");
return error.NavigationFailed;
};
} }
const router = @import("router.zig"); const router = @import("router.zig");
@@ -855,3 +827,12 @@ fn testLoadPage(url: [:0]const u8, writer: *std.Io.Writer) !*Server {
try runner.wait(.{ .ms = 2000 }); try runner.wait(.{ .ms = 2000 });
return server; return server;
} }
errdefer server.deinit();
const page = try server.session.createPage();
try page.navigate(url, .{});
var runner = try server.session.runner(.{});
try runner.wait(.{ .ms = 2000 });
return server;
}