mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-22 04:34:44 +00:00
mcp: add Model Context Protocol server support
Adds a new `mcp` run mode to start an MCP server over stdio. Implements tools for navigation and JS evaluation, along with resources for HTML and Markdown page content.
This commit is contained in:
@@ -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,
|
||||||
@@ -322,7 +328,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
|
||||||
@@ -366,6 +372,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
|
||||||
@@ -375,7 +387,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();
|
||||||
}
|
}
|
||||||
@@ -410,6 +422,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);
|
||||||
@@ -534,6 +548,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(.app, "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,
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ 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 = struct {
|
||||||
|
pub const Server = @import("mcp/Server.zig").McpServer;
|
||||||
|
pub const protocol = @import("mcp/protocol.zig");
|
||||||
|
pub const router = @import("mcp/router.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");
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
11
src/main.zig
11
src/main.zig
@@ -130,6 +130,17 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
|
|||||||
return err;
|
return err;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
.mcp => {
|
||||||
|
log.info(.app, "starting MCP server", .{});
|
||||||
|
|
||||||
|
log.opts.format = .logfmt;
|
||||||
|
|
||||||
|
var mcp_server = try lp.mcp.Server.init(allocator, app);
|
||||||
|
defer mcp_server.deinit();
|
||||||
|
|
||||||
|
try mcp_server.start();
|
||||||
|
lp.mcp.router.processRequests(mcp_server);
|
||||||
|
},
|
||||||
else => unreachable,
|
else => unreachable,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
142
src/mcp/Server.zig
Normal file
142
src/mcp/Server.zig
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
const App = @import("../App.zig");
|
||||||
|
const protocol = @import("protocol.zig");
|
||||||
|
const lp = @import("lightpanda");
|
||||||
|
const HttpClient = @import("../http/Client.zig");
|
||||||
|
|
||||||
|
pub const McpServer = struct {
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
app: *App,
|
||||||
|
|
||||||
|
// Browser State
|
||||||
|
http_client: *HttpClient,
|
||||||
|
notification: *lp.Notification,
|
||||||
|
browser: *lp.Browser,
|
||||||
|
session: *lp.Session,
|
||||||
|
page: *lp.Page,
|
||||||
|
|
||||||
|
// Thread synchronization
|
||||||
|
io_thread: ?std.Thread = null,
|
||||||
|
queue_mutex: std.Thread.Mutex = .{},
|
||||||
|
queue_condition: std.Thread.Condition = .{},
|
||||||
|
message_queue: std.ArrayListUnmanaged([]const u8) = .empty,
|
||||||
|
|
||||||
|
// State
|
||||||
|
is_running: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
|
||||||
|
|
||||||
|
// Stdio
|
||||||
|
stdout_mutex: std.Thread.Mutex = .{},
|
||||||
|
|
||||||
|
const Self = @This();
|
||||||
|
|
||||||
|
pub fn init(allocator: std.mem.Allocator, app: *App) !*Self {
|
||||||
|
const self = try allocator.create(Self);
|
||||||
|
errdefer allocator.destroy(self);
|
||||||
|
|
||||||
|
self.allocator = allocator;
|
||||||
|
self.app = app;
|
||||||
|
self.message_queue = .empty;
|
||||||
|
|
||||||
|
self.http_client = try app.http.createClient(allocator);
|
||||||
|
errdefer self.http_client.deinit();
|
||||||
|
|
||||||
|
self.notification = try lp.Notification.init(allocator);
|
||||||
|
errdefer self.notification.deinit();
|
||||||
|
|
||||||
|
self.browser = try allocator.create(lp.Browser);
|
||||||
|
errdefer allocator.destroy(self.browser);
|
||||||
|
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.stop();
|
||||||
|
if (self.io_thread) |*thread| {
|
||||||
|
thread.join();
|
||||||
|
}
|
||||||
|
for (self.message_queue.items) |msg| {
|
||||||
|
self.allocator.free(msg);
|
||||||
|
}
|
||||||
|
self.message_queue.deinit(self.allocator);
|
||||||
|
|
||||||
|
// Clean up browser state
|
||||||
|
self.browser.deinit();
|
||||||
|
self.allocator.destroy(self.browser);
|
||||||
|
self.notification.deinit();
|
||||||
|
self.http_client.deinit();
|
||||||
|
|
||||||
|
self.allocator.destroy(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(self: *Self) !void {
|
||||||
|
self.is_running.store(true, .seq_cst);
|
||||||
|
self.io_thread = try std.Thread.spawn(.{}, ioWorker, .{self});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(self: *Self) void {
|
||||||
|
self.is_running.store(false, .seq_cst);
|
||||||
|
self.queue_condition.signal();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ioWorker(self: *Self) void {
|
||||||
|
var stdin_file = std.fs.File.stdin();
|
||||||
|
var stdin_buf: [8192]u8 = undefined;
|
||||||
|
var stdin = stdin_file.reader(&stdin_buf);
|
||||||
|
|
||||||
|
while (self.is_running.load(.seq_cst)) {
|
||||||
|
const msg_or_err = stdin.interface.adaptToOldInterface().readUntilDelimiterAlloc(self.allocator, '\n', 1024 * 1024 * 10);
|
||||||
|
if (msg_or_err) |msg| {
|
||||||
|
if (msg.len == 0) {
|
||||||
|
self.allocator.free(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.queue_mutex.lock();
|
||||||
|
self.message_queue.append(self.allocator, msg) catch |err| {
|
||||||
|
std.debug.print("MCP Error: Failed to queue message: {}\n", .{err});
|
||||||
|
self.allocator.free(msg);
|
||||||
|
};
|
||||||
|
self.queue_mutex.unlock();
|
||||||
|
self.queue_condition.signal();
|
||||||
|
} else |err| {
|
||||||
|
if (err == error.EndOfStream) {
|
||||||
|
self.stop();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std.debug.print("MCP IO Error: {}\n", .{err});
|
||||||
|
std.Thread.sleep(100 * std.time.ns_per_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn getNextMessage(self: *Self) ?[]const u8 {
|
||||||
|
self.queue_mutex.lock();
|
||||||
|
defer self.queue_mutex.unlock();
|
||||||
|
|
||||||
|
while (self.message_queue.items.len == 0 and self.is_running.load(.seq_cst)) {
|
||||||
|
self.queue_condition.wait(&self.queue_mutex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.message_queue.items.len > 0) {
|
||||||
|
return self.message_queue.orderedRemove(0);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sendResponse(self: *Self, response: anytype) !void {
|
||||||
|
self.stdout_mutex.lock();
|
||||||
|
defer self.stdout_mutex.unlock();
|
||||||
|
|
||||||
|
var stdout_file = std.fs.File.stdout();
|
||||||
|
var stdout_buf: [8192]u8 = undefined;
|
||||||
|
var stdout = stdout_file.writer(&stdout_buf);
|
||||||
|
try std.json.Stringify.value(response, .{ .emit_null_optional_fields = false }, &stdout.interface);
|
||||||
|
try stdout.interface.writeByte('\n');
|
||||||
|
try stdout.interface.flush();
|
||||||
|
}
|
||||||
|
};
|
||||||
97
src/mcp/protocol.zig
Normal file
97
src/mcp/protocol.zig
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
pub const Request = struct {
|
||||||
|
jsonrpc: []const u8 = "2.0",
|
||||||
|
id: std.json.Value,
|
||||||
|
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 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: std.json.Value,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const Resource = struct {
|
||||||
|
uri: []const u8,
|
||||||
|
name: []const u8,
|
||||||
|
description: ?[]const u8 = null,
|
||||||
|
mimeType: ?[]const u8 = null,
|
||||||
|
};
|
||||||
97
src/mcp/resources.zig
Normal file
97
src/mcp/resources.zig
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
const McpServer = @import("Server.zig").McpServer;
|
||||||
|
const protocol = @import("protocol.zig");
|
||||||
|
const lp = @import("lightpanda");
|
||||||
|
|
||||||
|
pub fn handleList(server: *McpServer, req: protocol.Request) !void {
|
||||||
|
const resources = [_]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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = struct {
|
||||||
|
resources: []const protocol.Resource,
|
||||||
|
}{
|
||||||
|
.resources = &resources,
|
||||||
|
};
|
||||||
|
|
||||||
|
try sendResult(server, req.id, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadParams = struct {
|
||||||
|
uri: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn handleRead(server: *McpServer, arena: std.mem.Allocator, req: protocol.Request) !void {
|
||||||
|
if (req.params == null) {
|
||||||
|
return sendError(server, req.id, -32602, "Missing params");
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = std.json.parseFromValueLeaky(ReadParams, arena, req.params.?, .{}) catch {
|
||||||
|
return sendError(server, req.id, -32602, "Invalid params");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (std.mem.eql(u8, params.uri, "mcp://page/html")) {
|
||||||
|
var aw = std.Io.Writer.Allocating.init(arena);
|
||||||
|
try lp.dump.root(server.page.window._document, .{}, &aw.writer, server.page);
|
||||||
|
|
||||||
|
const contents = [_]struct {
|
||||||
|
uri: []const u8,
|
||||||
|
mimeType: []const u8,
|
||||||
|
text: []const u8,
|
||||||
|
}{.{
|
||||||
|
.uri = params.uri,
|
||||||
|
.mimeType = "text/html",
|
||||||
|
.text = aw.written(),
|
||||||
|
}};
|
||||||
|
try sendResult(server, req.id, .{ .contents = &contents });
|
||||||
|
} else if (std.mem.eql(u8, params.uri, "mcp://page/markdown")) {
|
||||||
|
var aw = std.Io.Writer.Allocating.init(arena);
|
||||||
|
try lp.markdown.dump(server.page.window._document.asNode(), .{}, &aw.writer, server.page);
|
||||||
|
|
||||||
|
const contents = [_]struct {
|
||||||
|
uri: []const u8,
|
||||||
|
mimeType: []const u8,
|
||||||
|
text: []const u8,
|
||||||
|
}{.{
|
||||||
|
.uri = params.uri,
|
||||||
|
.mimeType = "text/markdown",
|
||||||
|
.text = aw.written(),
|
||||||
|
}};
|
||||||
|
try sendResult(server, req.id, .{ .contents = &contents });
|
||||||
|
} else {
|
||||||
|
return sendError(server, req.id, -32602, "Resource not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sendResult(server: *McpServer, id: std.json.Value, result: anytype) !void {
|
||||||
|
const GenericResponse = struct {
|
||||||
|
jsonrpc: []const u8 = "2.0",
|
||||||
|
id: std.json.Value,
|
||||||
|
result: @TypeOf(result),
|
||||||
|
};
|
||||||
|
try server.sendResponse(GenericResponse{
|
||||||
|
.id = id,
|
||||||
|
.result = result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sendError(server: *McpServer, id: std.json.Value, code: i64, message: []const u8) !void {
|
||||||
|
try server.sendResponse(protocol.Response{
|
||||||
|
.id = id,
|
||||||
|
.@"error" = protocol.Error{
|
||||||
|
.code = code,
|
||||||
|
.message = message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
82
src/mcp/router.zig
Normal file
82
src/mcp/router.zig
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
const lp = @import("lightpanda");
|
||||||
|
const McpServer = @import("Server.zig").McpServer;
|
||||||
|
const protocol = @import("protocol.zig");
|
||||||
|
const resources = @import("resources.zig");
|
||||||
|
const tools = @import("tools.zig");
|
||||||
|
const log = lp.log;
|
||||||
|
|
||||||
|
pub fn processRequests(server: *McpServer) void {
|
||||||
|
while (server.is_running.load(.seq_cst)) {
|
||||||
|
if (server.getNextMessage()) |msg| {
|
||||||
|
defer server.allocator.free(msg);
|
||||||
|
|
||||||
|
// Critical: Per-request Arena
|
||||||
|
var arena = std.heap.ArenaAllocator.init(server.allocator);
|
||||||
|
defer arena.deinit();
|
||||||
|
|
||||||
|
handleMessage(server, arena.allocator(), msg) catch |err| {
|
||||||
|
log.err(.app, "MCP Error processing message", .{ .err = err });
|
||||||
|
// We should ideally send a parse error response back, but it's hard to extract the ID if parsing failed entirely.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handleMessage(server: *McpServer, arena: std.mem.Allocator, msg: []const u8) !void {
|
||||||
|
const parsed = std.json.parseFromSliceLeaky(protocol.Request, arena, msg, .{
|
||||||
|
.ignore_unknown_fields = true,
|
||||||
|
}) catch |err| {
|
||||||
|
log.err(.app, "MCP JSON Parse Error", .{ .err = err, .msg = msg });
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (std.mem.eql(u8, parsed.method, "initialize")) {
|
||||||
|
try handleInitialize(server, parsed);
|
||||||
|
} else if (std.mem.eql(u8, parsed.method, "resources/list")) {
|
||||||
|
try resources.handleList(server, parsed);
|
||||||
|
} else if (std.mem.eql(u8, parsed.method, "resources/read")) {
|
||||||
|
try resources.handleRead(server, arena, parsed);
|
||||||
|
} else if (std.mem.eql(u8, parsed.method, "tools/list")) {
|
||||||
|
try tools.handleList(server, parsed);
|
||||||
|
} else if (std.mem.eql(u8, parsed.method, "tools/call")) {
|
||||||
|
try tools.handleCall(server, arena, parsed);
|
||||||
|
} else {
|
||||||
|
try server.sendResponse(protocol.Response{
|
||||||
|
.id = parsed.id,
|
||||||
|
.@"error" = protocol.Error{
|
||||||
|
.code = -32601,
|
||||||
|
.message = "Method not found",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sendResponseGeneric(server: *McpServer, id: std.json.Value, result: anytype) !void {
|
||||||
|
const GenericResponse = struct {
|
||||||
|
jsonrpc: []const u8 = "2.0",
|
||||||
|
id: std.json.Value,
|
||||||
|
result: @TypeOf(result),
|
||||||
|
};
|
||||||
|
try server.sendResponse(GenericResponse{
|
||||||
|
.id = id,
|
||||||
|
.result = result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handleInitialize(server: *McpServer, req: protocol.Request) !void {
|
||||||
|
const result = protocol.InitializeResult{
|
||||||
|
.protocolVersion = "2024-11-05",
|
||||||
|
.capabilities = .{
|
||||||
|
.logging = .{},
|
||||||
|
.resources = .{ .subscribe = false, .listChanged = false },
|
||||||
|
.tools = .{ .listChanged = false },
|
||||||
|
},
|
||||||
|
.serverInfo = .{
|
||||||
|
.name = "lightpanda-mcp",
|
||||||
|
.version = "0.1.0",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try sendResponseGeneric(server, req.id, result);
|
||||||
|
}
|
||||||
136
src/mcp/tools.zig
Normal file
136
src/mcp/tools.zig
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
const McpServer = @import("Server.zig").McpServer;
|
||||||
|
const protocol = @import("protocol.zig");
|
||||||
|
const lp = @import("lightpanda");
|
||||||
|
const log = lp.log;
|
||||||
|
const js = lp.js;
|
||||||
|
|
||||||
|
pub fn handleList(server: *McpServer, req: protocol.Request) !void {
|
||||||
|
const tools = [_]protocol.Tool{
|
||||||
|
.{
|
||||||
|
.name = "navigate",
|
||||||
|
.description = "Navigate the browser to a specific URL",
|
||||||
|
.inputSchema = std.json.parseFromSliceLeaky(std.json.Value, server.allocator,
|
||||||
|
\\{
|
||||||
|
\\ "type": "object",
|
||||||
|
\\ "properties": {
|
||||||
|
\\ "url": { "type": "string" }
|
||||||
|
\\ },
|
||||||
|
\\ "required": ["url"]
|
||||||
|
\\}
|
||||||
|
, .{}) catch unreachable,
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.name = "evaluate",
|
||||||
|
.description = "Evaluate JavaScript in the current page context",
|
||||||
|
.inputSchema = std.json.parseFromSliceLeaky(std.json.Value, server.allocator,
|
||||||
|
\\{
|
||||||
|
\\ "type": "object",
|
||||||
|
\\ "properties": {
|
||||||
|
\\ "script": { "type": "string" }
|
||||||
|
\\ },
|
||||||
|
\\ "required": ["script"]
|
||||||
|
\\}
|
||||||
|
, .{}) catch unreachable,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = struct {
|
||||||
|
tools: []const protocol.Tool,
|
||||||
|
}{
|
||||||
|
.tools = &tools,
|
||||||
|
};
|
||||||
|
|
||||||
|
try sendResult(server, req.id, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavigateParams = struct {
|
||||||
|
url: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const EvaluateParams = struct {
|
||||||
|
script: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn handleCall(server: *McpServer, arena: std.mem.Allocator, req: protocol.Request) !void {
|
||||||
|
if (req.params == null) {
|
||||||
|
return sendError(server, req.id, -32602, "Missing params");
|
||||||
|
}
|
||||||
|
|
||||||
|
const CallParams = struct {
|
||||||
|
name: []const u8,
|
||||||
|
arguments: ?std.json.Value = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const call_params = std.json.parseFromValueLeaky(CallParams, arena, req.params.?, .{}) catch {
|
||||||
|
return sendError(server, req.id, -32602, "Invalid params");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (std.mem.eql(u8, call_params.name, "navigate")) {
|
||||||
|
if (call_params.arguments == null) {
|
||||||
|
return sendError(server, req.id, -32602, "Missing arguments for navigate");
|
||||||
|
}
|
||||||
|
const args = std.json.parseFromValueLeaky(NavigateParams, arena, call_params.arguments.?, .{}) catch {
|
||||||
|
return sendError(server, req.id, -32602, "Invalid arguments for navigate");
|
||||||
|
};
|
||||||
|
|
||||||
|
const url_z = try arena.dupeZ(u8, args.url);
|
||||||
|
_ = server.page.navigate(url_z, .{
|
||||||
|
.reason = .address_bar,
|
||||||
|
.kind = .{ .push = null },
|
||||||
|
}) catch {
|
||||||
|
return sendError(server, req.id, -32603, "Failed to navigate");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wait for page load (simple wait for now)
|
||||||
|
_ = server.session.wait(5000);
|
||||||
|
|
||||||
|
const content = [_]struct { type: []const u8, text: []const u8 }{.{ .type = "text", .text = "Navigated successfully." }};
|
||||||
|
try sendResult(server, req.id, .{ .content = &content });
|
||||||
|
} else if (std.mem.eql(u8, call_params.name, "evaluate")) {
|
||||||
|
if (call_params.arguments == null) {
|
||||||
|
return sendError(server, req.id, -32602, "Missing arguments for evaluate");
|
||||||
|
}
|
||||||
|
const args = std.json.parseFromValueLeaky(EvaluateParams, arena, call_params.arguments.?, .{}) catch {
|
||||||
|
return sendError(server, req.id, -32602, "Invalid arguments for evaluate");
|
||||||
|
};
|
||||||
|
|
||||||
|
var ls: js.Local.Scope = undefined;
|
||||||
|
server.page.js.localScope(&ls);
|
||||||
|
defer ls.deinit();
|
||||||
|
|
||||||
|
const js_result = ls.local.compileAndRun(args.script, null) catch {
|
||||||
|
const content = [_]struct { type: []const u8, text: []const u8 }{.{ .type = "text", .text = "Script evaluation failed." }};
|
||||||
|
return sendResult(server, req.id, .{ .content = &content, .isError = true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const str_result = js_result.toStringSliceWithAlloc(arena) catch "undefined";
|
||||||
|
|
||||||
|
const content = [_]struct { type: []const u8, text: []const u8 }{.{ .type = "text", .text = str_result }};
|
||||||
|
try sendResult(server, req.id, .{ .content = &content });
|
||||||
|
} else {
|
||||||
|
return sendError(server, req.id, -32601, "Tool not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sendResult(server: *McpServer, id: std.json.Value, result: anytype) !void {
|
||||||
|
const GenericResponse = struct {
|
||||||
|
jsonrpc: []const u8 = "2.0",
|
||||||
|
id: std.json.Value,
|
||||||
|
result: @TypeOf(result),
|
||||||
|
};
|
||||||
|
try server.sendResponse(GenericResponse{
|
||||||
|
.id = id,
|
||||||
|
.result = result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sendError(server: *McpServer, id: std.json.Value, code: i64, message: []const u8) !void {
|
||||||
|
try server.sendResponse(protocol.Response{
|
||||||
|
.id = id,
|
||||||
|
.@"error" = protocol.Error{
|
||||||
|
.code = code,
|
||||||
|
.message = message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user