diff --git a/README.md b/README.md index fe2b2f3d..448318fc 100644 --- a/README.md +++ b/README.md @@ -160,13 +160,16 @@ A skill is available in [lightpanda-io/agent-skill](https://github.com/lightpand `lightpanda agent` runs an interactive agent on top of the same browser. It supports an LLM-driven REPL (Anthropic, OpenAI, Gemini, Ollama), a one-shot -`--task` mode that prints the answer to stdout, and a small scripting language -(Pandascript) for recording and deterministically replaying browser sessions, -with optional `--self-heal` recovery from selector drift. +`--task` mode that prints the answer to stdout, an `--mcp` mode that exposes +the agent itself as an MCP `task` tool so other agents can delegate sub-tasks +without polluting their context, and a small scripting language (Pandascript) +for recording and deterministically replaying browser sessions, with optional +`--self-heal` recovery from selector drift. ```console ./lightpanda agent --provider anthropic ./lightpanda agent --provider gemini --task "top story on news.ycombinator.com?" +./lightpanda agent --mcp --provider anthropic ./lightpanda agent session.panda ``` diff --git a/docs/agent.md b/docs/agent.md index 1b8319c5..f29cd4bf 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -6,9 +6,11 @@ It can act as: - an **LLM agent** that drives the browser with tool calls (`--provider`), - a **scripted runner** that replays a `.panda` script deterministically, - a **dumb REPL** for hand-driven Pandascript with no LLM at all, -- a **one-shot task runner** that prints a single answer to stdout (`--task`). +- a **one-shot task runner** that prints a single answer to stdout (`--task`), +- an **MCP server** that exposes the agent itself as a single `task` tool + for other agents to delegate to (`--mcp`). -All four modes share the same browser tools (`goto`, `click`, `fill`, `tree`, +All five modes share the same browser tools (`goto`, `click`, `fill`, `tree`, `markdown`, `search`, ...). The same set is exposed over MCP via `lightpanda mcp`, so an agent script and an MCP client see the same surface. @@ -29,6 +31,9 @@ mcp`, so an agent script and an MCP client see the same surface. # One-shot: ask a question, capture the answer on stdout ./lightpanda agent --provider gemini --task "what is on the front page of hn?" + +# MCP server: expose a single `task` tool for other agents to delegate to +./lightpanda agent --mcp --provider anthropic ``` ## Providers and API keys @@ -146,6 +151,54 @@ from selector drift, not to redesign the script. exits. Combine with `--task-attachment ` (repeatable) to feed local files to providers that accept attachments. +## MCP server mode (`--mcp`) + +`lightpanda agent --mcp --provider

` runs the agent as an MCP server +over stdio. It exposes a single tool, `task`, so a calling agent can +delegate a high-level browsing task and receive only the final answer +without the intermediate browser tool calls (tree dumps, clicks, scrolls) +filling its own context. + +```console +./lightpanda agent --mcp --provider anthropic +``` + +MCP configuration: + +```json +{ + "mcpServers": { + "lightpanda-agent": { + "command": "/path/to/lightpanda", + "args": ["agent", "--mcp", "--provider", "anthropic"] + } + } +} +``` + +The `task` tool accepts: + +| Field | Type | Notes | +|---------------|------------------|------------------------------------------------------------------------| +| `task` | string, required | Natural-language instruction for the agent. | +| `attachments` | string[] | Optional local file paths (image / PDF / text) for providers that accept attachments. | +| `fresh` | boolean | If true, start the task from a fresh browser session (no cookies, no current page). | + +Each call resets the agent's LLM conversation, so tasks are independent +from each other at the model level. The browser session, by contrast, +persists across calls by default — set `fresh: true` to reset it. + +This mode is distinct from `lightpanda mcp`, which exposes the raw +browser tools (`goto`, `click`, `fill`, ...) and does not depend on an +LLM. Pick `lightpanda mcp` when the calling agent wants to drive the +browser itself, and `lightpanda agent --mcp` when it wants to hand off +the whole sub-task. `--mcp` cannot be combined with `--task`, `-i`, or a +script file. + +Limitations: the JSON-RPC loop is single-threaded, so a long-running +task call blocks subsequent calls until it finishes. There is no +cancellation from the client side yet. + ## Browser tools The agent and MCP server share the tool set defined in `src/browser/tools.zig`. diff --git a/src/Config.zig b/src/Config.zig index f6bbd280..5635255c 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -189,6 +189,7 @@ const Commands = cli.Builder(.{ .{ .name = "interactive", .type = bool }, .{ .name = "task", .type = ?[]const u8 }, .{ .name = "task_attachments", .type = []const u8, .multiple = true }, + .{ .name = "mcp", .type = bool }, }, .shared_options = CommonOptions, }, @@ -763,6 +764,12 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ a positional script is present, any new commands \\ entered in the REPL are appended to that file. \\ + \\--mcp Run as an MCP server over stdio that exposes a single + \\ `task` tool. Each call delegates a high-level task to + \\ the agent and returns only the final answer to the + \\ MCP client. Requires --provider; cannot be combined + \\ with --task, -i, or a script file. + \\ \\The API key is read from the environment: \\ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY. \\Ollama does not require an API key. diff --git a/src/agent.zig b/src/agent.zig index 69d646c4..22c27c6a 100644 --- a/src/agent.zig +++ b/src/agent.zig @@ -6,6 +6,7 @@ pub const CommandExecutor = @import("agent/CommandExecutor.zig"); pub const Recorder = @import("agent/Recorder.zig"); pub const Verifier = @import("agent/Verifier.zig"); pub const SlashCommand = @import("agent/SlashCommand.zig"); +pub const McpServer = @import("agent/McpServer.zig"); test { _ = Agent; @@ -14,4 +15,5 @@ test { _ = Recorder; _ = Verifier; _ = SlashCommand; + _ = McpServer; } diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 6372520e..e894576f 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -127,8 +127,16 @@ slash_schemas: []const SlashCommand.SchemaInfo, pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self { const is_one_shot = opts.task != null; - const will_repl = !is_one_shot and (opts.interactive or opts.script_file == null); - const needs_llm = will_repl or is_one_shot; + const is_mcp = opts.mcp; + const will_repl = !is_one_shot and !is_mcp and (opts.interactive or opts.script_file == null); + const needs_llm = will_repl or is_one_shot or is_mcp; + + if (is_mcp and (is_one_shot or opts.interactive or opts.script_file != null)) { + log.fatal(.app, "incompatible flags", .{ + .hint = "--mcp cannot be combined with --task, --interactive, or a script file", + }); + return error.IncompatibleFlags; + } if (opts.self_heal and opts.provider == null) { log.fatal(.app, "missing --provider", .{ @@ -144,6 +152,13 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self return error.TaskWithoutProvider; } + if (is_mcp and opts.provider == null) { + log.fatal(.app, "missing --provider", .{ + .hint = "--mcp requires --provider", + }); + return error.McpWithoutProvider; + } + const api_key = try resolveApiKey(opts.provider, needs_llm); const tool_executor: *ToolExecutor = try .init(allocator, app); @@ -249,7 +264,7 @@ pub fn run(self: *Self) bool { /// tool calls, errors, and info go to stderr, so callers can capture stdout /// as the clean answer. fn runOneShot(self: *Self, task: []const u8) bool { - self.processUserMessage(task, null) catch |err| switch (err) { + const text = self.processUserMessage(task, null) catch |err| switch (err) { error.UnsupportedAttachment, error.AttachmentReadFailed => { // Already logged in buildUserMessageParts with detail. return false; @@ -259,6 +274,7 @@ fn runOneShot(self: *Self, task: []const u8) bool { return false; }, }; + if (text) |t| self.terminal.printAssistant(t) else self.terminal.printInfo("(no response from model)"); return true; } @@ -292,15 +308,9 @@ fn runRepl(self: *Self) void { switch (cmd) { .comment => continue :repl, - .login => self.processUserMessage(login_prompt, line) catch |err| { - self.terminal.printErrorFmt("LOGIN failed: {s}", .{@errorName(err)}); - }, - .accept_cookies => self.processUserMessage(accept_cookies_prompt, line) catch |err| { - self.terminal.printErrorFmt("ACCEPT_COOKIES failed: {s}", .{@errorName(err)}); - }, - .natural_language => self.processUserMessage(line, line) catch |err| { - self.terminal.printErrorFmt("Request failed: {s}", .{@errorName(err)}); - }, + .login => self.runLlmTurnPrint(login_prompt, line, "LOGIN"), + .accept_cookies => self.runLlmTurnPrint(accept_cookies_prompt, line, "ACCEPT_COOKIES"), + .natural_language => self.runLlmTurnPrint(line, line, "Request"), else => { self.cmd_executor.execute(cmd); self.recorder.record(cmd); @@ -476,7 +486,7 @@ fn runScript(self: *Self, path: []const u8) bool { return false; } const prompt = if (entry.command == .login) login_prompt else accept_cookies_prompt; - self.processUserMessage(prompt, null) catch |err| { + const text = self.processUserMessage(prompt, null) catch |err| { self.terminal.printErrorFmt("line {d}: {s} failed: {s}", .{ entry.line_num, entry.raw_line, @@ -485,6 +495,7 @@ fn runScript(self: *Self, path: []const u8) bool { self.flushReplacements(path, content, replacements.items); return false; }; + if (text) |t| self.terminal.printAssistant(t); }, else => { self.terminal.printInfoFmt("[{d}] {s}", .{ entry.line_num, entry.raw_line }); @@ -824,7 +835,42 @@ fn attemptSelfHeal(self: *Self, arena: std.mem.Allocator, failed_command: []cons return null; } -fn processUserMessage(self: *Self, user_input: []const u8, record_comment: ?[]const u8) !void { +/// MCP entry point: run a single user task with a clean LLM context. Browser +/// state (URL, cookies, etc.) is preserved by default; pass a fresh session +/// upstream if isolation is needed. Returns the assistant text on success +/// (memory tied to `message_arena`, valid until the next call), or `null` +/// if the model emitted nothing. +pub fn runOneTask( + self: *Self, + task: []const u8, + attachments: ?[]const []const u8, +) !?[]const u8 { + self.messages.clearRetainingCapacity(); + _ = self.message_arena.reset(.retain_capacity); + // Each task gets a fresh LLM context; drop registry entries that point + // into the old session so a stray backendNodeId can't survive a navigation. + self.tool_executor.node_registry.reset(); + self.one_shot_attachments = attachments; + return self.processUserMessage(task, null); +} + +/// REPL helper: run an LLM turn and route the answer to the terminal, +/// reporting failures with `label` ("LOGIN", "Request", ...). Errors are +/// swallowed — the REPL must not die from a single failed turn. +fn runLlmTurnPrint(self: *Self, prompt: []const u8, record_comment: ?[]const u8, label: []const u8) void { + const text = self.processUserMessage(prompt, record_comment) catch |err| { + self.terminal.printErrorFmt("{s} failed: {s}", .{ label, @errorName(err) }); + return; + }; + if (text) |t| self.terminal.printAssistant(t) else self.terminal.printInfo("(no response from model)"); +} + +/// Run one user-input → final-answer turn. Returns the assistant text on +/// success (memory lives in `message_arena`), or `null` if the model emitted +/// nothing even after a synthesis turn. Callers decide how to surface the +/// result (stdout for the CLI, JSON-RPC payload for MCP). Tool calls, +/// recording, and pruning all happen here. +fn processUserMessage(self: *Self, user_input: []const u8, record_comment: ?[]const u8) !?[]const u8 { const ma = self.message_arena.allocator(); try self.ensureSystemPrompt(); @@ -894,11 +940,11 @@ fn processUserMessage(self: *Self, user_input: []const u8, record_comment: ?[]co } } - printed: { - if (result.text) |text| { - self.terminal.printAssistant(text); - break :printed; - } + // `result.text` and `synth.text` are owned by their RunToolsResult arenas, + // which are deinited at the end of this function. Dupe into the agent's + // `message_arena` so the returned slice outlives those arenas. + const final_text: ?[]const u8 = blk: { + if (result.text) |text| break :blk try ma.dupe(u8, text); // The tool-use loop exhausted max_turns or returned an empty turn // with no final text. Ask the model for a synthesis answer without @@ -947,19 +993,15 @@ fn processUserMessage(self: *Self, user_input: []const u8, record_comment: ?[]co }, ) catch |err| { log.err(.app, "AI synthesis error", .{ .err = err }); - self.terminal.printInfo("(no response from model)"); - break :printed; + break :blk null; }; defer synth.deinit(); - if (synth.text) |text| { - self.terminal.printAssistant(text); - } else { - self.terminal.printInfo("(no response from model)"); - } - } + break :blk if (synth.text) |text| try ma.dupe(u8, text) else null; + }; self.pruneMessages(); + return final_text; } /// Build a `parts`-based user message when `--task-attachment` was given. diff --git a/src/agent/McpServer.zig b/src/agent/McpServer.zig new file mode 100644 index 00000000..93675543 --- /dev/null +++ b/src/agent/McpServer.zig @@ -0,0 +1,119 @@ +const std = @import("std"); +const lp = @import("lightpanda"); + +const App = @import("../App.zig"); +const Agent = @import("Agent.zig"); +const browser_tools = lp.tools; +const protocol = @import("../mcp/protocol.zig"); +const Transport = @import("../mcp/Transport.zig"); + +const log = lp.log; +const Self = @This(); + +/// MCP server exposing a single `task` tool backed by an `Agent`. +allocator: std.mem.Allocator, +agent: *Agent, +transport: Transport, + +const task_tool_schema = browser_tools.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "task": { "type": "string", "description": "Natural-language instruction for the agent to execute against a headless browser." }, + \\ "attachments": { "type": "array", "items": { "type": "string" }, "description": "Optional local file paths to attach to the request (image/PDF/text). Provider must accept attachments." }, + \\ "fresh": { "type": "boolean", "description": "If true, start the task from a fresh browser session with no cookies and no current page." } + \\ }, + \\ "required": ["task"] + \\} +); + +const task_tool = protocol.Tool{ + .name = "task", + .description = "Delegate a high-level browsing task to the Lightpanda agent. The agent drives the browser internally with multiple tool calls and returns only the final answer, so the caller's context is not polluted with intermediate tree dumps, clicks, or scrolls.", + .inputSchema = task_tool_schema, +}; + +pub fn init(allocator: std.mem.Allocator, app: *App, opts: lp.Config.Agent, writer: *std.io.Writer) !*Self { + const agent = try Agent.init(allocator, app, opts); + errdefer agent.deinit(); + + const self = try allocator.create(Self); + errdefer allocator.destroy(self); + + self.* = .{ + .allocator = allocator, + .agent = agent, + .transport = .init(allocator, writer), + }; + return self; +} + +pub fn deinit(self: *Self) void { + self.transport.deinit(); + self.agent.deinit(); + self.allocator.destroy(self); +} + +pub fn handleInitialize(self: *Self, req: protocol.Request) !void { + const id = req.id orelse return; + try self.transport.sendResult(id, protocol.InitializeResult{ + .protocolVersion = @tagName(protocol.Version.default), + .capabilities = .{ .tools = .{} }, + .serverInfo = .{ .name = "lightpanda-agent", .version = "0.1.0" }, + }); +} + +pub fn handleToolList(self: *Self, arena: std.mem.Allocator, req: protocol.Request) !void { + _ = arena; + const id = req.id orelse return; + try self.transport.sendResult(id, .{ .tools = &[_]protocol.Tool{task_tool} }); +} + +pub fn handleToolCall(self: *Self, arena: std.mem.Allocator, req: protocol.Request) !void { + const id = req.id orelse return; + const params = req.params orelse return self.transport.sendError(id, .InvalidParams, "Missing params"); + + const CallParams = struct { + name: []const u8, + arguments: ?std.json.Value = null, + }; + const call_params = std.json.parseFromValueLeaky(CallParams, arena, params, .{ .ignore_unknown_fields = true }) catch + return self.transport.sendError(id, .InvalidParams, "Invalid params"); + + if (!std.mem.eql(u8, call_params.name, task_tool.name)) { + return self.transport.sendError(id, .MethodNotFound, "Tool not found"); + } + + const args_value = call_params.arguments orelse + return self.transport.sendError(id, .InvalidParams, "Missing arguments"); + + const TaskArgs = struct { + task: []const u8, + attachments: ?[]const []const u8 = null, + fresh: ?bool = null, + }; + const args = std.json.parseFromValueLeaky(TaskArgs, arena, args_value, .{ .ignore_unknown_fields = true }) catch + return self.transport.sendError(id, .InvalidParams, "Invalid task arguments"); + + if (args.fresh orelse false) { + self.agent.tool_executor.resetSession() catch |err| { + log.err(.mcp, "fresh session reset failed", .{ .err = err }); + return self.sendErrorResult(id, "Failed to start a fresh browser session"); + }; + } + + const answer = self.agent.runOneTask(args.task, args.attachments) catch |err| { + log.err(.mcp, "agent task failed", .{ .err = err }); + return self.sendErrorResult(id, @errorName(err)); + }; + + const text = answer orelse return self.sendErrorResult(id, "(no response from model)"); + + const content = [_]protocol.TextContent([]const u8){.{ .text = text }}; + try self.transport.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); +} + +fn sendErrorResult(self: *Self, id: std.json.Value, msg: []const u8) !void { + const content = [_]protocol.TextContent([]const u8){.{ .text = msg }}; + try self.transport.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content, .isError = true }); +} diff --git a/src/agent/ToolExecutor.zig b/src/agent/ToolExecutor.zig index efd5128e..f806e58b 100644 --- a/src/agent/ToolExecutor.zig +++ b/src/agent/ToolExecutor.zig @@ -55,6 +55,15 @@ pub fn deinit(self: *Self) void { self.allocator.destroy(self); } +/// Tear down the current `Browser` and `Session` and replace them with +/// fresh ones. Caller is responsible for clearing any registry/cache +/// state that depended on the old session. +pub fn resetSession(self: *Self) !void { + self.browser.deinit(); + self.browser = try lp.Browser.init(self.app, .{ .http_client = self.http_client }); + self.session = try self.browser.newSession(self.notification); +} + pub const CallError = browser_tools.ToolError || error{InvalidJsonArguments}; /// Allocator backing the parsed tool schemas. Lives for the executor's diff --git a/src/main.zig b/src/main.zig index 378af6e2..b111ef89 100644 --- a/src/main.zig +++ b/src/main.zig @@ -196,6 +196,24 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { fn agentThread(allocator: std.mem.Allocator, app: *App, opts: Config.Agent, failed: *bool) void { defer app.network.stop(); + if (opts.mcp) { + var stdout = std.fs.File.stdout().writer(&.{}); + var server = lp.agent.McpServer.init(allocator, app, opts, &stdout.interface) catch |err| { + log.fatal(.app, "agent mcp init error", .{ .err = err }); + failed.* = true; + return; + }; + defer server.deinit(); + + var stdin_buf: [64 * 1024]u8 = undefined; + var stdin = std.fs.File.stdin().reader(&stdin_buf); + lp.mcp.router.processRequests(server, &stdin.interface) catch |err| { + log.err(.app, "agent mcp error", .{ .err = err }); + failed.* = true; + }; + return; + } + var agent_instance = lp.agent.Agent.init(allocator, app, opts) catch |err| { log.fatal(.app, "agent init error", .{ .err = err }); failed.* = true; diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig index 59668398..44e9b967 100644 --- a/src/mcp/Server.zig +++ b/src/mcp/Server.zig @@ -6,7 +6,10 @@ const App = @import("../App.zig"); const HttpClient = @import("../browser/HttpClient.zig"); const testing = @import("../testing.zig"); const protocol = @import("protocol.zig"); +const resources = @import("resources.zig"); const router = @import("router.zig"); +const tools = @import("tools.zig"); +const Transport = @import("Transport.zig"); const CDPNode = @import("../cdp/Node.zig"); const Self = @This(); @@ -20,9 +23,7 @@ browser: lp.Browser, session: *lp.Session, node_registry: CDPNode.Registry, -writer: *std.io.Writer, -mutex: std.Thread.Mutex = .{}, -aw: std.io.Writer.Allocating, +transport: Transport, pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*Self { const http_client = try HttpClient.init(allocator, &app.network); @@ -40,9 +41,8 @@ pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*S self.* = .{ .allocator = allocator, .app = app, - .writer = writer, .browser = browser, - .aw = .init(allocator), + .transport = .init(allocator, writer), .http_client = http_client, .notification = notification, .session = undefined, @@ -64,7 +64,7 @@ pub fn deinit(self: *Self) void { } self.node_registry.deinit(); - self.aw.deinit(); + self.transport.deinit(); self.browser.deinit(); self.notification.deinit(); self.http_client.deinit(); @@ -72,39 +72,34 @@ pub fn deinit(self: *Self) void { self.allocator.destroy(self); } -pub fn sendResponse(self: *Self, response: anytype) !void { - self.mutex.lock(); - defer self.mutex.unlock(); - - self.aw.clearRetainingCapacity(); - try std.json.Stringify.value(response, .{ .emit_null_optional_fields = false }, &self.aw.writer); - try self.aw.writer.writeByte('\n'); - try self.writer.writeAll(self.aw.writer.buffered()); - try self.writer.flush(); -} - -pub fn sendResult(self: *Self, id: std.json.Value, result: anytype) !void { - const GenericResponse = struct { - jsonrpc: []const u8 = "2.0", - id: std.json.Value, - result: @TypeOf(result), - }; - try self.sendResponse(GenericResponse{ - .id = id, - .result = result, - }); -} - -pub fn sendError(self: *Self, id: std.json.Value, code: protocol.ErrorCode, message: []const u8) !void { - try self.sendResponse(protocol.Response{ - .id = id, - .@"error" = protocol.Error{ - .code = @intFromEnum(code), - .message = message, +pub fn handleInitialize(self: *Self, req: protocol.Request) !void { + const id = req.id orelse return; + try self.transport.sendResult(id, protocol.InitializeResult{ + .protocolVersion = @tagName(protocol.Version.default), + .capabilities = .{ + .resources = .{}, + .tools = .{}, }, + .serverInfo = .{ .name = "lightpanda", .version = "0.1.0" }, }); } +pub fn handleToolList(self: *Self, arena: std.mem.Allocator, req: protocol.Request) !void { + return tools.handleList(self, arena, req); +} + +pub fn handleToolCall(self: *Self, arena: std.mem.Allocator, req: protocol.Request) !void { + return tools.handleCall(self, arena, req); +} + +pub fn handleResourceList(self: *Self, req: protocol.Request) !void { + return resources.handleList(self, req); +} + +pub fn handleResourceRead(self: *Self, arena: std.mem.Allocator, req: protocol.Request) !void { + return resources.handleRead(self, arena, req); +} + test "MCP.Server - Integration: synchronous smoke test" { defer testing.reset(); const allocator = testing.allocator; diff --git a/src/mcp/Transport.zig b/src/mcp/Transport.zig new file mode 100644 index 00000000..e9889357 --- /dev/null +++ b/src/mcp/Transport.zig @@ -0,0 +1,51 @@ +//! Stdio JSON-RPC writer shared between the browser-tools MCP server +//! (`mcp/Server.zig`) and the agent-tool MCP server +//! (`agent/McpServer.zig`). Owns the output writer, a serialization buffer, +//! and the mutex that serializes concurrent response writes. + +const std = @import("std"); +const protocol = @import("protocol.zig"); + +const Self = @This(); + +writer: *std.io.Writer, +mutex: std.Thread.Mutex = .{}, +aw: std.io.Writer.Allocating, + +pub fn init(allocator: std.mem.Allocator, writer: *std.io.Writer) Self { + return .{ .writer = writer, .aw = .init(allocator) }; +} + +pub fn deinit(self: *Self) void { + self.aw.deinit(); +} + +pub fn sendResponse(self: *Self, response: anytype) !void { + self.mutex.lock(); + defer self.mutex.unlock(); + + self.aw.clearRetainingCapacity(); + try std.json.Stringify.value(response, .{ .emit_null_optional_fields = false }, &self.aw.writer); + try self.aw.writer.writeByte('\n'); + try self.writer.writeAll(self.aw.writer.buffered()); + try self.writer.flush(); +} + +pub fn sendResult(self: *Self, id: std.json.Value, result: anytype) !void { + const GenericResponse = struct { + jsonrpc: []const u8 = "2.0", + id: std.json.Value, + result: @TypeOf(result), + }; + try self.sendResponse(GenericResponse{ .id = id, .result = result }); +} + +pub fn sendError(self: *Self, id: std.json.Value, code: protocol.ErrorCode, message: []const u8) !void { + try self.sendResponse(protocol.Response{ + .id = id, + .@"error" = protocol.Error{ + .code = @intFromEnum(code), + .message = message, + }, + }); +} diff --git a/src/mcp/resources.zig b/src/mcp/resources.zig index ed413c33..1170e2ff 100644 --- a/src/mcp/resources.zig +++ b/src/mcp/resources.zig @@ -23,7 +23,7 @@ pub const resource_list = [_]protocol.Resource{ pub fn handleList(server: *Server, req: protocol.Request) !void { const id = req.id orelse return; - try server.sendResult(id, .{ .resources = &resource_list }); + try server.transport.sendResult(id, .{ .resources = &resource_list }); } const ReadParams = struct { @@ -74,20 +74,20 @@ const resource_map = std.StaticStringMap(ResourceUri).initComptime(.{ pub fn handleRead(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { if (req.params == null or req.id == null) { - return server.sendError(req.id orelse .{ .integer = -1 }, .InvalidParams, "Missing params"); + return server.transport.sendError(req.id orelse .{ .integer = -1 }, .InvalidParams, "Missing params"); } const req_id = req.id.?; const params = std.json.parseFromValueLeaky(ReadParams, arena, req.params.?, .{ .ignore_unknown_fields = true }) catch { - return server.sendError(req_id, .InvalidParams, "Invalid params"); + return server.transport.sendError(req_id, .InvalidParams, "Invalid params"); }; const uri = resource_map.get(params.uri) orelse { - return server.sendError(req_id, .InvalidRequest, "Resource not found"); + return server.transport.sendError(req_id, .InvalidRequest, "Resource not found"); }; const frame = server.session.currentFrame() orelse { - return server.sendError(req_id, .FrameNotLoaded, "Page not loaded"); + return server.transport.sendError(req_id, .FrameNotLoaded, "Page not loaded"); }; const format: Format = switch (uri) { @@ -106,7 +106,7 @@ pub fn handleRead(server: *Server, arena: std.mem.Allocator, req: protocol.Reque .text = .{ .frame = frame, .format = format }, }}, }; - server.sendResult(req_id, result) catch { - return server.sendError(req_id, .InternalError, "Failed to serialize resource content"); + server.transport.sendResult(req_id, result) catch { + return server.transport.sendError(req_id, .InternalError, "Failed to serialize resource content"); }; } diff --git a/src/mcp/router.zig b/src/mcp/router.zig index d3feec5f..f6177b8b 100644 --- a/src/mcp/router.zig +++ b/src/mcp/router.zig @@ -2,13 +2,17 @@ const std = @import("std"); const lp = @import("lightpanda"); const protocol = @import("protocol.zig"); -const resources = @import("resources.zig"); -const Server = @import("Server.zig"); -const tools = @import("tools.zig"); const log = lp.log; -pub fn processRequests(server: *Server, reader: *std.io.Reader) !void { +/// Generic over the server type so both `mcp/Server.zig` (browser tools) and +/// `agent/McpServer.zig` (the `task` tool) can reuse this loop. The server +/// must expose: `allocator`, a `transport: Transport` field, and the +/// per-method `handleInitialize`, `handleToolList`, `handleToolCall` +/// methods. `handleResourceList` / `handleResourceRead` are optional — +/// servers that don't expose resources can omit them and the router +/// returns `MethodNotFound` automatically. +pub fn processRequests(server: anytype, reader: *std.io.Reader) !void { var arena: std.heap.ArenaAllocator = .init(server.allocator); defer arena.deinit(); @@ -19,7 +23,7 @@ pub fn processRequests(server: *Server, reader: *std.io.Reader) !void { const buffered_line = reader.takeDelimiter('\n') catch |err| switch (err) { error.StreamTooLong => { log.err(.mcp, "Message too long", .{}); - try server.sendError(.null, .InvalidRequest, "Message too long"); + try server.transport.sendError(.null, .InvalidRequest, "Message too long"); continue; }, else => return err, @@ -54,55 +58,47 @@ const method_map = std.StaticStringMap(Method).initComptime(.{ .{ "resources/read", .@"resources/read" }, }); -pub fn handleMessage(server: *Server, arena: std.mem.Allocator, msg: []const u8) !void { +pub fn handleMessage(server: anytype, arena: std.mem.Allocator, msg: []const u8) !void { const req = std.json.parseFromSliceLeaky(protocol.Request, arena, msg, .{ .ignore_unknown_fields = true, }) catch |err| { log.warn(.mcp, "JSON Parse Error", .{ .err = err, .msg = msg }); - try server.sendError(.null, .ParseError, "Parse error"); + try server.transport.sendError(.null, .ParseError, "Parse error"); return; }; const method = method_map.get(req.method) orelse { if (req.id != null) { - try server.sendError(req.id.?, .MethodNotFound, "Method not found"); + try server.transport.sendError(req.id.?, .MethodNotFound, "Method not found"); } return; }; switch (method) { - .initialize => try handleInitialize(server, req), + .initialize => try server.handleInitialize(req), .ping => try handlePing(server, req), .@"notifications/initialized" => {}, - .@"tools/list" => try tools.handleList(server, arena, req), - .@"tools/call" => try tools.handleCall(server, arena, req), - .@"resources/list" => try resources.handleList(server, req), - .@"resources/read" => try resources.handleRead(server, arena, req), + .@"tools/list" => try server.handleToolList(arena, req), + .@"tools/call" => try server.handleToolCall(arena, req), + .@"resources/list" => try handleOptional(server, req, "handleResourceList", .{req}), + .@"resources/read" => try handleOptional(server, req, "handleResourceRead", .{ arena, req }), } } -fn handleInitialize(server: *Server, req: protocol.Request) !void { - const id = req.id orelse return; - const result: protocol.InitializeResult = .{ - .protocolVersion = @tagName(protocol.Version.default), - .capabilities = .{ - .resources = .{}, - .tools = .{}, - }, - .serverInfo = .{ - .name = "lightpanda", - .version = "0.1.0", - }, - }; - - try server.sendResult(id, result); +fn handleOptional(server: anytype, req: protocol.Request, comptime method: []const u8, args: anytype) !void { + if (@hasDecl(@TypeOf(server.*), method)) { + try @call(.auto, @field(@TypeOf(server.*), method), .{server} ++ args); + } else if (req.id) |id| { + try server.transport.sendError(id, .MethodNotFound, "Method not supported"); + } } -fn handlePing(server: *Server, req: protocol.Request) !void { +fn handlePing(server: anytype, req: protocol.Request) !void { const id = req.id orelse return; - try server.sendResult(id, .{}); + try server.transport.sendResult(id, .{}); } +const Server = @import("Server.zig"); const testing = @import("../testing.zig"); test "MCP.router - handleMessage - synchronous unit tests" { diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index a7662097..21dfabe5 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -23,12 +23,12 @@ const tool_list = blk: { pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { _ = arena; const id = req.id orelse return; - try server.sendResult(id, .{ .tools = &tool_list }); + try server.transport.sendResult(id, .{ .tools = &tool_list }); } pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { if (req.params == null or req.id == null) { - return server.sendError(req.id orelse .{ .integer = -1 }, .InvalidParams, "Missing params"); + return server.transport.sendError(req.id orelse .{ .integer = -1 }, .InvalidParams, "Missing params"); } const CallParams = struct { @@ -37,20 +37,20 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque }; const call_params = std.json.parseFromValueLeaky(CallParams, arena, req.params.?, .{ .ignore_unknown_fields = true }) catch { - return server.sendError(req.id.?, .InvalidParams, "Invalid params"); + return server.transport.sendError(req.id.?, .InvalidParams, "Invalid params"); }; const id = req.id.?; const action = std.meta.stringToEnum(browser_tools.Action, call_params.name) orelse { - return server.sendError(id, .MethodNotFound, "Tool not found"); + return server.transport.sendError(id, .MethodNotFound, "Tool not found"); }; // JS errors are returned as isError tool results, not protocol errors if (action == .eval) { const result = browser_tools.callEval(server.session, arena, &server.node_registry, call_params.arguments); const content = [_]protocol.TextContent([]const u8){.{ .text = result.text }}; - return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content, .isError = result.is_error }); + return server.transport.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content, .isError = result.is_error }); } const result = browser_tools.call(server.session, arena, &server.node_registry, call_params.name, call_params.arguments) catch |err| { @@ -59,11 +59,11 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque error.NodeNotFound, error.InvalidParams => .InvalidParams, error.NavigationFailed, error.InternalError, error.OutOfMemory => .InternalError, }; - return server.sendError(id, code, @errorName(err)); + return server.transport.sendError(id, code, @errorName(err)); }; const content = [_]protocol.TextContent([]const u8){.{ .text = result }}; - try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); + try server.transport.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } const router = @import("router.zig");