diff --git a/src/mcp/protocol.zig b/src/mcp/protocol.zig index 232d07e62..18e91cb67 100644 --- a/src/mcp/protocol.zig +++ b/src/mcp/protocol.zig @@ -100,15 +100,30 @@ pub const ToolsCapability = struct { listChanged: ?bool = null, }; +/// Advisory hints for clients (e.g. auto-approving read-only calls). +/// Defaults are the spec's: assume the worst when unset. +pub const ToolAnnotations = struct { + readOnlyHint: bool = false, + destructiveHint: bool = true, + idempotentHint: bool = false, + openWorldHint: bool = true, +}; + pub const Tool = struct { name: []const u8, + title: ?[]const u8 = null, description: ?[]const u8 = null, inputSchema: []const u8, + annotations: ?ToolAnnotations = null, pub fn jsonStringify(self: @This(), jw: anytype) !void { try jw.beginObject(); try jw.objectField("name"); try jw.write(self.name); + if (self.title) |t| { + try jw.objectField("title"); + try jw.write(t); + } if (self.description) |d| { try jw.objectField("description"); try jw.write(d); @@ -117,6 +132,10 @@ pub const Tool = struct { _ = try jw.beginWriteRaw(); try jw.writer.writeAll(self.inputSchema); jw.endWriteRaw(); + if (self.annotations) |a| { + try jw.objectField("annotations"); + try jw.write(a); + } try jw.endObject(); } }; @@ -302,3 +321,19 @@ test "MCP.protocol - Tool serialization" { try testing.expectString("{\"name\":\"test\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"foo\":{\"type\":\"string\"}}}}", aw.written()); } + +test "MCP.protocol - Tool serialization with title and annotations" { + const t = Tool{ + .name = "test", + .title = "Test", + .inputSchema = "{}", + .annotations = .{ .readOnlyHint = true, .destructiveHint = false }, + }; + + 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\",\"title\":\"Test\",\"inputSchema\":{},\"annotations\":{\"readOnlyHint\":true,\"destructiveHint\":false,\"idempotentHint\":false,\"openWorldHint\":true}}", aw.written()); +} diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 47ff1c746..0211ddec2 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -18,13 +18,29 @@ const browser_tool_list = blk: { for (browser_tools.tool_defs, fields, 0..) |td, f, i| { tools[i] = .{ .name = f.name, + .title = td.summary, .description = td.description, .inputSchema = td.input_schema, + .annotations = annotations(@field(BrowserTool, f.name)), }; } break :blk tools; }; +const read_only: protocol.ToolAnnotations = .{ .readOnlyHint = true, .destructiveHint = false, .idempotentHint = true, .openWorldHint = false }; + +/// Exhaustive so a new tool must classify itself. Navigation is an HTTP GET, +/// so tools that only navigate and read stay read-only; they are open-world. +fn annotations(tool: BrowserTool) protocol.ToolAnnotations { + return switch (tool) { + .nodeDetails, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv, .extract, .waitForSelector, .waitForScript, .waitForState => read_only, + .goto, .search, .markdown, .html, .links, .tree, .interactiveElements, .structuredData, .detectForms => .{ .readOnlyHint = true, .destructiveHint = false, .idempotentHint = true }, + .evaluate, .click, .press => .{}, + .fill, .selectOption, .setChecked, .hover => .{ .destructiveHint = false, .idempotentHint = true, .openWorldHint = false }, + .scroll => .{ .destructiveHint = false, .openWorldHint = false }, + }; +} + const save_schema = browser_tools.minify( \\{ \\ "type": "object", @@ -58,21 +74,29 @@ const session_id_schema = browser_tools.minify( const extra_tools = [_]McpTool{ .{ .name = "save", + .title = "Save the session as an agent script", + .annotations = .{ .idempotentHint = true, .openWorldHint = false }, .description = "Save the session as a reusable Lightpanda agent script. You hold the conversation, so synthesize the `script` yourself — `const page = new Page(); await page.goto(url);` then call the builtins you used as tools (extract, click, fill, …) as methods on `page` with the same object arguments. Keep `$LP_*` placeholders; never inline a resolved secret.\n\n" ++ browser_tools.save_synthesis_prompt ++ "\n\n" ++ browser_tools.save_script_rules, .inputSchema = save_schema, }, .{ .name = "session_new", + .title = "Create an isolated browser session", + .annotations = .{ .destructiveHint = false, .idempotentHint = true, .openWorldHint = false }, .description = "Create a new isolated browser session (its own page, cookies and memory) and return its id. Use it to give a separate agent its own browsing context, or to obtain an id to share. Pass that id back as the `Mcp-Session-Id` header to route calls to it.", .inputSchema = session_new_schema, }, .{ .name = "session_list", + .title = "List browser sessions", + .annotations = read_only, .description = "List the active browser sessions with their id and current URL. The `default` session always exists.", .inputSchema = browser_tools.minify("{ \"type\": \"object\", \"properties\": {} }"), }, .{ .name = "session_close", + .title = "Close a browser session", + .annotations = .{ .openWorldHint = false }, .description = "Close a browser session, freeing its page and memory. The `default` session cannot be closed.", .inputSchema = session_id_schema, }, @@ -270,6 +294,36 @@ fn sendToolResultFmt(server: *Server, arena: std.mem.Allocator, id: std.json.Val const router = @import("router.zig"); const testing = @import("../testing.zig"); +test "MCP - tools/list carries titles and annotations" { + const json = try std.json.Stringify.valueAlloc(testing.allocator, all_tools, .{}); + defer testing.allocator.free(json); + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); + defer parsed.deinit(); + + const Expect = struct { name: []const u8, title: []const u8, read_only: bool, destructive: bool }; + const expected = [_]Expect{ + .{ .name = "getUrl", .title = "Show the current page URL", .read_only = true, .destructive = false }, + .{ .name = "markdown", .title = "Render the page or a subtree as markdown", .read_only = true, .destructive = false }, + .{ .name = "click", .title = "Click an element", .read_only = false, .destructive = true }, + .{ .name = "save", .title = "Save the session as an agent script", .read_only = false, .destructive = true }, + .{ .name = "session_list", .title = "List browser sessions", .read_only = true, .destructive = false }, + }; + + var found: usize = 0; + for (parsed.value.array.items) |tool| { + const name = tool.object.get("name").?.string; + for (expected) |e| { + if (!std.mem.eql(u8, name, e.name)) continue; + found += 1; + try testing.expectEqual(e.title, tool.object.get("title").?.string); + const a = tool.object.get("annotations").?.object; + try testing.expectEqual(e.read_only, a.get("readOnlyHint").?.bool); + try testing.expectEqual(e.destructive, a.get("destructiveHint").?.bool); + } + } + try testing.expectEqual(expected.len, found); +} + test "MCP - evaluate error reporting" { var out: std.Io.Writer.Allocating = .init(testing.arena_allocator); const server = try testLoadPage("about:blank", &out.writer);