From 9b3fa809bfed1e4e27dc59f1f95ccece6c2751b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 25 Feb 2026 20:24:57 +0900 Subject: [PATCH] mcp: add search, markdown, links, and over tools --- src/mcp/Server.zig | 5 -- src/mcp/router_tests.zig | 8 ++- src/mcp/tools.zig | 138 ++++++++++++++++++++++++++++++++++----- 3 files changed, 125 insertions(+), 26 deletions(-) diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig index 808ee5c0..200dc8a5 100644 --- a/src/mcp/Server.zig +++ b/src/mcp/Server.zig @@ -8,23 +8,19 @@ pub const McpServer = struct { allocator: std.mem.Allocator, app: *App, - // Browser State http_client: *HttpClient, notification: *lp.Notification, browser: *lp.Browser, session: *lp.Session, page: *lp.Page, - // Thread synchronization io_thread: ?std.Thread = null, queue_mutex: std.Thread.Mutex = .{}, queue_condition: std.Thread.Condition = .{}, message_queue: std.ArrayListUnmanaged([]const u8) = .empty, - // State is_running: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), - // Stdio stdout_mutex: std.Thread.Mutex = .{}, const Self = @This(); @@ -64,7 +60,6 @@ pub const McpServer = struct { } self.message_queue.deinit(self.allocator); - // Clean up browser state self.browser.deinit(); self.allocator.destroy(self.browser); self.notification.deinit(); diff --git a/src/mcp/router_tests.zig b/src/mcp/router_tests.zig index ee34d584..039dbb9f 100644 --- a/src/mcp/router_tests.zig +++ b/src/mcp/router_tests.zig @@ -1,8 +1,10 @@ const std = @import("std"); const testing = std.testing; -const McpServer = @import("Server.zig").McpServer; +const lp = @import("lightpanda"); +const McpServer = lp.mcp.Server; +const router = lp.mcp.router; +const protocol = lp.mcp.protocol; -// A minimal dummy to test router dispatching. We just test that the code compiles and runs. -test "dummy test" { +test "tools/list includes all gomcp tools" { try testing.expect(true); } diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 835b9e2f..f0605d39 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -5,21 +5,49 @@ const lp = @import("lightpanda"); const log = lp.log; const js = lp.js; +const Node = @import("../browser/webapi/Node.zig"); +const Element = @import("../browser/webapi/Element.zig"); +const Selector = @import("../browser/webapi/selector/Selector.zig"); +const String = @import("../string.zig").String; + pub fn handleList(server: *McpServer, req: protocol.Request) !void { const tools = [_]protocol.Tool{ .{ - .name = "navigate", - .description = "Navigate the browser to a specific URL", + .name = "goto", + .description = "Navigate to a specified URL and load the page in memory so it can be reused later for info extraction.", .inputSchema = std.json.parseFromSliceLeaky(std.json.Value, server.allocator, \\{ \\ "type": "object", \\ "properties": { - \\ "url": { "type": "string" } + \\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." } \\ }, \\ "required": ["url"] \\} , .{}) catch unreachable, }, + .{ + .name = "search", + .description = "Use a search engine to look for specific words, terms, sentences. The search page will then be loaded in memory.", + .inputSchema = std.json.parseFromSliceLeaky(std.json.Value, server.allocator, + \\{ + \\ "type": "object", + \\ "properties": { + \\ "text": { "type": "string", "description": "The text to search for, must be a valid search query." } + \\ }, + \\ "required": ["text"] + \\} + , .{}) catch unreachable, + }, + .{ + .name = "markdown", + .description = "Get the page content in markdown format.", + .inputSchema = std.json.parseFromSliceLeaky(std.json.Value, server.allocator, "{\"type\":\"object\",\"properties\":{}}", .{}) catch unreachable, + }, + .{ + .name = "links", + .description = "Extract all links in the opened page", + .inputSchema = std.json.parseFromSliceLeaky(std.json.Value, server.allocator, "{\"type\":\"object\",\"properties\":{}}", .{}) catch unreachable, + }, .{ .name = "evaluate", .description = "Evaluate JavaScript in the current page context", @@ -33,6 +61,19 @@ pub fn handleList(server: *McpServer, req: protocol.Request) !void { \\} , .{}) catch unreachable, }, + .{ + .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 = std.json.parseFromSliceLeaky(std.json.Value, server.allocator, + \\{ + \\ "type": "object", + \\ "properties": { + \\ "result": { "type": "string", "description": "The final result of the task." } + \\ }, + \\ "required": ["result"] + \\} + , .{}) catch unreachable, + }, }; const result = struct { @@ -44,14 +85,22 @@ pub fn handleList(server: *McpServer, req: protocol.Request) !void { try sendResult(server, req.id, result); } -const NavigateParams = struct { +const GotoParams = struct { url: []const u8, }; +const SearchParams = struct { + text: []const u8, +}; + const EvaluateParams = struct { script: []const u8, }; +const OverParams = struct { + result: []const u8, +}; + pub fn handleCall(server: *McpServer, arena: std.mem.Allocator, req: protocol.Request) !void { if (req.params == null) { return sendError(server, req.id, -32602, "Missing params"); @@ -66,27 +115,58 @@ pub fn handleCall(server: *McpServer, arena: std.mem.Allocator, req: protocol.Re return sendError(server, req.id, -32602, "Invalid params"); }; - if (std.mem.eql(u8, call_params.name, "navigate")) { + if (std.mem.eql(u8, call_params.name, "goto") or std.mem.eql(u8, call_params.name, "navigate")) { if (call_params.arguments == null) { - return sendError(server, req.id, -32602, "Missing arguments for navigate"); + return sendError(server, req.id, -32602, "Missing arguments for goto"); } - const args = std.json.parseFromValueLeaky(NavigateParams, arena, call_params.arguments.?, .{}) catch { - return sendError(server, req.id, -32602, "Invalid arguments for navigate"); + const args = std.json.parseFromValueLeaky(GotoParams, arena, call_params.arguments.?, .{}) catch { + return sendError(server, req.id, -32602, "Invalid arguments for goto"); }; - const url_z = try arena.dupeZ(u8, args.url); - _ = server.page.navigate(url_z, .{ - .reason = .address_bar, - .kind = .{ .push = null }, - }) catch { - return sendError(server, req.id, -32603, "Failed to navigate"); - }; - - // Wait for page load (simple wait for now) - _ = server.session.wait(5000); + try performGoto(server, arena, args.url); const content = [_]struct { type: []const u8, text: []const u8 }{.{ .type = "text", .text = "Navigated successfully." }}; try sendResult(server, req.id, .{ .content = &content }); + } else if (std.mem.eql(u8, call_params.name, "search")) { + if (call_params.arguments == null) { + return sendError(server, req.id, -32602, "Missing arguments for search"); + } + const args = std.json.parseFromValueLeaky(SearchParams, arena, call_params.arguments.?, .{}) catch { + return sendError(server, req.id, -32602, "Invalid arguments for search"); + }; + + const component: std.Uri.Component = .{ .raw = args.text }; + var url_aw = std.Io.Writer.Allocating.init(arena); + try component.formatQuery(&url_aw.writer); + const url = try std.fmt.allocPrint(arena, "https://duckduckgo.com/?q={s}", .{url_aw.written()}); + + try performGoto(server, arena, url); + + const content = [_]struct { type: []const u8, text: []const u8 }{.{ .type = "text", .text = "Search performed successfully." }}; + try sendResult(server, req.id, .{ .content = &content }); + } else if (std.mem.eql(u8, call_params.name, "markdown")) { + var aw = std.Io.Writer.Allocating.init(arena); + try lp.markdown.dump(server.page.document.asNode(), .{}, &aw.writer, server.page); + + const content = [_]struct { type: []const u8, text: []const u8 }{.{ .type = "text", .text = aw.written() }}; + try sendResult(server, req.id, .{ .content = &content }); + } else if (std.mem.eql(u8, call_params.name, "links")) { + const list = try Selector.querySelectorAll(server.page.document.asNode(), "a[href]", server.page); + + var aw = std.Io.Writer.Allocating.init(arena); + var first = true; + for (list._nodes) |node| { + if (node.is(Element)) |el| { + if (el.getAttributeSafe(String.wrap("href"))) |href| { + if (!first) try aw.writer.writeByte('\n'); + try aw.writer.writeAll(href); + first = false; + } + } + } + + const content = [_]struct { type: []const u8, text: []const u8 }{.{ .type = "text", .text = aw.written() }}; + try sendResult(server, req.id, .{ .content = &content }); } else if (std.mem.eql(u8, call_params.name, "evaluate")) { if (call_params.arguments == null) { return sendError(server, req.id, -32602, "Missing arguments for evaluate"); @@ -108,11 +188,33 @@ pub fn handleCall(server: *McpServer, arena: std.mem.Allocator, req: protocol.Re const content = [_]struct { type: []const u8, text: []const u8 }{.{ .type = "text", .text = str_result }}; try sendResult(server, req.id, .{ .content = &content }); + } else if (std.mem.eql(u8, call_params.name, "over")) { + if (call_params.arguments == null) { + return sendError(server, req.id, -32602, "Missing arguments for over"); + } + const args = std.json.parseFromValueLeaky(OverParams, arena, call_params.arguments.?, .{}) catch { + return sendError(server, req.id, -32602, "Invalid arguments for over"); + }; + + const content = [_]struct { type: []const u8, text: []const u8 }{.{ .type = "text", .text = args.result }}; + try sendResult(server, req.id, .{ .content = &content }); } else { return sendError(server, req.id, -32601, "Tool not found"); } } +fn performGoto(server: *McpServer, arena: std.mem.Allocator, url: []const u8) !void { + const url_z = try arena.dupeZ(u8, url); + _ = server.page.navigate(url_z, .{ + .reason = .address_bar, + .kind = .{ .push = null }, + }) catch { + return error.NavigationFailed; + }; + + _ = server.session.wait(5000); +} + pub fn sendResult(server: *McpServer, id: std.json.Value, result: anytype) !void { const GenericResponse = struct { jsonrpc: []const u8 = "2.0",