Merge pull request #1673 from arrufat/mcp
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled

Add Model Context Protocol (MCP) server support
This commit is contained in:
Pierre Tachoire
2026-03-03 15:18:34 +01:00
committed by GitHub
11 changed files with 1057 additions and 15 deletions

View File

@@ -28,6 +28,7 @@ pub const RunMode = enum {
fetch, fetch,
serve, serve,
version, version,
mcp,
}; };
pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096; pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096;
@@ -59,56 +60,56 @@ pub fn deinit(self: *const Config, allocator: Allocator) void {
pub fn tlsVerifyHost(self: *const Config) bool { pub fn tlsVerifyHost(self: *const Config) bool {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.tls_verify_host, inline .serve, .fetch, .mcp => |opts| opts.common.tls_verify_host,
else => unreachable, else => unreachable,
}; };
} }
pub fn obeyRobots(self: *const Config) bool { pub fn obeyRobots(self: *const Config) bool {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.obey_robots, inline .serve, .fetch, .mcp => |opts| opts.common.obey_robots,
else => unreachable, else => unreachable,
}; };
} }
pub fn httpProxy(self: *const Config) ?[:0]const u8 { pub fn httpProxy(self: *const Config) ?[:0]const u8 {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_proxy, inline .serve, .fetch, .mcp => |opts| opts.common.http_proxy,
else => unreachable, else => unreachable,
}; };
} }
pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 { pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.proxy_bearer_token, inline .serve, .fetch, .mcp => |opts| opts.common.proxy_bearer_token,
.help, .version => null, .help, .version => null,
}; };
} }
pub fn httpMaxConcurrent(self: *const Config) u8 { pub fn httpMaxConcurrent(self: *const Config) u8 {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_max_concurrent orelse 10, inline .serve, .fetch, .mcp => |opts| opts.common.http_max_concurrent orelse 10,
else => unreachable, else => unreachable,
}; };
} }
pub fn httpMaxHostOpen(self: *const Config) u8 { pub fn httpMaxHostOpen(self: *const Config) u8 {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_max_host_open orelse 4, inline .serve, .fetch, .mcp => |opts| opts.common.http_max_host_open orelse 4,
else => unreachable, else => unreachable,
}; };
} }
pub fn httpConnectTimeout(self: *const Config) u31 { pub fn httpConnectTimeout(self: *const Config) u31 {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_connect_timeout orelse 0, inline .serve, .fetch, .mcp => |opts| opts.common.http_connect_timeout orelse 0,
else => unreachable, else => unreachable,
}; };
} }
pub fn httpTimeout(self: *const Config) u31 { pub fn httpTimeout(self: *const Config) u31 {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_timeout orelse 5000, inline .serve, .fetch, .mcp => |opts| opts.common.http_timeout orelse 5000,
else => unreachable, else => unreachable,
}; };
} }
@@ -119,35 +120,35 @@ pub fn httpMaxRedirects(_: *const Config) u8 {
pub fn httpMaxResponseSize(self: *const Config) ?usize { pub fn httpMaxResponseSize(self: *const Config) ?usize {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_max_response_size, inline .serve, .fetch, .mcp => |opts| opts.common.http_max_response_size,
else => unreachable, else => unreachable,
}; };
} }
pub fn logLevel(self: *const Config) ?log.Level { pub fn logLevel(self: *const Config) ?log.Level {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.log_level, inline .serve, .fetch, .mcp => |opts| opts.common.log_level,
else => unreachable, else => unreachable,
}; };
} }
pub fn logFormat(self: *const Config) ?log.Format { pub fn logFormat(self: *const Config) ?log.Format {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.log_format, inline .serve, .fetch, .mcp => |opts| opts.common.log_format,
else => unreachable, else => unreachable,
}; };
} }
pub fn logFilterScopes(self: *const Config) ?[]const log.Scope { pub fn logFilterScopes(self: *const Config) ?[]const log.Scope {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.log_filter_scopes, inline .serve, .fetch, .mcp => |opts| opts.common.log_filter_scopes,
else => unreachable, else => unreachable,
}; };
} }
pub fn userAgentSuffix(self: *const Config) ?[]const u8 { pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.user_agent_suffix, inline .serve, .fetch, .mcp => |opts| opts.common.user_agent_suffix,
.help, .version => null, .help, .version => null,
}; };
} }
@@ -171,6 +172,7 @@ pub const Mode = union(RunMode) {
fetch: Fetch, fetch: Fetch,
serve: Serve, serve: Serve,
version: void, version: void,
mcp: Mcp,
}; };
pub const Serve = struct { pub const Serve = struct {
@@ -182,6 +184,10 @@ pub const Serve = struct {
common: Common = .{}, common: Common = .{},
}; };
pub const Mcp = struct {
common: Common = .{},
};
pub const DumpFormat = enum { pub const DumpFormat = enum {
html, html,
markdown, markdown,
@@ -324,7 +330,7 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
const usage = const usage =
\\usage: {s} command [options] [URL] \\usage: {s} command [options] [URL]
\\ \\
\\Command can be either 'fetch', 'serve' or 'help' \\Command can be either 'fetch', 'serve', 'mcp' or 'help'
\\ \\
\\fetch command \\fetch command
\\Fetches the specified URL \\Fetches the specified URL
@@ -370,6 +376,12 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\ Maximum pending connections in the accept queue. \\ Maximum pending connections in the accept queue.
\\ Defaults to 128. \\ Defaults to 128.
\\ \\
++ common_options ++
\\
\\mcp command
\\Starts an MCP (Model Context Protocol) server over stdio
\\Example: {s} mcp
\\
++ common_options ++ ++ common_options ++
\\ \\
\\version command \\version command
@@ -379,7 +391,7 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\Displays this message \\Displays this message
\\ \\
; ;
std.debug.print(usage, .{ self.exec_name, self.exec_name, self.exec_name, self.exec_name }); std.debug.print(usage, .{ self.exec_name, self.exec_name, self.exec_name, self.exec_name, self.exec_name });
if (success) { if (success) {
return std.process.cleanExit(); return std.process.cleanExit();
} }
@@ -414,6 +426,8 @@ pub fn parseArgs(allocator: Allocator) !Config {
return init(allocator, exec_name, .{ .help = false }) }, return init(allocator, exec_name, .{ .help = false }) },
.fetch => .{ .fetch = parseFetchArgs(allocator, &args) catch .fetch => .{ .fetch = parseFetchArgs(allocator, &args) catch
return init(allocator, exec_name, .{ .help = false }) }, return init(allocator, exec_name, .{ .help = false }) },
.mcp => .{ .mcp = parseMcpArgs(allocator, &args) catch
return init(allocator, exec_name, .{ .help = false }) },
.version => .{ .version = {} }, .version => .{ .version = {} },
}; };
return init(allocator, exec_name, mode); return init(allocator, exec_name, mode);
@@ -542,6 +556,24 @@ fn parseServeArgs(
return serve; return serve;
} }
fn parseMcpArgs(
allocator: Allocator,
args: *std.process.ArgIterator,
) !Mcp {
var mcp: Mcp = .{};
while (args.next()) |opt| {
if (try parseCommonArg(allocator, opt, args, &mcp.common)) {
continue;
}
log.fatal(.mcp, "unknown argument", .{ .mode = "mcp", .arg = opt });
return error.UnkownOption;
}
return mcp;
}
fn parseFetchArgs( fn parseFetchArgs(
allocator: Allocator, allocator: Allocator,
args: *std.process.ArgIterator, args: *std.process.ArgIterator,

View File

@@ -134,4 +134,17 @@ pub const Caught = struct {
try writer.write(prefix ++ ".line", self.line); try writer.write(prefix ++ ".line", self.line);
try writer.write(prefix ++ ".caught", self.caught); try writer.write(prefix ++ ".caught", self.caught);
} }
pub fn jsonStringify(self: Caught, jw: anytype) !void {
try jw.beginObject();
try jw.objectField("exception");
try jw.write(self.exception);
try jw.objectField("stack");
try jw.write(self.stack);
try jw.objectField("line");
try jw.write(self.line);
try jw.objectField("caught");
try jw.write(self.caught);
try jw.endObject();
}
}; };

