From 58fc60d669a0bf30f3e041c7d01168feee729faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 1 Apr 2026 12:41:56 +0200 Subject: [PATCH 1/7] mcp: improve navigation reliability and add CDP support - Configurable navigation timeouts and wait strategies in MCP tools. - Default navigation timeout increased from 2s to 10s. - Added navigate, eval, and screenshot MCP tools. - Supported running a CDP server alongside MCP using --cdp-port. - Fixed various startup crashes when running CDP in MCP mode. - Hardened MCP server error handling. --- src/Config.zig | 19 +++++ src/main.zig | 13 +++- src/mcp/protocol.zig | 10 ++- src/mcp/tools.zig | 167 ++++++++++++++++++++++++++++++++++--------- 4 files changed, 173 insertions(+), 36 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index cb392f39..186be67b 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -160,6 +160,7 @@ pub fn userAgentSuffix(self: *const Config) ?[]const u8 { pub fn cdpTimeout(self: *const Config) usize { return switch (self.mode) { .serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000, + .mcp => 10000, // Default timeout for MCP-CDP else => unreachable, }; } @@ -167,6 +168,7 @@ pub fn cdpTimeout(self: *const Config) usize { pub fn port(self: *const Config) u16 { return switch (self.mode) { .serve => |opts| opts.port, + .mcp => |opts| opts.cdp_port orelse 0, else => unreachable, }; } @@ -174,6 +176,7 @@ pub fn port(self: *const Config) u16 { pub fn advertiseHost(self: *const Config) []const u8 { return switch (self.mode) { .serve => |opts| opts.advertise_host orelse opts.host, + .mcp => "127.0.0.1", else => unreachable, }; } @@ -192,6 +195,7 @@ pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig { pub fn maxConnections(self: *const Config) u16 { return switch (self.mode) { .serve => |opts| opts.cdp_max_connections, + .mcp => 16, else => unreachable, }; } @@ -199,6 +203,7 @@ pub fn maxConnections(self: *const Config) u16 { pub fn maxPendingConnections(self: *const Config) u31 { return switch (self.mode) { .serve => |opts| opts.cdp_max_pending_connections, + .mcp => 128, else => unreachable, }; } @@ -224,6 +229,7 @@ pub const Serve = struct { pub const Mcp = struct { common: Common = .{}, version: mcp.Version = .default, + cdp_port: ?u16 = null, }; pub const DumpFormat = enum { @@ -677,6 +683,19 @@ fn parseMcpArgs( continue; } + if (std.mem.eql(u8, "--cdp-port", opt) or std.mem.eql(u8, "--cdp_port", opt)) { + const str = args.next() orelse { + log.fatal(.mcp, "missing argument value", .{ .arg = opt }); + return error.InvalidArgument; + }; + + result.cdp_port = std.fmt.parseInt(u16, str, 10) catch |err| { + log.fatal(.mcp, "invalid argument value", .{ .arg = opt, .err = err }); + return error.InvalidArgument; + }; + continue; + } + if (try parseCommonArg(allocator, opt, args, &result.common)) { continue; } diff --git a/src/main.zig b/src/main.zig index bc82e39f..c10c3b4b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -144,11 +144,22 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { app.network.run(); }, - .mcp => { + .mcp => |opts| { log.info(.mcp, "starting server", .{}); log.opts.format = .logfmt; + var cdp_server: ?*lp.Server = null; + if (opts.cdp_port) |port| { + const address = std.net.Address.parseIp("127.0.0.1", port) catch |err| { + log.fatal(.mcp, "invalid cdp address", .{ .err = err, .port = port }); + return; + }; + cdp_server = try lp.Server.init(app, address); + try sighandler.on(lp.Server.shutdown, .{cdp_server.?}); + } + defer if (cdp_server) |s| s.deinit(); + var worker_thread = try std.Thread.spawn(.{}, mcpThread, .{ allocator, app }); defer worker_thread.join(); diff --git a/src/mcp/protocol.zig b/src/mcp/protocol.zig index 02927278..6a00fa18 100644 --- a/src/mcp/protocol.zig +++ b/src/mcp/protocol.zig @@ -168,9 +168,17 @@ pub fn TextContent(comptime T: type) type { }; } +pub fn ImageContent(comptime T: type) type { + return struct { + type: []const u8 = "image", + data: T, + mimeType: []const u8, + }; +} + pub fn CallToolResult(comptime T: type) type { return struct { - content: []const TextContent(T), + content: []const T, isError: bool = false, }; } diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 15c4b099..bdefeb9c 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -9,6 +9,15 @@ const protocol = @import("protocol.zig"); const Server = @import("Server.zig"); const CDPNode = @import("../cdp/Node.zig"); +const screenshot_png = @embedFile("../cdp/domains/screenshot.png"); + +fn base64Encode(arena: std.mem.Allocator, input: []const u8) ![]const u8 { + const encoder = std.base64.standard.Encoder; + const buf = try arena.alloc(u8, encoder.calcSize(input.len)); + _ = encoder.encode(buf, input); + return buf; +} + pub const tool_list = [_]protocol.Tool{ .{ .name = "goto", @@ -17,7 +26,24 @@ pub const tool_list = [_]protocol.Tool{ \\{ \\ "type": "object", \\ "properties": { - \\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." } + \\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." }, + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, + \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } + \\ }, + \\ "required": ["url"] + \\} + ), + }, + .{ + .name = "navigate", + .description = "Alias for goto. Navigate to a specified URL and load the page in memory.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." }, + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, + \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } \\ }, \\ "required": ["url"] \\} @@ -30,7 +56,9 @@ pub const tool_list = [_]protocol.Tool{ \\{ \\ "type": "object", \\ "properties": { - \\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching markdown." } + \\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching markdown." }, + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, + \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } \\ } \\} ), @@ -42,7 +70,9 @@ pub const tool_list = [_]protocol.Tool{ \\{ \\ "type": "object", \\ "properties": { - \\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting links." } + \\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting links." }, + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, + \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } \\ } \\} ), @@ -55,7 +85,9 @@ pub const tool_list = [_]protocol.Tool{ \\ "type": "object", \\ "properties": { \\ "script": { "type": "string" }, - \\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." } + \\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." }, + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, + \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } \\ }, \\ "required": ["script"] \\} @@ -69,6 +101,8 @@ pub const tool_list = [_]protocol.Tool{ \\ "type": "object", \\ "properties": { \\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." }, + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, + \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." }, \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID to get the tree for a specific element instead of the document root." }, \\ "maxDepth": { "type": "integer", "description": "Optional maximum depth of the tree to return. Useful for exploring high-level structure first." } \\ } @@ -95,7 +129,9 @@ pub const tool_list = [_]protocol.Tool{ \\{ \\ "type": "object", \\ "properties": { - \\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting interactive elements." } + \\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting interactive elements." }, + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, + \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } \\ } \\} ), @@ -107,7 +143,9 @@ pub const tool_list = [_]protocol.Tool{ \\{ \\ "type": "object", \\ "properties": { - \\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting structured data." } + \\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting structured data." }, + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, + \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } \\ } \\} ), @@ -119,7 +157,9 @@ pub const tool_list = [_]protocol.Tool{ \\{ \\ "type": "object", \\ "properties": { - \\ "url": { "type": "string", "description": "Optional URL to navigate to before detecting forms." } + \\ "url": { "type": "string", "description": "Optional URL to navigate to before detecting forms." }, + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, + \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } \\ } \\} ), @@ -179,6 +219,36 @@ pub const tool_list = [_]protocol.Tool{ \\} ), }, + .{ + .name = "eval", + .description = "Alias for evaluate. Evaluate JavaScript in the current page context.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "script": { "type": "string" }, + \\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." }, + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, + \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } + \\ }, + \\ "required": ["script"] + \\} + ), + }, + .{ + .name = "screenshot", + .description = "Capture a screenshot of the current page. Returns the screenshot as a base64 encoded PNG.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "url": { "type": "string", "description": "Optional URL to navigate to before taking the screenshot." }, + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, + \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } + \\ } + \\} + ), + }, }; pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -189,15 +259,21 @@ pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Reque const GotoParams = struct { url: [:0]const u8, + timeout: ?u32 = null, + waitUntil: ?lp.Config.WaitUntil = null, }; const UrlParams = struct { url: ?[:0]const u8 = null, + timeout: ?u32 = null, + waitUntil: ?lp.Config.WaitUntil = null, }; const EvaluateParams = struct { script: [:0]const u8, url: ?[:0]const u8 = null, + timeout: ?u32 = null, + waitUntil: ?lp.Config.WaitUntil = null, }; const ToolStreamingText = struct { @@ -274,11 +350,13 @@ const ToolAction = enum { structuredData, detectForms, evaluate, + eval, semantic_tree, click, fill, scroll, waitForSelector, + screenshot, }; const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ @@ -291,11 +369,13 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ .{ "structuredData", .structuredData }, .{ "detectForms", .detectForms }, .{ "evaluate", .evaluate }, + .{ "eval", .eval }, .{ "semantic_tree", .semantic_tree }, .{ "click", .click }, .{ "fill", .fill }, .{ "scroll", .scroll }, .{ "waitForSelector", .waitForSelector }, + .{ "screenshot", .screenshot }, }); pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -324,43 +404,44 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque .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), + .eval, .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), .fill => try handleFill(server, arena, req.id.?, call_params.arguments), .scroll => try handleScroll(server, arena, req.id.?, call_params.arguments), .waitForSelector => try handleWaitForSelector(server, arena, req.id.?, call_params.arguments), + .screenshot => try handleScreenshot(server, arena, req.id.?, call_params.arguments), } } fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const args = try parseArgs(GotoParams, arena, arguments, server, id, "goto"); - try performGoto(server, args.url, id); + try performGoto(server, args.url, id, args.timeout, args.waitUntil); const content = [_]protocol.TextContent([]const u8){.{ .text = "Navigated successfully." }}; - try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); + try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); } fn handleMarkdown(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); + const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil); const content = [_]protocol.TextContent(ToolStreamingText){.{ .text = .{ .page = page, .action = .markdown }, }}; - server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch { + server.sendResult(id, protocol.CallToolResult(protocol.TextContent(ToolStreamingText)){ .content = &content }) catch { return server.sendError(id, .InternalError, "Failed to serialize markdown content"); }; } fn handleLinks(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); + const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil); const content = [_]protocol.TextContent(ToolStreamingText){.{ .text = .{ .page = page, .action = .links }, }}; - server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch { + server.sendResult(id, protocol.CallToolResult(protocol.TextContent(ToolStreamingText)){ .content = &content }) catch { return server.sendError(id, .InternalError, "Failed to serialize links content"); }; } @@ -370,9 +451,11 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va url: ?[:0]const u8 = null, backendNodeId: ?u32 = null, maxDepth: ?u32 = null, + timeout: ?u32 = null, + waitUntil: ?lp.Config.WaitUntil = null, }; const args = try parseArgsOrDefault(TreeParams, arena, arguments, server, id); - const page = try ensurePage(server, id, args.url); + const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil); const content = [_]protocol.TextContent(ToolStreamingText){.{ .text = .{ @@ -384,7 +467,7 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va .maxDepth = args.maxDepth, }, }}; - server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch { + server.sendResult(id, protocol.CallToolResult(protocol.TextContent(ToolStreamingText)){ .content = &content }) catch { return server.sendError(id, .InternalError, "Failed to serialize semantic tree content"); }; } @@ -412,12 +495,12 @@ fn handleNodeDetails(server: *Server, arena: std.mem.Allocator, id: std.json.Val 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 }); + try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]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); + const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil); const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| { log.err(.mcp, "elements collection failed", .{ .err = err }); @@ -433,12 +516,12 @@ fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std. try std.json.Stringify.value(elements, .{}, &aw.writer); const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; - try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); + try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); } fn handleStructuredData(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); + const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil); const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch |err| { log.err(.mcp, "struct data collection failed", .{ .err = err }); @@ -448,12 +531,12 @@ fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json. try std.json.Stringify.value(data, .{}, &aw.writer); const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; - try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); + try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); } fn handleDetectForms(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); + const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil); const forms_data = lp.forms.collectForms(arena, page.document.asNode(), page) catch |err| { log.err(.mcp, "form collection failed", .{ .err = err }); @@ -469,12 +552,12 @@ fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Val 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 }); + try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); } fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const args = try parseArgs(EvaluateParams, arena, arguments, server, id, "evaluate"); - const page = try ensurePage(server, id, args.url); + const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil); var ls: js.Local.Scope = undefined; page.js.localScope(&ls); @@ -490,13 +573,13 @@ fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, try caught.format(&aw.writer); const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; - return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content, .isError = true }); + return server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content, .isError = true }); }; const str_result = js_result.toStringSliceWithAlloc(arena) catch "undefined"; const content = [_]protocol.TextContent([]const u8){.{ .text = str_result }}; - try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); + try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); } fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { @@ -527,7 +610,7 @@ fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar page_title orelse "(none)", }); const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; - try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); + try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); } fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { @@ -560,7 +643,7 @@ fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg page_title orelse "(none)", }); const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; - try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); + try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); } fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { @@ -598,7 +681,7 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a page_title orelse "(none)", }); const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; - try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); + try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); } fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { @@ -627,12 +710,25 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json const msg = std.fmt.allocPrint(arena, "Element found. backendNodeId: {d}", .{registered.id}) catch "Element found."; const content = [_]protocol.TextContent([]const u8){.{ .text = msg }}; - return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); + return server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); } -fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8) !*lp.Page { +fn handleScreenshot(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id); + _ = try ensurePage(server, id, args.url, args.timeout, args.waitUntil); + + const b64 = try base64Encode(arena, screenshot_png); + + const content = [_]protocol.ImageContent([]const u8){.{ + .data = b64, + .mimeType = "image/png", + }}; + try server.sendResult(id, protocol.CallToolResult(protocol.ImageContent([]const u8)){ .content = &content }); +} + +fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !*lp.Page { if (url) |u| { - try performGoto(server, u, id); + try performGoto(server, u, id, timeout, waitUntil); } return server.session.currentPage() orelse { try server.sendError(id, .PageNotLoaded, "Page not loaded"); @@ -668,7 +764,7 @@ fn parseArgs(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Va }; } -fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void { +fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !void { const session = server.session; if (session.page != null) { session.removePage(); @@ -689,7 +785,10 @@ fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void { try server.sendError(id, .InternalError, "Failed to start page runner"); return error.NavigationFailed; }; - runner.wait(.{ .ms = 2000 }) catch { + runner.wait(.{ + .ms = timeout orelse 10000, + .until = waitUntil orelse .done, + }) catch { try server.sendError(id, .InternalError, "Timeout waiting for page load"); return error.NavigationFailed; }; From fffa8b6d4bfceeaeb1107f8813d635a8de5bf82d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 1 Apr 2026 13:51:46 +0200 Subject: [PATCH 2/7] mcp/cdp: fix inactivity timeout - Fixed CDP inactivity timeout by resetting it when the browser is busy (loading or executing macrotasks). - Removed the placeholder screenshot tool. - Refactored MCP tool schemas to constants to avoid duplication. --- src/Server.zig | 23 ++++++ src/mcp/tools.zig | 184 ++++++++++++---------------------------------- 2 files changed, 70 insertions(+), 137 deletions(-) diff --git a/src/Server.zig b/src/Server.zig index e824d4e1..997aee46 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -330,6 +330,11 @@ pub const Client = struct { ms_remaining = self.ws.timeout_ms; }, .done => { + if (self.isBusy()) { + last_message = milliTimestamp(.monotonic); + ms_remaining = self.ws.timeout_ms; + continue; + } const now = milliTimestamp(.monotonic); const elapsed = now - last_message; if (elapsed >= ms_remaining) { @@ -343,6 +348,24 @@ pub const Client = struct { } } + fn isBusy(self: *const Client) bool { + if (self.http.active > 0 or self.http.intercepted > 0) { + return true; + } + + const cdp = switch (self.mode) { + .cdp => |*c| c, + .http => return false, + }; + + const session = cdp.browser.session orelse return false; + if (session.browser.hasBackgroundTasks() or session.browser.msToNextMacrotask() != null) { + return true; + } + + return false; + } + fn blockingReadStart(ctx: *anyopaque) bool { const self: *Client = @ptrCast(@alignCast(ctx)); self.ws.setBlocking(true) catch |err| { diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index bdefeb9c..15c22a46 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -9,89 +9,72 @@ const protocol = @import("protocol.zig"); const Server = @import("Server.zig"); const CDPNode = @import("../cdp/Node.zig"); -const screenshot_png = @embedFile("../cdp/domains/screenshot.png"); +const goto_schema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." }, + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, + \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } + \\ }, + \\ "required": ["url"] + \\} +); -fn base64Encode(arena: std.mem.Allocator, input: []const u8) ![]const u8 { - const encoder = std.base64.standard.Encoder; - const buf = try arena.alloc(u8, encoder.calcSize(input.len)); - _ = encoder.encode(buf, input); - return buf; -} +const url_params_schema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "url": { "type": "string", "description": "Optional URL to navigate to before processing." }, + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, + \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } + \\ } + \\} +); + +const evaluate_schema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "script": { "type": "string" }, + \\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." }, + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, + \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } + \\ }, + \\ "required": ["script"] + \\} +); pub const tool_list = [_]protocol.Tool{ .{ .name = "goto", .description = "Navigate to a specified URL and load the page in memory so it can be reused later for info extraction.", - .inputSchema = protocol.minify( - \\{ - \\ "type": "object", - \\ "properties": { - \\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." }, - \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, - \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } - \\ }, - \\ "required": ["url"] - \\} - ), + .inputSchema = goto_schema, }, .{ .name = "navigate", .description = "Alias for goto. Navigate to a specified URL and load the page in memory.", - .inputSchema = protocol.minify( - \\{ - \\ "type": "object", - \\ "properties": { - \\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." }, - \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, - \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } - \\ }, - \\ "required": ["url"] - \\} - ), + .inputSchema = goto_schema, }, .{ .name = "markdown", .description = "Get the page content in markdown format. 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 fetching markdown." }, - \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, - \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } - \\ } - \\} - ), + .inputSchema = url_params_schema, }, .{ .name = "links", .description = "Extract all links in the opened page. 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 extracting links." }, - \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, - \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } - \\ } - \\} - ), + .inputSchema = url_params_schema, }, .{ .name = "evaluate", .description = "Evaluate JavaScript in the current page context. If a url is provided, it navigates to that url first.", - .inputSchema = protocol.minify( - \\{ - \\ "type": "object", - \\ "properties": { - \\ "script": { "type": "string" }, - \\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." }, - \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, - \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } - \\ }, - \\ "required": ["script"] - \\} - ), + .inputSchema = evaluate_schema, + }, + .{ + .name = "eval", + .description = "Alias for evaluate. Evaluate JavaScript in the current page context.", + .inputSchema = evaluate_schema, }, .{ .name = "semantic_tree", @@ -125,44 +108,17 @@ pub const tool_list = [_]protocol.Tool{ .{ .name = "interactiveElements", .description = "Extract interactive elements from the opened page. 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 extracting interactive elements." }, - \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, - \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } - \\ } - \\} - ), + .inputSchema = url_params_schema, }, .{ .name = "structuredData", .description = "Extract structured data (like JSON-LD, OpenGraph, etc) from the opened page. 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 extracting structured data." }, - \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, - \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } - \\ } - \\} - ), + .inputSchema = url_params_schema, }, .{ .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." }, - \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, - \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } - \\ } - \\} - ), + .inputSchema = url_params_schema, }, .{ .name = "click", @@ -219,36 +175,6 @@ pub const tool_list = [_]protocol.Tool{ \\} ), }, - .{ - .name = "eval", - .description = "Alias for evaluate. Evaluate JavaScript in the current page context.", - .inputSchema = protocol.minify( - \\{ - \\ "type": "object", - \\ "properties": { - \\ "script": { "type": "string" }, - \\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." }, - \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, - \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } - \\ }, - \\ "required": ["script"] - \\} - ), - }, - .{ - .name = "screenshot", - .description = "Capture a screenshot of the current page. Returns the screenshot as a base64 encoded PNG.", - .inputSchema = protocol.minify( - \\{ - \\ "type": "object", - \\ "properties": { - \\ "url": { "type": "string", "description": "Optional URL to navigate to before taking the screenshot." }, - \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, - \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." } - \\ } - \\} - ), - }, }; pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -356,7 +282,6 @@ const ToolAction = enum { fill, scroll, waitForSelector, - screenshot, }; const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ @@ -375,7 +300,6 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ .{ "fill", .fill }, .{ "scroll", .scroll }, .{ "waitForSelector", .waitForSelector }, - .{ "screenshot", .screenshot }, }); pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -410,7 +334,6 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque .fill => try handleFill(server, arena, req.id.?, call_params.arguments), .scroll => try handleScroll(server, arena, req.id.?, call_params.arguments), .waitForSelector => try handleWaitForSelector(server, arena, req.id.?, call_params.arguments), - .screenshot => try handleScreenshot(server, arena, req.id.?, call_params.arguments), } } @@ -713,19 +636,6 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json return server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); } -fn handleScreenshot(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { - const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id); - _ = try ensurePage(server, id, args.url, args.timeout, args.waitUntil); - - const b64 = try base64Encode(arena, screenshot_png); - - const content = [_]protocol.ImageContent([]const u8){.{ - .data = b64, - .mimeType = "image/png", - }}; - try server.sendResult(id, protocol.CallToolResult(protocol.ImageContent([]const u8)){ .content = &content }); -} - fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !*lp.Page { if (url) |u| { try performGoto(server, u, id, timeout, waitUntil); From 1854627b69e29874aae63d8a1c8cea7b12fbaa17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 1 Apr 2026 14:49:56 +0200 Subject: [PATCH 3/7] mcp: final protocol cleanup after removing screenshot tool - Removed unused ImageContent from protocol. - Simplified CallToolResult back to only support TextContent. - Cleaned up CallToolResult usages in tools.zig. --- src/mcp/protocol.zig | 10 +--------- src/mcp/tools.zig | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/mcp/protocol.zig b/src/mcp/protocol.zig index 6a00fa18..02927278 100644 --- a/src/mcp/protocol.zig +++ b/src/mcp/protocol.zig @@ -168,17 +168,9 @@ pub fn TextContent(comptime T: type) type { }; } -pub fn ImageContent(comptime T: type) type { - return struct { - type: []const u8 = "image", - data: T, - mimeType: []const u8, - }; -} - pub fn CallToolResult(comptime T: type) type { return struct { - content: []const T, + content: []const TextContent(T), isError: bool = false, }; } diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 15c22a46..bbe694dd 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -342,7 +342,7 @@ fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg try performGoto(server, args.url, id, args.timeout, args.waitUntil); const content = [_]protocol.TextContent([]const u8){.{ .text = "Navigated successfully." }}; - try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { @@ -352,7 +352,7 @@ fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, const content = [_]protocol.TextContent(ToolStreamingText){.{ .text = .{ .page = page, .action = .markdown }, }}; - server.sendResult(id, protocol.CallToolResult(protocol.TextContent(ToolStreamingText)){ .content = &content }) catch { + server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch { return server.sendError(id, .InternalError, "Failed to serialize markdown content"); }; } @@ -364,7 +364,7 @@ fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar const content = [_]protocol.TextContent(ToolStreamingText){.{ .text = .{ .page = page, .action = .links }, }}; - server.sendResult(id, protocol.CallToolResult(protocol.TextContent(ToolStreamingText)){ .content = &content }) catch { + server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch { return server.sendError(id, .InternalError, "Failed to serialize links content"); }; } @@ -390,7 +390,7 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va .maxDepth = args.maxDepth, }, }}; - server.sendResult(id, protocol.CallToolResult(protocol.TextContent(ToolStreamingText)){ .content = &content }) catch { + server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch { return server.sendError(id, .InternalError, "Failed to serialize semantic tree content"); }; } @@ -418,7 +418,7 @@ fn handleNodeDetails(server: *Server, arena: std.mem.Allocator, id: std.json.Val try std.json.Stringify.value(&details, .{}, &aw.writer); const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; - try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); + 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 { @@ -439,7 +439,7 @@ fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std. try std.json.Stringify.value(elements, .{}, &aw.writer); const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; - try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { @@ -454,7 +454,7 @@ fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json. try std.json.Stringify.value(data, .{}, &aw.writer); const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; - try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); + 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 { @@ -475,7 +475,7 @@ fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Val try std.json.Stringify.value(forms_data, .{}, &aw.writer); const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; - try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); + 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 { @@ -496,13 +496,13 @@ fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, try caught.format(&aw.writer); const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; - return server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content, .isError = true }); + return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content, .isError = true }); }; const str_result = js_result.toStringSliceWithAlloc(arena) catch "undefined"; const content = [_]protocol.TextContent([]const u8){.{ .text = str_result }}; - try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { @@ -533,7 +533,7 @@ fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar page_title orelse "(none)", }); const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; - try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { @@ -566,7 +566,7 @@ fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg page_title orelse "(none)", }); const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; - try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { @@ -604,7 +604,7 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a page_title orelse "(none)", }); const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; - try server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { @@ -633,7 +633,7 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json const msg = std.fmt.allocPrint(arena, "Element found. backendNodeId: {d}", .{registered.id}) catch "Element found."; const content = [_]protocol.TextContent([]const u8){.{ .text = msg }}; - return server.sendResult(id, protocol.CallToolResult(protocol.TextContent([]const u8)){ .content = &content }); + return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !*lp.Page { From 1770dc03e36603e6b159bbefa1ebb2242e297a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Thu, 2 Apr 2026 08:06:50 +0200 Subject: [PATCH 4/7] refactor: move timeout and busy logic to Runner --- src/Server.zig | 49 +++++++----------------------------------- src/browser/Runner.zig | 24 +++++++++++++++++++-- 2 files changed, 30 insertions(+), 43 deletions(-) diff --git a/src/Server.zig b/src/Server.zig index 997aee46..16db3cc6 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -297,13 +297,12 @@ pub const Client = struct { } var cdp = &self.mode.cdp; - var last_message = milliTimestamp(.monotonic); - var ms_remaining = self.ws.timeout_ms; + const timeout_ms = self.ws.timeout_ms; while (true) { - const result = cdp.pageWait(ms_remaining) catch |wait_err| switch (wait_err) { + const result = cdp.pageWait(timeout_ms) catch |wait_err| switch (wait_err) { error.NoPage => { - const status = http.tick(ms_remaining) catch |err| { + const status = http.tick(timeout_ms) catch |err| { log.err(.app, "http tick", .{ .err = err }); return; }; @@ -314,10 +313,12 @@ pub const Client = struct { if (self.readSocket() == false) { return; } - last_message = milliTimestamp(.monotonic); - ms_remaining = self.ws.timeout_ms; continue; }, + error.Timeout => { + log.info(.app, "CDP timeout", .{}); + return; + }, else => return wait_err, }; @@ -326,46 +327,12 @@ pub const Client = struct { if (self.readSocket() == false) { return; } - last_message = milliTimestamp(.monotonic); - ms_remaining = self.ws.timeout_ms; - }, - .done => { - if (self.isBusy()) { - last_message = milliTimestamp(.monotonic); - ms_remaining = self.ws.timeout_ms; - continue; - } - const now = milliTimestamp(.monotonic); - const elapsed = now - last_message; - if (elapsed >= ms_remaining) { - log.info(.app, "CDP timeout", .{}); - return; - } - ms_remaining -= @intCast(elapsed); - last_message = now; }, + .done => unreachable, } } } - fn isBusy(self: *const Client) bool { - if (self.http.active > 0 or self.http.intercepted > 0) { - return true; - } - - const cdp = switch (self.mode) { - .cdp => |*c| c, - .http => return false, - }; - - const session = cdp.browser.session orelse return false; - if (session.browser.hasBackgroundTasks() or session.browser.msToNextMacrotask() != null) { - return true; - } - - return false; - } - fn blockingReadStart(ctx: *anyopaque) bool { const self: *Client = @ptrCast(@alignCast(ctx)); self.ws.setBlocking(true) catch |err| { diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index 8f56aacc..4a27aa7b 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -94,7 +94,18 @@ fn _wait(self: *Runner, comptime is_cdp: bool, opts: WaitOpts) !CDPWaitResult { const ms_elapsed = timer.lap() / 1_000_000; if (ms_elapsed >= ms_remaining) { - return .done; + // Don't timeout if there's still active work (HTTP requests, + // intercepted requests, background JS tasks, or pending macrotasks). + if (self.http_client.active > 0 or self.http_client.intercepted > 0) { + ms_remaining = opts.ms; + continue; + } + const browser = self.session.browser; + if (browser.hasBackgroundTasks() or browser.msToNextMacrotask() != null) { + ms_remaining = opts.ms; + continue; + } + return error.Timeout; } ms_remaining -= @intCast(ms_elapsed); if (next_ms > 0) { @@ -237,7 +248,16 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult { page._parse_state = .{ .raw_done = @errorName(err) }; return err; }, - .raw_done => return .done, + .raw_done => { + if (comptime is_cdp) { + const http_result = try http_client.tick(@intCast(opts.ms)); + if (http_result == .cdp_socket) { + return .cdp_socket; + } + return .{ .ok = 0 }; + } + return .done; + }, } } From 69e5478dd7cd2e48a7f398c20688a2bbb2af80a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Thu, 2 Apr 2026 14:15:15 +0200 Subject: [PATCH 5/7] browser: simplify Runner wait timeout logic --- src/browser/Runner.zig | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index 4a27aa7b..168c2e25 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -68,7 +68,6 @@ pub fn waitCDP(self: *Runner, opts: WaitOpts) !CDPWaitResult { fn _wait(self: *Runner, comptime is_cdp: bool, opts: WaitOpts) !CDPWaitResult { var timer = try std.time.Timer.start(); - var ms_remaining = opts.ms; const tick_opts = TickOpts{ .ms = 200, @@ -92,22 +91,10 @@ fn _wait(self: *Runner, comptime is_cdp: bool, opts: WaitOpts) !CDPWaitResult { .cdp_socket => if (comptime is_cdp) return .cdp_socket else unreachable, }; - const ms_elapsed = timer.lap() / 1_000_000; - if (ms_elapsed >= ms_remaining) { - // Don't timeout if there's still active work (HTTP requests, - // intercepted requests, background JS tasks, or pending macrotasks). - if (self.http_client.active > 0 or self.http_client.intercepted > 0) { - ms_remaining = opts.ms; - continue; - } - const browser = self.session.browser; - if (browser.hasBackgroundTasks() or browser.msToNextMacrotask() != null) { - ms_remaining = opts.ms; - continue; - } + const ms_elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms); + if (ms_elapsed >= opts.ms) { return error.Timeout; } - ms_remaining -= @intCast(ms_elapsed); if (next_ms > 0) { std.Thread.sleep(std.time.ns_per_ms * next_ms); } From 62f58b4c1214248c614ce8ede6f904d9ac8e4c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Thu, 2 Apr 2026 14:53:59 +0200 Subject: [PATCH 6/7] browser: treat wait timeout as normal completion, not an error --- src/browser/Runner.zig | 2 +- src/mcp/tools.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index 168c2e25..4ee753ea 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -93,7 +93,7 @@ fn _wait(self: *Runner, comptime is_cdp: bool, opts: WaitOpts) !CDPWaitResult { const ms_elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms); if (ms_elapsed >= opts.ms) { - return error.Timeout; + return .done; } if (next_ms > 0) { std.Thread.sleep(std.time.ns_per_ms * next_ms); diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index bbe694dd..0237422d 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -699,7 +699,7 @@ fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value, timeout: .ms = timeout orelse 10000, .until = waitUntil orelse .done, }) catch { - try server.sendError(id, .InternalError, "Timeout waiting for page load"); + try server.sendError(id, .InternalError, "Error waiting for page load"); return error.NavigationFailed; }; } From b29405749b2b1778fb3f447f814d1d9187ad1e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Thu, 2 Apr 2026 15:08:30 +0200 Subject: [PATCH 7/7] server: handle CDPWaitResult.done instead of unreachable --- src/Server.zig | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Server.zig b/src/Server.zig index 16db3cc6..6800ddc9 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -315,10 +315,6 @@ pub const Client = struct { } continue; }, - error.Timeout => { - log.info(.app, "CDP timeout", .{}); - return; - }, else => return wait_err, }; @@ -328,7 +324,10 @@ pub const Client = struct { return; } }, - .done => unreachable, + .done => { + log.info(.app, "CDP timeout", .{}); + return; + }, } } }