mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-22 04:34:44 +00:00
mcp: refactor for testability and add comprehensive test suite
- Refactor mcp.Server and router to accept injected I/O streams. - Implement McpHarness for high-fidelity MCP integration testing. - Add unit tests for protocol, tools, and resources modules. - Add integration tests covering initialization, tool/resource execution, and error handling. - Improve error reporting for malformed JSON requests.
This commit is contained in:
@@ -136,10 +136,10 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
|
||||
|
||||
log.opts.format = .logfmt;
|
||||
|
||||
var mcp_server = try lp.mcp.Server.init(allocator, app);
|
||||
var mcp_server = try lp.mcp.Server.init(allocator, app, std.fs.File.stdout());
|
||||
defer mcp_server.deinit();
|
||||
|
||||
try lp.mcp.router.processRequests(mcp_server);
|
||||
try lp.mcp.router.processRequests(mcp_server, std.fs.File.stdin());
|
||||
},
|
||||
else => unreachable,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub const Server = @import("mcp/Server.zig");
|
||||
pub const protocol = @import("mcp/protocol.zig");
|
||||
pub const router = @import("mcp/router.zig");
|
||||
pub const testing = @import("mcp/testing.zig");
|
||||
|
||||
@@ -17,13 +17,15 @@ session: *lp.Session,
|
||||
page: *lp.Page,
|
||||
|
||||
is_running: std.atomic.Value(bool) = .init(false),
|
||||
out_stream: std.fs.File,
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator, app: *App) !*Self {
|
||||
pub fn init(allocator: std.mem.Allocator, app: *App, out_stream: std.fs.File) !*Self {
|
||||
const self = try allocator.create(Self);
|
||||
errdefer allocator.destroy(self);
|
||||
|
||||
self.allocator = allocator;
|
||||
self.app = app;
|
||||
self.out_stream = out_stream;
|
||||
|
||||
self.http_client = try app.http.createClient(allocator);
|
||||
errdefer self.http_client.deinit();
|
||||
@@ -55,7 +57,7 @@ pub fn sendResponse(self: *Self, response: anytype) !void {
|
||||
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());
|
||||
try self.out_stream.writeAll(aw.written());
|
||||
}
|
||||
|
||||
pub fn sendResult(self: *Self, id: std.json.Value, result: anytype) !void {
|
||||
@@ -79,3 +81,164 @@ pub fn sendError(self: *Self, id: std.json.Value, code: protocol.ErrorCode, mess
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
const McpHarness = @import("testing.zig").McpHarness;
|
||||
|
||||
test "MCP Integration: handshake and tools/list" {
|
||||
const harness = try McpHarness.init(testing.allocator, testing.test_app);
|
||||
defer harness.deinit();
|
||||
|
||||
harness.thread = try std.Thread.spawn(.{}, testHandshakeAndTools, .{harness});
|
||||
try harness.runServer();
|
||||
}
|
||||
|
||||
fn testHandshakeAndTools(harness: *McpHarness) void {
|
||||
defer harness.server.is_running.store(false, .release);
|
||||
|
||||
// 1. Initialize
|
||||
harness.sendRequest(
|
||||
\\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}
|
||||
) catch return;
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(harness.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const response1 = harness.readResponse(arena.allocator()) catch return;
|
||||
testing.expect(std.mem.indexOf(u8, response1, "\"id\":1") != null) catch return;
|
||||
testing.expect(std.mem.indexOf(u8, response1, "\"protocolVersion\":\"2025-11-25\"") != null) catch return;
|
||||
|
||||
// 2. Initialized notification
|
||||
harness.sendRequest(
|
||||
\\{"jsonrpc":"2.0","method":"notifications/initialized"}
|
||||
) catch return;
|
||||
|
||||
// 3. List tools
|
||||
harness.sendRequest(
|
||||
\\{"jsonrpc":"2.0","id":2,"method":"tools/list"}
|
||||
) catch return;
|
||||
|
||||
const response2 = harness.readResponse(arena.allocator()) catch return;
|
||||
testing.expect(std.mem.indexOf(u8, response2, "\"id\":2") != null) catch return;
|
||||
testing.expect(std.mem.indexOf(u8, response2, "\"name\":\"goto\"") != null) catch return;
|
||||
}
|
||||
|
||||
test "MCP Integration: tools/call evaluate" {
|
||||
const harness = try McpHarness.init(testing.allocator, testing.test_app);
|
||||
defer harness.deinit();
|
||||
|
||||
harness.thread = try std.Thread.spawn(.{}, testEvaluate, .{harness});
|
||||
try harness.runServer();
|
||||
}
|
||||
|
||||
fn testEvaluate(harness: *McpHarness) void {
|
||||
defer harness.server.is_running.store(false, .release);
|
||||
|
||||
harness.sendRequest(
|
||||
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"evaluate","arguments":{"script":"1 + 1"}}}
|
||||
) catch return;
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(harness.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const response = harness.readResponse(arena.allocator()) catch return;
|
||||
testing.expect(std.mem.indexOf(u8, response, "\"id\":1") != null) catch return;
|
||||
testing.expect(std.mem.indexOf(u8, response, "\"text\":\"2\"") != null) catch return;
|
||||
}
|
||||
|
||||
test "MCP Integration: error handling" {
|
||||
const harness = try McpHarness.init(testing.allocator, testing.test_app);
|
||||
defer harness.deinit();
|
||||
|
||||
harness.thread = try std.Thread.spawn(.{}, testErrorHandling, .{harness});
|
||||
try harness.runServer();
|
||||
}
|
||||
|
||||
fn testErrorHandling(harness: *McpHarness) void {
|
||||
defer harness.server.is_running.store(false, .release);
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(harness.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
// 1. Tool not found
|
||||
harness.sendRequest(
|
||||
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"non_existent_tool"}}
|
||||
) catch return;
|
||||
|
||||
const response1 = harness.readResponse(arena.allocator()) catch return;
|
||||
testing.expect(std.mem.indexOf(u8, response1, "\"id\":1") != null) catch return;
|
||||
testing.expect(std.mem.indexOf(u8, response1, "\"code\":-32601") != null) catch return;
|
||||
|
||||
// 2. Invalid params (missing script for evaluate)
|
||||
harness.sendRequest(
|
||||
\\{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"evaluate","arguments":{}}}
|
||||
) catch return;
|
||||
|
||||
const response2 = harness.readResponse(arena.allocator()) catch return;
|
||||
testing.expect(std.mem.indexOf(u8, response2, "\"id\":2") != null) catch return;
|
||||
testing.expect(std.mem.indexOf(u8, response2, "\"code\":-32602") != null) catch return;
|
||||
}
|
||||
|
||||
test "MCP Integration: resources" {
|
||||
const harness = try McpHarness.init(testing.allocator, testing.test_app);
|
||||
defer harness.deinit();
|
||||
|
||||
harness.thread = try std.Thread.spawn(.{}, testResources, .{harness});
|
||||
try harness.runServer();
|
||||
}
|
||||
|
||||
fn testResources(harness: *McpHarness) void {
|
||||
defer harness.server.is_running.store(false, .release);
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(harness.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
// 1. List resources
|
||||
harness.sendRequest(
|
||||
\\{"jsonrpc":"2.0","id":1,"method":"resources/list"}
|
||||
) catch return;
|
||||
|
||||
const response1 = harness.readResponse(arena.allocator()) catch return;
|
||||
testing.expect(std.mem.indexOf(u8, response1, "\"uri\":\"mcp://page/html\"") != null) catch return;
|
||||
|
||||
// 2. Read resource
|
||||
harness.sendRequest(
|
||||
\\{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"mcp://page/html"}}
|
||||
) catch return;
|
||||
|
||||
const response2 = harness.readResponse(arena.allocator()) catch return;
|
||||
testing.expect(std.mem.indexOf(u8, response2, "\"id\":2") != null) catch return;
|
||||
// Check for some HTML content
|
||||
testing.expect(std.mem.indexOf(u8, response2, "<html>") != null) catch return;
|
||||
}
|
||||
|
||||
test "MCP Integration: tools markdown and links" {
|
||||
const harness = try McpHarness.init(testing.allocator, testing.test_app);
|
||||
defer harness.deinit();
|
||||
|
||||
harness.thread = try std.Thread.spawn(.{}, testMarkdownAndLinks, .{harness});
|
||||
try harness.runServer();
|
||||
}
|
||||
|
||||
fn testMarkdownAndLinks(harness: *McpHarness) void {
|
||||
defer harness.server.is_running.store(false, .release);
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(harness.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
// 1. Test markdown
|
||||
harness.sendRequest(
|
||||
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"markdown"}}
|
||||
) catch return;
|
||||
|
||||
const response1 = harness.readResponse(arena.allocator()) catch return;
|
||||
testing.expect(std.mem.indexOf(u8, response1, "\"id\":1") != null) catch return;
|
||||
|
||||
// 2. Test links
|
||||
harness.sendRequest(
|
||||
\\{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"links"}}
|
||||
) catch return;
|
||||
|
||||
const response2 = harness.readResponse(arena.allocator()) catch return;
|
||||
testing.expect(std.mem.indexOf(u8, response2, "\"id\":2") != null) catch return;
|
||||
}
|
||||
|
||||
@@ -218,3 +218,27 @@ test "protocol error formatting" {
|
||||
|
||||
try testing.expectString("{\"jsonrpc\":\"2.0\",\"id\":\"abc\",\"error\":{\"code\":-32601,\"message\":\"Method not found\"}}", aw.written());
|
||||
}
|
||||
|
||||
test "JsonEscapingWriter" {
|
||||
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer aw.deinit();
|
||||
|
||||
var escaping_writer = JsonEscapingWriter.init(&aw.writer);
|
||||
|
||||
// test newlines and quotes
|
||||
try escaping_writer.writer.writeAll("hello\n\"world\"");
|
||||
|
||||
// the writer outputs escaped string chars without surrounding quotes
|
||||
try testing.expectString("hello\\n\\\"world\\\"", aw.written());
|
||||
}
|
||||
|
||||
test "RawJson serialization" {
|
||||
const raw = RawJson{ .json = "{\"test\": 123}" };
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer aw.deinit();
|
||||
|
||||
try std.json.Stringify.value(raw, .{}, &aw.writer);
|
||||
|
||||
try testing.expectString("{\"test\":123}", aw.written());
|
||||
}
|
||||
|
||||
@@ -105,3 +105,21 @@ pub fn handleRead(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
|
||||
return server.sendError(req.id.?, .InvalidRequest, "Resource not found");
|
||||
}
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
|
||||
test "resource_list contains expected resources" {
|
||||
try testing.expect(resource_list.len >= 2);
|
||||
try testing.expectString("mcp://page/html", resource_list[0].uri);
|
||||
try testing.expectString("mcp://page/markdown", resource_list[1].uri);
|
||||
}
|
||||
|
||||
test "ReadParams parsing" {
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const aa = arena.allocator();
|
||||
|
||||
const raw = "{\"uri\": \"mcp://page/html\"}";
|
||||
const parsed = try std.json.parseFromSlice(ReadParams, aa, raw, .{});
|
||||
try testing.expectString("mcp://page/html", parsed.value.uri);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ const resources = @import("resources.zig");
|
||||
const Server = @import("Server.zig");
|
||||
const tools = @import("tools.zig");
|
||||
|
||||
pub fn processRequests(server: *Server) !void {
|
||||
pub fn processRequests(server: *Server, in_stream: std.fs.File) !void {
|
||||
server.is_running.store(true, .release);
|
||||
|
||||
const Streams = enum { stdin };
|
||||
var poller = std.io.poll(server.allocator, Streams, .{ .stdin = std.fs.File.stdin() });
|
||||
var poller = std.io.poll(server.allocator, Streams, .{ .stdin = in_stream });
|
||||
defer poller.deinit();
|
||||
|
||||
const reader = poller.reader(.stdin);
|
||||
@@ -67,6 +67,7 @@ fn handleMessage(server: *Server, arena: std.mem.Allocator, msg: []const u8) !vo
|
||||
.ignore_unknown_fields = true,
|
||||
}) catch |err| {
|
||||
log.warn(.mcp, "JSON Parse Error", .{ .err = err, .msg = msg });
|
||||
try server.sendError(.null, .ParseError, "Parse error");
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -110,3 +111,27 @@ fn handleInitialize(server: *Server, req: protocol.Request) !void {
|
||||
|
||||
try server.sendResult(req.id.?, result);
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
const McpHarness = @import("testing.zig").McpHarness;
|
||||
|
||||
test "handleMessage - ParseError" {
|
||||
const harness = try McpHarness.init(testing.allocator, testing.test_app);
|
||||
defer harness.deinit();
|
||||
|
||||
harness.thread = try std.Thread.spawn(.{}, testParseError, .{harness});
|
||||
try harness.runServer();
|
||||
}
|
||||
|
||||
fn testParseError(harness: *McpHarness) void {
|
||||
defer harness.server.is_running.store(false, .release);
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(harness.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
harness.sendRequest("invalid json") catch return;
|
||||
|
||||
const response = harness.readResponse(arena.allocator()) catch return;
|
||||
testing.expect(std.mem.indexOf(u8, response, "\"id\":null") != null) catch return;
|
||||
testing.expect(std.mem.indexOf(u8, response, "\"code\":-32700") != null) catch return;
|
||||
}
|
||||
|
||||
104
src/mcp/testing.zig
Normal file
104
src/mcp/testing.zig
Normal file
@@ -0,0 +1,104 @@
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const App = @import("../App.zig");
|
||||
const Server = @import("Server.zig");
|
||||
const router = @import("router.zig");
|
||||
|
||||
pub const McpHarness = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
app: *App,
|
||||
server: *Server,
|
||||
|
||||
// Client view of the communication
|
||||
client_in: std.fs.File, // Client reads from this (server's stdout)
|
||||
client_out: std.fs.File, // Client writes to this (server's stdin)
|
||||
|
||||
// Server view of the communication
|
||||
server_in: std.fs.File, // Server reads from this (client's stdout)
|
||||
server_out: std.fs.File, // Server writes to this (client's stdin)
|
||||
|
||||
thread: ?std.Thread = null,
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator, app: *App) !*McpHarness {
|
||||
const self = try allocator.create(McpHarness);
|
||||
errdefer allocator.destroy(self);
|
||||
|
||||
self.allocator = allocator;
|
||||
self.app = app;
|
||||
self.thread = null;
|
||||
|
||||
// Pipe for Server Stdin (Client writes, Server reads)
|
||||
const server_stdin_pipe = try std.posix.pipe();
|
||||
errdefer {
|
||||
std.posix.close(server_stdin_pipe[0]);
|
||||
std.posix.close(server_stdin_pipe[1]);
|
||||
}
|
||||
self.server_in = .{ .handle = server_stdin_pipe[0] };
|
||||
self.client_out = .{ .handle = server_stdin_pipe[1] };
|
||||
|
||||
// Pipe for Server Stdout (Server writes, Client reads)
|
||||
const server_stdout_pipe = try std.posix.pipe();
|
||||
errdefer {
|
||||
std.posix.close(server_stdout_pipe[0]);
|
||||
std.posix.close(server_stdout_pipe[1]);
|
||||
self.server_in.close();
|
||||
self.client_out.close();
|
||||
}
|
||||
self.client_in = .{ .handle = server_stdout_pipe[0] };
|
||||
self.server_out = .{ .handle = server_stdout_pipe[1] };
|
||||
|
||||
self.server = try Server.init(allocator, app, self.server_out);
|
||||
errdefer self.server.deinit();
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *McpHarness) void {
|
||||
self.server.is_running.store(false, .release);
|
||||
|
||||
// Unblock poller if it's waiting for stdin
|
||||
self.client_out.writeAll("\n") catch {};
|
||||
|
||||
if (self.thread) |t| t.join();
|
||||
|
||||
self.server.deinit();
|
||||
|
||||
self.server_in.close();
|
||||
self.server_out.close();
|
||||
self.client_in.close();
|
||||
self.client_out.close();
|
||||
|
||||
self.allocator.destroy(self);
|
||||
}
|
||||
|
||||
pub fn runServer(self: *McpHarness) !void {
|
||||
try router.processRequests(self.server, self.server_in);
|
||||
}
|
||||
|
||||
pub fn sendRequest(self: *McpHarness, request_json: []const u8) !void {
|
||||
try self.client_out.writeAll(request_json);
|
||||
if (request_json.len > 0 and request_json[request_json.len - 1] != '\n') {
|
||||
try self.client_out.writeAll("\n");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn readResponse(self: *McpHarness, arena: std.mem.Allocator) ![]const u8 {
|
||||
const Streams = enum { stdout };
|
||||
var poller = std.io.poll(self.allocator, Streams, .{ .stdout = self.client_in });
|
||||
defer poller.deinit();
|
||||
|
||||
var timeout_count: usize = 0;
|
||||
while (timeout_count < 20) : (timeout_count += 1) {
|
||||
const poll_result = try poller.pollTimeout(100 * std.time.ns_per_ms);
|
||||
const r = poller.reader(.stdout);
|
||||
const buffered = r.buffered();
|
||||
if (std.mem.indexOfScalar(u8, buffered, '\n')) |idx| {
|
||||
const line = try arena.dupe(u8, buffered[0..idx]);
|
||||
r.toss(idx + 1);
|
||||
return line;
|
||||
}
|
||||
if (!poll_result) return error.EndOfStream;
|
||||
}
|
||||
return error.Timeout;
|
||||
}
|
||||
};
|
||||
@@ -322,3 +322,33 @@ fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void {
|
||||
|
||||
_ = server.session.wait(5000);
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
|
||||
test "tool_list contains expected tools" {
|
||||
try testing.expect(tool_list.len >= 6);
|
||||
try testing.expectString("goto", tool_list[0].name);
|
||||
}
|
||||
|
||||
test "parseParams - valid" {
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const aa = arena.allocator();
|
||||
|
||||
const arguments = try std.json.parseFromSlice(std.json.Value, aa, "{\"url\": \"https://example.com\"}", .{});
|
||||
|
||||
const args = try parseParamsOptional(GotoParams, aa, arguments.value);
|
||||
try testing.expect(args != null);
|
||||
try testing.expectString("https://example.com", args.?.url);
|
||||
}
|
||||
|
||||
test "parseParams - invalid" {
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const aa = arena.allocator();
|
||||
|
||||
const arguments = try std.json.parseFromSlice(std.json.Value, aa, "{\"not_url\": \"foo\"}", .{});
|
||||
|
||||
const args = try parseParamsOptional(GotoParams, aa, arguments.value);
|
||||
try testing.expect(args == null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user