View File

@@ -30,6 +30,7 @@ pub const log = @import("log.zig");
pub const js = @import("browser/js/js.zig"); pub const js = @import("browser/js/js.zig");
pub const dump = @import("browser/dump.zig"); pub const dump = @import("browser/dump.zig");
pub const markdown = @import("browser/markdown.zig"); pub const markdown = @import("browser/markdown.zig");
pub const mcp = @import("mcp.zig");
pub const build_config = @import("build_config"); pub const build_config = @import("build_config");
pub const crash_handler = @import("crash_handler.zig"); pub const crash_handler = @import("crash_handler.zig");

View File

@@ -38,6 +38,7 @@ pub const Scope = enum {
not_implemented, not_implemented,
telemetry, telemetry,
unknown_prop, unknown_prop,
mcp,
}; };
const Opts = struct { const Opts = struct {

View File

@@ -131,6 +131,21 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
return err; return err;
}; };
}, },
.mcp => {
log.info(.mcp, "starting server", .{});
log.opts.format = .logfmt;
var stdout = std.fs.File.stdout().writer(&.{});
var mcp_server: *lp.mcp.Server = try .init(allocator, app, &stdout.interface);
defer mcp_server.deinit();
var stdin_buf: [64 * 1024]u8 = undefined;
var stdin = std.fs.File.stdin().reader(&stdin_buf);
try lp.mcp.router.processRequests(mcp_server, &stdin.interface);
},
else => unreachable, else => unreachable,
} }
} }

