mcp: add title and annotations to tools/list

Every tool already had a short summary and a natural read-only /
destructive classification, but tools/list exposed neither, so clients
had to prompt for every call, including pure reads like getUrl.
This commit is contained in:
Adrià Arrufat committed 2026-08-25 15:46:30 +02:00
1 parent 08219f91d6
commit ade731cb0b
2 files changed
+89

No files matched your search

+35
View File
@@ -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());
}
+54
View File
@@ -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);