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.
This commit is contained in:
Adrià Arrufat
2026-04-01 13:51:46 +02:00
parent 58fc60d669
commit fffa8b6d4b
2 changed files with 70 additions and 137 deletions

View File

@@ -330,6 +330,11 @@ pub const Client = struct {
ms_remaining = self.ws.timeout_ms; ms_remaining = self.ws.timeout_ms;
}, },
.done => { .done => {
if (self.isBusy()) {
last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms;
continue;
}
const now = milliTimestamp(.monotonic); const now = milliTimestamp(.monotonic);
const elapsed = now - last_message; const elapsed = now - last_message;
if (elapsed >= ms_remaining) { 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 { fn blockingReadStart(ctx: *anyopaque) bool {
const self: *Client = @ptrCast(@alignCast(ctx)); const self: *Client = @ptrCast(@alignCast(ctx));
self.ws.setBlocking(true) catch |err| { self.ws.setBlocking(true) catch |err| {

View File

@@ -9,89 +9,72 @@ const protocol = @import("protocol.zig");
const Server = @import("Server.zig"); const Server = @import("Server.zig");
const CDPNode = @import("../cdp/Node.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 url_params_schema = protocol.minify(
const encoder = std.base64.standard.Encoder; \\{
const buf = try arena.alloc(u8, encoder.calcSize(input.len)); \\ "type": "object",
_ = encoder.encode(buf, input); \\ "properties": {
return buf; \\ "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{ pub const tool_list = [_]protocol.Tool{
.{ .{
.name = "goto", .name = "goto",
.description = "Navigate to a specified URL and load the page in memory so it can be reused later for info extraction.", .description = "Navigate to a specified URL and load the page in memory so it can be reused later for info extraction.",
.inputSchema = protocol.minify( .inputSchema = goto_schema,
\\{
\\ "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"]
\\}
),
}, },
.{ .{
.name = "navigate", .name = "navigate",
.description = "Alias for goto. Navigate to a specified URL and load the page in memory.", .description = "Alias for goto. Navigate to a specified URL and load the page in memory.",
.inputSchema = protocol.minify( .inputSchema = goto_schema,
\\{
\\ "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"]
\\}
),
}, },
.{ .{
.name = "markdown", .name = "markdown",
.description = "Get the page content in markdown format. If a url is provided, it navigates to that url first.", .description = "Get the page content in markdown format. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify( .inputSchema = url_params_schema,
\\{
\\ "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'." }
\\ }
\\}
),
}, },
.{ .{
.name = "links", .name = "links",
.description = "Extract all links in the opened page. If a url is provided, it navigates to that url first.", .description = "Extract all links in the opened page. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify( .inputSchema = url_params_schema,
\\{
\\ "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'." }
\\ }
\\}
),
}, },
.{ .{
.name = "evaluate", .name = "evaluate",
.description = "Evaluate JavaScript in the current page context. If a url is provided, it navigates to that url first.", .description = "Evaluate JavaScript in the current page context. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify( .inputSchema = evaluate_schema,
\\{ },
\\ "type": "object", .{
\\ "properties": { .name = "eval",
\\ "script": { "type": "string" }, .description = "Alias for evaluate. Evaluate JavaScript in the current page context.",
\\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." }, .inputSchema = evaluate_schema,
\\ "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 = "semantic_tree", .name = "semantic_tree",
@@ -125,44 +108,17 @@ pub const tool_list = [_]protocol.Tool{
.{ .{
.name = "interactiveElements", .name = "interactiveElements",
.description = "Extract interactive elements from the opened page. If a url is provided, it navigates to that url first.", .description = "Extract interactive elements from the opened page. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify( .inputSchema = url_params_schema,
\\{
\\ "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'." }
\\ }
\\}
),
}, },
.{ .{
.name = "structuredData", .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.", .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( .inputSchema = url_params_schema,
\\{
\\ "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'." }
\\ }
\\}
),
}, },
.{ .{
.name = "detectForms", .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.", .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( .inputSchema = url_params_schema,
\\{
\\ "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'." }
\\ }
\\}
),
}, },
.{ .{
.name = "click", .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 { pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
@@ -356,7 +282,6 @@ const ToolAction = enum {
fill, fill,
scroll, scroll,
waitForSelector, waitForSelector,
screenshot,
}; };
const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
@@ -375,7 +300,6 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
.{ "fill", .fill }, .{ "fill", .fill },
.{ "scroll", .scroll }, .{ "scroll", .scroll },
.{ "waitForSelector", .waitForSelector }, .{ "waitForSelector", .waitForSelector },
.{ "screenshot", .screenshot },
}); });
pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { 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), .fill => try handleFill(server, arena, req.id.?, call_params.arguments),
.scroll => try handleScroll(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), .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 }); 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 { fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !*lp.Page {
if (url) |u| { if (url) |u| {
try performGoto(server, u, id, timeout, waitUntil); try performGoto(server, u, id, timeout, waitUntil);