9
src/mcp.zig Normal file
View File

@@ -0,0 +1,9 @@
const std = @import("std");
pub const protocol = @import("mcp/protocol.zig");
pub const router = @import("mcp/router.zig");
pub const Server = @import("mcp/Server.zig");
test {
std.testing.refAllDecls(@This());
}

111
src/mcp/Server.zig Normal file
View File

@@ -0,0 +1,111 @@
const std = @import("std");
const lp = @import("lightpanda");
const App = @import("../App.zig");
const HttpClient = @import("../http/Client.zig");
const testing = @import("../testing.zig");
const protocol = @import("protocol.zig");
const router = @import("router.zig");
const Self = @This();
allocator: std.mem.Allocator,
app: *App,
http_client: *HttpClient,
notification: *lp.Notification,
browser: lp.Browser,
session: *lp.Session,
page: *lp.Page,
writer: *std.io.Writer,
mutex: std.Thread.Mutex = .{},
aw: std.io.Writer.Allocating,
pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*Self {
const self = try allocator.create(Self);
errdefer allocator.destroy(self);
self.allocator = allocator;
self.app = app;
self.writer = writer;
self.aw = .init(allocator);
self.http_client = try app.http.createClient(allocator);
errdefer self.http_client.deinit();
self.notification = try .init(allocator);
errdefer self.notification.deinit();
self.browser = try lp.Browser.init(app, .{ .http_client = self.http_client });
errdefer self.browser.deinit();
self.session = try self.browser.newSession(self.notification);
self.page = try self.session.createPage();
return self;
}
pub fn deinit(self: *Self) void {
self.aw.deinit();
self.browser.deinit();
self.notification.deinit();
self.http_client.deinit();
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,
},
});
}
test "MCP.Server - Integration: synchronous smoke test" {
defer testing.reset();
const allocator = testing.allocator;
const app = testing.test_app;
const input =
\\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}
;
var in_reader: std.io.Reader = .fixed(input);
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator);
defer out_alloc.deinit();
var server = try Self.init(allocator, app, &out_alloc.writer);
defer server.deinit();
try router.processRequests(server, &in_reader);
try testing.expectJson(.{ .id = 1 }, out_alloc.writer.buffered());
}

304
src/mcp/protocol.zig Normal file
View File

