diff --git a/src/main.zig b/src/main.zig index 31c9f8bb..97883de4 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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, } diff --git a/src/mcp.zig b/src/mcp.zig index 41998f5a..9af6fd2d 100644 --- a/src/mcp.zig +++ b/src/mcp.zig @@ -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"); diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig index cb2cd9c4..fad4128b 100644 --- a/src/mcp/Server.zig +++ b/src/mcp/Server.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, "") != 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; +} diff --git a/src/mcp/protocol.zig b/src/mcp/protocol.zig index a8c7560d..baec24a9 100644 --- a/src/mcp/protocol.zig +++ b/src/mcp/protocol.zig @@ -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()); +} diff --git a/src/mcp/resources.zig b/src/mcp/resources.zig index 7b5ef6bc..a47fcc11 100644 --- a/src/mcp/resources.zig +++ b/src/mcp/resources.zig @@ -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); +} diff --git a/src/mcp/router.zig b/src/mcp/router.zig index 01184ecb..9d1e96aa 100644 --- a/src/mcp/router.zig +++ b/src/mcp/router.zig @@ -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; +} diff --git a/src/mcp/testing.zig b/src/mcp/testing.zig new file mode 100644 index 00000000..1c8dd882 --- /dev/null +++ b/src/mcp/testing.zig @@ -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; + } +}; diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 83a564ef..46f0757d 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -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); +}