diff --git a/src/Config.zig b/src/Config.zig index ddef91be..1a93c88c 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -28,6 +28,7 @@ pub const RunMode = enum { fetch, serve, version, + mcp, }; 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 { 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, }; } pub fn obeyRobots(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch => |opts| opts.common.obey_robots, + inline .serve, .fetch, .mcp => |opts| opts.common.obey_robots, else => unreachable, }; } pub fn httpProxy(self: *const Config) ?[:0]const u8 { return switch (self.mode) { - inline .serve, .fetch => |opts| opts.common.http_proxy, + inline .serve, .fetch, .mcp => |opts| opts.common.http_proxy, else => unreachable, }; } pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 { 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, }; } pub fn httpMaxConcurrent(self: *const Config) u8 { 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, }; } pub fn httpMaxHostOpen(self: *const Config) u8 { 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, }; } pub fn httpConnectTimeout(self: *const Config) u31 { 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, }; } pub fn httpTimeout(self: *const Config) u31 { 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, }; } @@ -119,35 +120,35 @@ pub fn httpMaxRedirects(_: *const Config) u8 { pub fn httpMaxResponseSize(self: *const Config) ?usize { 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, }; } pub fn logLevel(self: *const Config) ?log.Level { return switch (self.mode) { - inline .serve, .fetch => |opts| opts.common.log_level, + inline .serve, .fetch, .mcp => |opts| opts.common.log_level, else => unreachable, }; } pub fn logFormat(self: *const Config) ?log.Format { return switch (self.mode) { - inline .serve, .fetch => |opts| opts.common.log_format, + inline .serve, .fetch, .mcp => |opts| opts.common.log_format, else => unreachable, }; } pub fn logFilterScopes(self: *const Config) ?[]const log.Scope { 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, }; } pub fn userAgentSuffix(self: *const Config) ?[]const u8 { 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, }; } @@ -171,6 +172,7 @@ pub const Mode = union(RunMode) { fetch: Fetch, serve: Serve, version: void, + mcp: Mcp, }; pub const Serve = struct { @@ -182,6 +184,10 @@ pub const Serve = struct { common: Common = .{}, }; +pub const Mcp = struct { + common: Common = .{}, +}; + pub const DumpFormat = enum { html, markdown, @@ -322,7 +328,7 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { const usage = \\usage: {s} command [options] [URL] \\ - \\Command can be either 'fetch', 'serve' or 'help' + \\Command can be either 'fetch', 'serve', 'mcp' or 'help' \\ \\fetch command \\Fetches the specified URL @@ -366,6 +372,12 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ Maximum pending connections in the accept queue. \\ Defaults to 128. \\ + ++ common_options ++ + \\ + \\mcp command + \\Starts an MCP (Model Context Protocol) server over stdio + \\Example: {s} mcp + \\ ++ common_options ++ \\ \\version command @@ -375,7 +387,7 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\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) { return std.process.cleanExit(); } @@ -410,6 +422,8 @@ pub fn parseArgs(allocator: Allocator) !Config { return init(allocator, exec_name, .{ .help = false }) }, .fetch => .{ .fetch = parseFetchArgs(allocator, &args) catch return init(allocator, exec_name, .{ .help = false }) }, + .mcp => .{ .mcp = parseMcpArgs(allocator, &args) catch + return init(allocator, exec_name, .{ .help = false }) }, .version => .{ .version = {} }, }; return init(allocator, exec_name, mode); @@ -534,6 +548,24 @@ fn parseServeArgs( 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( allocator: Allocator, args: *std.process.ArgIterator, diff --git a/src/browser/webapi/storage/Cookie.zig b/src/browser/webapi/storage/Cookie.zig index 5e352bd4..da499efb 100644 --- a/src/browser/webapi/storage/Cookie.zig +++ b/src/browser/webapi/storage/Cookie.zig @@ -435,7 +435,7 @@ pub const Jar = struct { pub fn removeExpired(self: *Jar, request_time: ?i64) void { if (self.cookies.items.len == 0) return; const time = request_time orelse std.time.timestamp(); - var i: usize = self.cookies.items.len ; + var i: usize = self.cookies.items.len; while (i > 0) { i -= 1; const cookie = &self.cookies.items[i]; diff --git a/src/lightpanda.zig b/src/lightpanda.zig index f97142d0..cbd738ae 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -29,6 +29,11 @@ pub const log = @import("log.zig"); pub const js = @import("browser/js/js.zig"); pub const dump = @import("browser/dump.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 crash_handler = @import("crash_handler.zig"); diff --git a/src/log.zig b/src/log.zig index b1ff926b..8cf712ba 100644 --- a/src/log.zig +++ b/src/log.zig @@ -38,6 +38,7 @@ pub const Scope = enum { not_implemented, telemetry, unknown_prop, + mcp, }; const Opts = struct { diff --git a/src/main.zig b/src/main.zig index 3b2ded5e..d312eb81 100644 --- a/src/main.zig +++ b/src/main.zig @@ -130,6 +130,17 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { 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, } } diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig new file mode 100644 index 00000000..808ee5c0 --- /dev/null +++ b/src/mcp/Server.zig @@ -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(); + } +}; diff --git a/src/mcp/protocol.zig b/src/mcp/protocol.zig new file mode 100644 index 00000000..7a759710 --- /dev/null +++ b/src/mcp/protocol.zig @@ -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, +}; diff --git a/src/mcp/resources.zig b/src/mcp/resources.zig new file mode 100644 index 00000000..781475ac --- /dev/null +++ b/src/mcp/resources.zig @@ -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, + }, + }); +} diff --git a/src/mcp/router.zig b/src/mcp/router.zig new file mode 100644 index 00000000..3b6f3fe8 --- /dev/null +++ b/src/mcp/router.zig @@ -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); +} diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig new file mode 100644 index 00000000..835b9e2f --- /dev/null +++ b/src/mcp/tools.zig @@ -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, + }, + }); +}