@@ -0,0 +1,304 @@
const std = @import("std");
pub const Request = struct {
jsonrpc: []const u8 = "2.0",
id: ?std.json.Value = null,
method: []const u8,
params: ?std.json.Value = null,
};
pub const Response = struct {
jsonrpc: []const u8 = "2.0",
id: std.json.Value,
result: ?std.json.Value = null,
@"error": ?Error = null,
};
pub const Error = struct {
code: i64,
message: []const u8,
data: ?std.json.Value = null,
};
pub const ErrorCode = enum(i64) {
ParseError = -32700,
InvalidRequest = -32600,
MethodNotFound = -32601,
InvalidParams = -32602,
InternalError = -32603,
};
pub const Notification = struct {
jsonrpc: []const u8 = "2.0",
method: []const u8,
params: ?std.json.Value = null,
};
// Core MCP Types mapping to official specification
pub const InitializeRequest = struct {
jsonrpc: []const u8 = "2.0",
id: std.json.Value,
method: []const u8 = "initialize",
params: InitializeParams,
};
pub const InitializeParams = struct {
protocolVersion: []const u8,
capabilities: Capabilities,
clientInfo: Implementation,
};
pub const Capabilities = struct {
experimental: ?std.json.Value = null,
roots: ?RootsCapability = null,
sampling: ?SamplingCapability = null,
};
pub const RootsCapability = struct {
listChanged: ?bool = null,
};
pub const SamplingCapability = struct {};
pub const Implementation = struct {
name: []const u8,
version: []const u8,
};
pub const InitializeResult = struct {
protocolVersion: []const u8,
capabilities: ServerCapabilities,
serverInfo: Implementation,
};
pub const ServerCapabilities = struct {
experimental: ?std.json.Value = null,
logging: ?LoggingCapability = null,
prompts: ?PromptsCapability = null,
resources: ?ResourcesCapability = null,
tools: ?ToolsCapability = null,
};
pub const LoggingCapability = struct {};
pub const PromptsCapability = struct {
listChanged: ?bool = null,
};
pub const ResourcesCapability = struct {
subscribe: ?bool = null,
listChanged: ?bool = null,
};
pub const ToolsCapability = struct {
listChanged: ?bool = null,
};
pub const Tool = struct {
name: []const u8,
description: ?[]const u8 = null,
inputSchema: []const u8,
pub fn jsonStringify(self: @This(), jw: anytype) !void {
try jw.beginObject();
try jw.objectField("name");
try jw.write(self.name);
if (self.description) |d| {
try jw.objectField("description");
try jw.write(d);
}
try jw.objectField("inputSchema");
_ = try jw.beginWriteRaw();
try jw.writer.writeAll(self.inputSchema);
jw.endWriteRaw();
try jw.endObject();
}
};
pub fn minify(comptime json: []const u8) []const u8 {
return comptime blk: {
var res: []const u8 = "";
var in_string = false;
var escaped = false;
for (json) |c| {
if (in_string) {
res = res ++ [1]u8{c};
if (escaped) {
escaped = false;
} else if (c == '\\') {
escaped = true;
} else if (c == '"') {
in_string = false;
}
} else {
switch (c) {
' ', '\n', '\r', '\t' => continue,
'"' => {
in_string = true;
res = res ++ [1]u8{c};
},
else => res = res ++ [1]u8{c},
}
}
}
break :blk res;
};
}
pub const Resource = struct {
uri: []const u8,
name: []const u8,
description: ?[]const u8 = null,
mimeType: ?[]const u8 = null,
};
pub fn TextContent(comptime T: type) type {
return struct {
type: []const u8 = "text",
text: T,
};
}
pub fn CallToolResult(comptime T: type) type {
return struct {
content: []const TextContent(T),
isError: bool = false,
};
}
pub const JsonEscapingWriter = struct {
inner_writer: *std.Io.Writer,
writer: std.Io.Writer,
pub fn init(inner_writer: *std.Io.Writer) JsonEscapingWriter {
return .{
.inner_writer = inner_writer,
.writer = .{
.vtable = &vtable,
.buffer = &.{},
},
};
}
const vtable = std.Io.Writer.VTable{
.drain = drain,
};
fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize {
const self: *JsonEscapingWriter = @alignCast(@fieldParentPtr("writer", w));
var total: usize = 0;
for (data[0 .. data.len - 1]) |slice| {
std.json.Stringify.encodeJsonStringChars(slice, .{}, self.inner_writer) catch return error.WriteFailed;
total += slice.len;
}
const pattern = data[data.len - 1];
for (0..splat) |_| {
std.json.Stringify.encodeJsonStringChars(pattern, .{}, self.inner_writer) catch return error.WriteFailed;
total += pattern.len;
}
return total;
}
};
const testing = @import("../testing.zig");
test "MCP.protocol - request parsing" {
defer testing.reset();
const raw_json =
\\{
\\ "jsonrpc": "2.0",
\\ "id": 1,
\\ "method": "initialize",
\\ "params": {
\\ "protocolVersion": "2024-11-05",
\\ "capabilities": {},
\\ "clientInfo": {
\\ "name": "test-client",
\\ "version": "1.0.0"
\\ }
\\ }
\\}
;
const parsed = try std.json.parseFromSlice(Request, testing.arena_allocator, raw_json, .{ .ignore_unknown_fields = true });
defer parsed.deinit();
const req = parsed.value;
try testing.expectString("2.0", req.jsonrpc);
try testing.expectString("initialize", req.method);
try testing.expect(req.id.? == .integer);
try testing.expectEqual(@as(i64, 1), req.id.?.integer);
try testing.expect(req.params != null);
// Test nested parsing of InitializeParams
const init_params = try std.json.parseFromValue(InitializeParams, testing.arena_allocator, req.params.?, .{ .ignore_unknown_fields = true });
defer init_params.deinit();
try testing.expectString("2024-11-05", init_params.value.protocolVersion);
try testing.expectString("test-client", init_params.value.clientInfo.name);
try testing.expectString("1.0.0", init_params.value.clientInfo.version);
}
test "MCP.protocol - response formatting" {
defer testing.reset();
const response = Response{
.id = .{ .integer = 42 },
.result = .{ .string = "success" },
};
var aw: std.Io.Writer.Allocating = .init(testing.arena_allocator);
defer aw.deinit();
try std.json.Stringify.value(response, .{ .emit_null_optional_fields = false }, &aw.writer);
try testing.expectString("{\"jsonrpc\":\"2.0\",\"id\":42,\"result\":\"success\"}", aw.written());
}
test "MCP.protocol - error formatting" {
defer testing.reset();
const response = Response{
.id = .{ .string = "abc" },
.@"error" = .{
.code = @intFromEnum(ErrorCode.MethodNotFound),
.message = "Method not found",
},
};
var aw: std.Io.Writer.Allocating = .init(testing.arena_allocator);
defer aw.deinit();
try std.json.Stringify.value(response, .{ .emit_null_optional_fields = false }, &aw.writer);
try testing.expectString("{\"jsonrpc\":\"2.0\",\"id\":\"abc\",\"error\":{\"code\":-32601,\"message\":\"Method not found\"}}", aw.written());
}
test "MCP.protocol - JsonEscapingWriter" {
defer testing.reset();
var aw: std.Io.Writer.Allocating = .init(testing.arena_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 "MCP.protocol - Tool serialization" {
defer testing.reset();
const t = Tool{
.name = "test",
.inputSchema = minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "foo": { "type": "string" }
\\ }
\\}
),
};
var aw: std.Io.Writer.Allocating = .init(testing.arena_allocator);
defer aw.deinit();
try std.json.Stringify.value(t, .{}, &aw.writer);
try testing.expectString("{\"name\":\"test\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"foo\":{\"type\":\"string\"}}}}", aw.written());
}

