agent: add MCP server mode with task tool

This commit is contained in:
Adrià Arrufat
2026-04-30 17:11:48 +02:00
parent 85f2a08128
commit 300fdfb34c
13 changed files with 406 additions and 111 deletions

View File

@@ -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
```

View File

@@ -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 <path>` (repeatable) to feed local
files to providers that accept attachments.
## MCP server mode (`--mcp`)
`lightpanda agent --mcp --provider <p>` 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`.

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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.

119
src/agent/McpServer.zig Normal file
View File

@@ -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 });
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

51
src/mcp/Transport.zig Normal file
View File

@@ -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,
},
});
}

View File

@@ -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");
};
}

View File

@@ -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" {

View File

@@ -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");