diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig index 11af72cc..48cf5f2e 100644 --- a/src/mcp/Server.zig +++ b/src/mcp/Server.zig @@ -12,7 +12,7 @@ app: *App, http_client: *HttpClient, notification: *lp.Notification, -browser: lp.Browser, +browser: *lp.Browser, session: *lp.Session, page: *lp.Page, @@ -53,12 +53,12 @@ pub fn deinit(self: *Self) void { self.allocator.destroy(self); } -pub fn sendResponse(_: *Self, response: anytype) !void { - var buffer: [8192]u8 = undefined; - var stdout = std.fs.File.stdout().writer(&buffer); - try std.json.Stringify.value(response, .{ .emit_null_optional_fields = false }, &stdout.interface); - try stdout.interface.writeByte('\n'); - try stdout.interface.flush(); +pub fn sendResponse(self: *Self, response: anytype) !void { + var aw: std.Io.Writer.Allocating = .init(self.allocator); + defer aw.deinit(); + try std.json.Stringify.value(response, .{ .emit_null_optional_fields = false }, &aw.writer); + try aw.writer.writeByte('\n'); + try std.fs.File.stdout().writeAll(aw.written()); } pub fn sendResult(self: *Self, id: std.json.Value, result: anytype) !void { diff --git a/src/mcp/router.zig b/src/mcp/router.zig index a791ba58..f883027c 100644 --- a/src/mcp/router.zig +++ b/src/mcp/router.zig @@ -17,24 +17,36 @@ pub fn processRequests(server: *Server) !void { const reader = poller.reader(.stdin); - var arena: std.heap.ArenaAllocator = .init(server.allocator); - defer arena.deinit(); + var arena_instance = std.heap.ArenaAllocator.init(server.allocator); + defer arena_instance.deinit(); + const arena = arena_instance.allocator(); while (server.is_running.load(.acquire)) { + // Run ready browser tasks and get time to next one const ms_to_next_task = (try server.browser.runMacrotasks()) orelse 10_000; - // Poll until the next macrotask is scheduled. This will block if no data is available. - const poll_ok = try poller.pollTimeout(ms_to_next_task * std.time.ns_per_ms); + // Keep the loop responsive to network events and stdin. + const ms_to_wait: u64 = @min(50, ms_to_next_task); + // Wait for stdin activity for up to ms_to_wait. + const poll_result = try poller.pollTimeout(ms_to_wait * @as(u64, std.time.ns_per_ms)); + + // Process any pending network I/O + _ = try server.http_client.tick(0); + + // Run V8 microtasks and internal message loop + server.browser.runMessageLoop(); + + // Process all complete lines available in the buffer while (true) { const buffered = reader.buffered(); if (std.mem.indexOfScalar(u8, buffered, '\n')) |idx| { const line = buffered[0..idx]; if (line.len > 0) { - handleMessage(server, arena.allocator(), line) catch |err| { + handleMessage(server, arena, line) catch |err| { log.warn(.mcp, "Error processing message", .{ .err = err }); }; - _ = arena.reset(.{ .retain_with_limit = 32 * 1024 }); + _ = arena_instance.reset(.{ .retain_with_limit = 32 * 1024 }); } reader.toss(idx + 1); } else { @@ -42,18 +54,14 @@ pub fn processRequests(server: *Server) !void { } } - if (!poll_ok) { - // Check if we have any data left in the buffer that didn't end with a newline + // pollTimeout returns false when all streams are closed (EOF on stdin) + if (!poll_result) { const buffered = reader.buffered(); if (buffered.len > 0) { - handleMessage(server, arena.allocator(), buffered) catch |err| { - log.warn(.mcp, "Error processing last message", .{ .err = err }); - }; + handleMessage(server, arena, buffered) catch {}; } break; } - - server.browser.runMessageLoop(); } } @@ -90,9 +98,10 @@ fn handleMessage(server: *Server, arena: std.mem.Allocator, msg: []const u8) !vo fn handleInitialize(server: *Server, req: protocol.Request) !void { const result = protocol.InitializeResult{ - .protocolVersion = "2024-11-05", + .protocolVersion = "2025-11-25", .capabilities = .{ .logging = .{}, + .prompts = .{ .listChanged = false }, .resources = .{ .subscribe = false, .listChanged = false }, .tools = .{ .listChanged = false }, }, diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 0f8a7693..b4522ff0 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -13,79 +13,32 @@ 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 = .{ .json = - \\{ - \\ "type": "object", - \\ "properties": { - \\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." } - \\ }, - \\ "required": ["url"] - \\} - }, + .inputSchema = .{ .json = "{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\",\"description\":\"The URL to navigate to, must be a valid URL.\"}},\"required\":[\"url\"]}" }, }, .{ .name = "search", .description = "Use a search engine to look for specific words, terms, sentences. The search page will then be loaded in memory.", - .inputSchema = .{ .json = - \\{ - \\ "type": "object", - \\ "properties": { - \\ "text": { "type": "string", "description": "The text to search for, must be a valid search query." } - \\ }, - \\ "required": ["text"] - \\} - }, + .inputSchema = .{ .json = "{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\",\"description\":\"The text to search for, must be a valid search query.\"}},\"required\":[\"text\"]}" }, }, .{ .name = "markdown", .description = "Get the page content in markdown format. If a url is provided, it navigates to that url first.", - .inputSchema = .{ .json = - \\{ - \\ "type": "object", - \\ "properties": { - \\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching markdown." } - \\ } - \\} - }, + .inputSchema = .{ .json = "{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\",\"description\":\"Optional URL to navigate to before fetching markdown.\"}}}" }, }, .{ .name = "links", .description = "Extract all links in the opened page. If a url is provided, it navigates to that url first.", - .inputSchema = .{ .json = - \\{ - \\ "type": "object", - \\ "properties": { - \\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting links." } - \\ } - \\} - }, + .inputSchema = .{ .json = "{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\",\"description\":\"Optional URL to navigate to before extracting links.\"}}}" }, }, .{ .name = "evaluate", .description = "Evaluate JavaScript in the current page context. If a url is provided, it navigates to that url first.", - .inputSchema = .{ .json = - \\{ - \\ "type": "object", - \\ "properties": { - \\ "script": { "type": "string" }, - \\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." } - \\ }, - \\ "required": ["script"] - \\} - }, + .inputSchema = .{ .json = "{\"type\":\"object\",\"properties\":{\"script\":{\"type\":\"string\"},\"url\":{\"type\":\"string\",\"description\":\"Optional URL to navigate to before evaluating.\"}},\"required\":[\"script\"]}" }, }, .{ .name = "over", .description = "Used to indicate that the task is over and give the final answer if there is any. This is the last tool to be called in a task.", - .inputSchema = .{ .json = - \\{ - \\ "type": "object", - \\ "properties": { - \\ "result": { "type": "string", "description": "The final result of the task." } - \\ }, - \\ "required": ["result"] - \\} - }, + .inputSchema = .{ .json = "{\"type\":\"object\",\"properties\":{\"result\":{\"type\":\"string\",\"description\":\"The final result of the task.\"}},\"required\":[\"result\"]}" }, }, };