108
src/mcp/resources.zig Normal file
View File

@@ -0,0 +1,108 @@
const std = @import("std");
const lp = @import("lightpanda");
const log = lp.log;
const protocol = @import("protocol.zig");
const Server = @import("Server.zig");
pub const resource_list = [_]protocol.Resource{
.{
.uri = "mcp://page/html",
.name = "Page HTML",
.description = "The serialized HTML DOM of the current page",
.mimeType = "text/html",
},
.{
.uri = "mcp://page/markdown",
.name = "Page Markdown",
.description = "The token-efficient markdown representation of the current page",
.mimeType = "text/markdown",
},
};
pub fn handleList(server: *Server, req: protocol.Request) !void {
try server.sendResult(req.id.?, .{ .resources = &resource_list });
}
const ReadParams = struct {
uri: []const u8,
};
const ResourceStreamingResult = struct {
contents: []const struct {
uri: []const u8,
mimeType: []const u8,
text: StreamingText,
},
const StreamingText = struct {
server: *Server,
format: enum { html, markdown },
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void {
try jw.beginWriteRaw();
try jw.writer.writeByte('"');
var escaped = protocol.JsonEscapingWriter.init(jw.writer);
switch (self.format) {
.html => lp.dump.root(self.server.page.document, .{}, &escaped.writer, self.server.page) catch |err| {
log.err(.mcp, "html dump failed", .{ .err = err });
},
.markdown => lp.markdown.dump(self.server.page.document.asNode(), .{}, &escaped.writer, self.server.page) catch |err| {
log.err(.mcp, "markdown dump failed", .{ .err = err });
},
}
try jw.writer.writeByte('"');
jw.endWriteRaw();
}
};
};
const ResourceUri = enum {
@"mcp://page/html",
@"mcp://page/markdown",
};
const resource_map = std.StaticStringMap(ResourceUri).initComptime(.{
.{ "mcp://page/html", .@"mcp://page/html" },
.{ "mcp://page/markdown", .@"mcp://page/markdown" },
});
pub fn handleRead(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
if (req.params == null) {
return server.sendError(req.id.?, .InvalidParams, "Missing params");
}
const params = std.json.parseFromValueLeaky(ReadParams, arena, req.params.?, .{ .ignore_unknown_fields = true }) catch {
return server.sendError(req.id.?, .InvalidParams, "Invalid params");
};
const uri = resource_map.get(params.uri) orelse {
return server.sendError(req.id.?, .InvalidRequest, "Resource not found");
};
switch (uri) {
.@"mcp://page/html" => {
const result: ResourceStreamingResult = .{
.contents = &.{.{
.uri = params.uri,
.mimeType = "text/html",
.text = .{ .server = server, .format = .html },
}},
};
try server.sendResult(req.id.?, result);
},
.@"mcp://page/markdown" => {
const result: ResourceStreamingResult = .{
.contents = &.{.{
.uri = params.uri,
.mimeType = "text/markdown",
.text = .{ .server = server, .format = .markdown },
}},
};
try server.sendResult(req.id.?, result);
},
}
}
const testing = @import("../testing.zig");

143
src/mcp/router.zig Normal file
View File

@@ -0,0 +1,143 @@
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");
pub fn processRequests(server: *Server, reader: *std.io.Reader) !void {
var arena: std.heap.ArenaAllocator = .init(server.allocator);
defer arena.deinit();
while (true) {
_ = arena.reset(.retain_capacity);
const aa = arena.allocator();
const buffered_line = reader.takeDelimiter('\n') catch |err| switch (err) {
error.StreamTooLong => {
log.err(.mcp, "Message too long", .{});
continue;
},
else => return err,
} orelse break;
const trimmed = std.mem.trim(u8, buffered_line, " \r\t");
if (trimmed.len > 0) {
handleMessage(server, aa, trimmed) catch |err| {
log.err(.mcp, "Failed to handle message", .{ .err = err, .msg = trimmed });
};
}
}
}
const log = @import("../log.zig");
const Method = enum {
initialize,
@"notifications/initialized",
@"tools/list",
@"tools/call",
@"resources/list",
@"resources/read",
};
const method_map = std.StaticStringMap(Method).initComptime(.{
.{ "initialize", .initialize },
.{ "notifications/initialized", .@"notifications/initialized" },
.{ "tools/list", .@"tools/list" },
.{ "tools/call", .@"tools/call" },
.{ "resources/list", .@"resources/list" },
.{ "resources/read", .@"resources/read" },
});
pub fn handleMessage(server: *Server, 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");
return;
};
const method = method_map.get(req.method) orelse {
if (req.id != null) {
try server.sendError(req.id.?, .MethodNotFound, "Method not found");
}
return;
};
switch (method) {
.initialize => try handleInitialize(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),
}
}
fn handleInitialize(server: *Server, req: protocol.Request) !void {
const result = protocol.InitializeResult{
.protocolVersion = "2025-11-25",
.capabilities = .{
.resources = .{},
.tools = .{},
},
.serverInfo = .{
.name = "lightpanda",
.version = "0.1.0",
},
};
try server.sendResult(req.id.?, result);
}
const testing = @import("../testing.zig");
test "MCP.router - handleMessage - synchronous unit tests" {
defer testing.reset();
const allocator = testing.allocator;
const app = testing.test_app;
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator);
defer out_alloc.deinit();
var server = try Server.init(allocator, app, &out_alloc.writer);
defer server.deinit();
const aa = testing.arena_allocator;
// 1. Valid handshake
try handleMessage(server, aa,
\\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}
);
try testing.expectJson(
\\{ "id": 1, "result": { "capabilities": { "tools": {} } } }
, out_alloc.writer.buffered());
out_alloc.writer.end = 0;
// 2. Tools list
try handleMessage(server, aa,
\\{"jsonrpc":"2.0","id":2,"method":"tools/list"}
);
try testing.expectJson(.{ .id = 2 }, out_alloc.writer.buffered());
try testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "\"name\":\"goto\"") != null);
out_alloc.writer.end = 0;
// 3. Method not found
try handleMessage(server, aa,
\\{"jsonrpc":"2.0","id":3,"method":"unknown_method"}
);
try testing.expectJson(.{ .id = 3, .@"error" = .{ .code = -32601 } }, out_alloc.writer.buffered());
out_alloc.writer.end = 0;
// 4. Parse error
{
const old_filter = log.opts.filter_scopes;
log.opts.filter_scopes = &.{.mcp};
defer log.opts.filter_scopes = old_filter;
try handleMessage(server, aa, "invalid json");
try testing.expectJson("{\"id\": null, \"error\": {\"code\": -32700}}", out_alloc.writer.buffered());
}
}

305
src/mcp/tools.zig Normal file
View File

@@ -0,0 +1,305 @@
const std = @import("std");
const lp = @import("lightpanda");
const log = lp.log;
const js = lp.js;
const Element = @import("../browser/webapi/Element.zig");
const Selector = @import("../browser/webapi/selector/Selector.zig");
const protocol = @import("protocol.zig");
const Server = @import("Server.zig");
pub const tool_list = [_]protocol.Tool{
.{
.name = "goto",
.description = "Navigate to a specified URL and load the page in memory so it can be reused later for info extraction.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." }
\\ },
\\ "required": ["url"]
\\}
),
},
.{
.name = "markdown",
.description = "Get the page content in markdown format. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching markdown." }
\\ }
\\}
),
},
.{
.name = "links",
.description = "Extract all links in the opened page. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting links." }
\\ }
\\}
),
},
.{
.name = "evaluate",
.description = "Evaluate JavaScript in the current page context. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "script": { "type": "string" },
\\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." }
\\ },
\\ "required": ["script"]
\\}
),
},
};
pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
_ = arena;
try server.sendResult(req.id.?, .{ .tools = &tool_list });
}
const GotoParams = struct {
url: [:0]const u8,
};
const EvaluateParams = struct {
script: [:0]const u8,
url: ?[:0]const u8 = null,
};
const ToolStreamingText = struct {
server: *Server,
action: enum { markdown, links },
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void {
try jw.beginWriteRaw();
try jw.writer.writeByte('"');
var escaped = protocol.JsonEscapingWriter.init(jw.writer);
const w = &escaped.writer;
switch (self.action) {
.markdown => lp.markdown.dump(self.server.page.document.asNode(), .{}, w, self.server.page) catch |err| {
log.err(.mcp, "markdown dump failed", .{ .err = err });
},
.links => {
if (Selector.querySelectorAll(self.server.page.document.asNode(), "a[href]", self.server.page)) |list| {
defer list.deinit(self.server.page);
var first = true;
for (list._nodes) |node| {
if (node.is(Element.Html.Anchor)) |anchor| {
const href = anchor.getHref(self.server.page) catch |err| {
log.err(.mcp, "resolve href failed", .{ .err = err });
continue;
};
if (href.len > 0) {
if (!first) try w.writeByte('\n');
try w.writeAll(href);
first = false;
}
}
}
} else |err| {
log.err(.mcp, "query links failed", .{ .err = err });
}
},
}
try jw.writer.writeByte('"');
jw.endWriteRaw();
}
};
const ToolAction = enum {
goto,
navigate,
markdown,
links,
evaluate,
};
const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
.{ "goto", .goto },
.{ "navigate", .navigate },
.{ "markdown", .markdown },
.{ "links", .links },
.{ "evaluate", .evaluate },
});
pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
if (req.params == null) {
return server.sendError(req.id.?, .InvalidParams, "Missing params");
}
const CallParams = struct {
name: []const u8,
arguments: ?std.json.Value = null,
};
const call_params = std.json.parseFromValueLeaky(CallParams, arena, req.params.?, .{ .ignore_unknown_fields = true }) catch {
return server.sendError(req.id.?, .InvalidParams, "Invalid params");
};
const action = tool_map.get(call_params.name) orelse {
return server.sendError(req.id.?, .MethodNotFound, "Tool not found");
};
switch (action) {
.goto, .navigate => try handleGoto(server, arena, req.id.?, call_params.arguments),
.markdown => try handleMarkdown(server, arena, req.id.?, call_params.arguments),
.links => try handleLinks(server, arena, req.id.?, call_params.arguments),
.evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments),
}
}
fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArguments(GotoParams, arena, arguments, server, id, "goto");
try performGoto(server, args.url, id);
const content = [_]protocol.TextContent([]const u8){.{ .text = "Navigated successfully." }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const MarkdownParams = struct {
url: ?[:0]const u8 = null,
};
if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(MarkdownParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
if (args.url) |u| {
try performGoto(server, u, id);
}
} else |_| {}
}
const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .server = server, .action = .markdown },
}};
try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content });
}
fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const LinksParams = struct {
url: ?[:0]const u8 = null,
};
if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(LinksParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
if (args.url) |u| {
try performGoto(server, u, id);
}
} else |_| {}
}
const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .server = server, .action = .links },
}};
try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content });
}
fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArguments(EvaluateParams, arena, arguments, server, id, "evaluate");
if (args.url) |url| {
try performGoto(server, url, id);
}
var ls: js.Local.Scope = undefined;
server.page.js.localScope(&ls);
defer ls.deinit();
var try_catch: js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
const js_result = ls.local.compileAndRun(args.script, null) catch |err| {
const caught = try_catch.caughtOrError(arena, err);
var aw: std.Io.Writer.Allocating = .init(arena);
try caught.format(&aw.writer);
const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }};
return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content, .isError = true });
};
const str_result = js_result.toStringSliceWithAlloc(arena) catch "undefined";
const content = [_]protocol.TextContent([]const u8){.{ .text = str_result }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn parseArguments(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T {
if (arguments == null) {
try server.sendError(id, .InvalidParams, "Missing arguments");
return error.InvalidParams;
}
return std.json.parseFromValueLeaky(T, arena, arguments.?, .{ .ignore_unknown_fields = true }) catch {
const msg = std.fmt.allocPrint(arena, "Invalid arguments for {s}", .{tool_name}) catch "Invalid arguments";
try server.sendError(id, .InvalidParams, msg);
return error.InvalidParams;
};
}
fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void {
_ = server.page.navigate(url, .{
.reason = .address_bar,
.kind = .{ .push = null },
}) catch {
try server.sendError(id, .InternalError, "Internal error during navigation");
return error.NavigationFailed;
};
_ = server.session.wait(5000);
}
const testing = @import("../testing.zig");
const router = @import("router.zig");
test "MCP - evaluate error reporting" {
defer testing.reset();
const allocator = testing.allocator;
const app = testing.test_app;
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator);
defer out_alloc.deinit();
var server = try Server.init(allocator, app, &out_alloc.writer);
defer server.deinit();
const aa = testing.arena_allocator;
// Call evaluate with a script that throws an error
const msg =
\\{
\\ "jsonrpc": "2.0",
\\ "id": 1,
\\ "method": "tools/call",
\\ "params": {
\\ "name": "evaluate",
\\ "arguments": {
\\ "script": "throw new Error('test error')"
\\ }
\\ }
\\}
;
try router.handleMessage(server, aa, msg);
try testing.expectJson(
\\{
\\ "id": 1,
\\ "result": {
\\ "isError": true,
\\ "content": [
\\ { "type": "text" }
\\ ]
\\ }
\\}
, out_alloc.writer.buffered());
}