From c244525582638fc15edeb6973c89380b5ef0f769 Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Tue, 21 Jul 2026 21:47:28 -0700 Subject: [PATCH 1/4] feat(mcp): add per-tool risk annotations Add MCP-standard annotations (readOnlyHint/destructiveHint) to all 53 tools so Droid classifies read-only tools as low/medium risk and auto-runs them under Auto (Low)/Medium, instead of defaulting every tool to high risk and prompting on each call. Non-destructive writes are marked non-read-only (medium), destructive tools keep the destructive hint (high); Auto (High) is unchanged. Annotations propagate through the existing tools/list response in both stdio and http transports, so no handler or REST changes are needed. Tool count is unchanged. Refs #1097 Signed-off-by: AJ Alon --- AGENTS.md | 2 +- src/mcp/tools-registry.ts | 62 ++++++++++ test/mcp-tool-annotations.test.ts | 192 ++++++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 test/mcp-tool-annotations.test.ts diff --git a/AGENTS.md b/AGENTS.md index dc08f431c..33b7c6cd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ agentmemory is a persistent memory system for AI coding agents, built on iii-eng ## Consistency Rules **When adding or removing MCP tools, you MUST update ALL of the following:** -1. `src/mcp/tools-registry.ts` — tool definition + `getAllTools()` array +1. `src/mcp/tools-registry.ts` — tool definition + `getAllTools()` array (every tool must include `annotations` with `readOnlyHint`/`destructiveHint`: read-only tools get `readOnlyHint: true`, destructive tools get `destructiveHint: true`) 2. `src/mcp/server.ts` — handler case in the `mcp::tools::call` switch 3. `src/triggers/api.ts` — REST endpoint registration 4. `src/index.ts` — function registration + endpoint count in the log line diff --git a/src/mcp/tools-registry.ts b/src/mcp/tools-registry.ts index 1225b4ce7..791bb533c 100644 --- a/src/mcp/tools-registry.ts +++ b/src/mcp/tools-registry.ts @@ -1,3 +1,11 @@ +export type McpToolAnnotations = { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + title?: string; +}; + export type McpToolDef = { name: string; description: string; @@ -6,6 +14,7 @@ export type McpToolDef = { properties: Record; required?: string[]; }; + annotations?: McpToolAnnotations; }; export const CORE_TOOLS: McpToolDef[] = [ @@ -35,6 +44,7 @@ export const CORE_TOOLS: McpToolDef[] = [ }, required: ["query"], }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_compress_file", @@ -50,6 +60,7 @@ export const CORE_TOOLS: McpToolDef[] = [ }, required: ["filePath"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_save", @@ -92,6 +103,7 @@ export const CORE_TOOLS: McpToolDef[] = [ }, required: ["content"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_file_history", @@ -107,6 +119,7 @@ export const CORE_TOOLS: McpToolDef[] = [ }, required: ["files"], }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_patterns", @@ -117,12 +130,14 @@ export const CORE_TOOLS: McpToolDef[] = [ project: { type: "string", description: "Project path to analyze" }, }, }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_sessions", description: "List recent sessions with their status and observation counts.", inputSchema: { type: "object", properties: {} }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_smart_search", @@ -139,6 +154,7 @@ export const CORE_TOOLS: McpToolDef[] = [ }, required: ["query"], }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_vision_search", @@ -154,6 +170,7 @@ export const CORE_TOOLS: McpToolDef[] = [ sessionId: { type: "string", description: "Filter to a single session" }, }, }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_timeline", @@ -177,6 +194,7 @@ export const CORE_TOOLS: McpToolDef[] = [ }, required: ["anchor"], }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_profile", @@ -192,11 +210,13 @@ export const CORE_TOOLS: McpToolDef[] = [ }, required: ["project"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_export", description: "Export all memory data as JSON.", inputSchema: { type: "object", properties: {} }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_relations", @@ -219,6 +239,7 @@ export const CORE_TOOLS: McpToolDef[] = [ }, required: ["memoryId"], }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_commit_lookup", @@ -231,6 +252,7 @@ export const CORE_TOOLS: McpToolDef[] = [ }, required: ["sha"], }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_commits", @@ -244,6 +266,7 @@ export const CORE_TOOLS: McpToolDef[] = [ limit: { type: "number", description: "Max results (default 100, max 500)" }, }, }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, ]; @@ -263,6 +286,7 @@ export const V040_TOOLS: McpToolDef[] = [ }, required: ["direction"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_graph_query", @@ -282,6 +306,7 @@ export const V040_TOOLS: McpToolDef[] = [ query: { type: "string", description: "Search nodes by name" }, }, }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_consolidate", @@ -296,6 +321,7 @@ export const V040_TOOLS: McpToolDef[] = [ }, }, }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_team_share", @@ -314,6 +340,7 @@ export const V040_TOOLS: McpToolDef[] = [ }, required: ["itemId", "itemType"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_team_feed", @@ -324,6 +351,7 @@ export const V040_TOOLS: McpToolDef[] = [ limit: { type: "number", description: "Max items (default 20)" }, }, }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_audit", @@ -335,6 +363,7 @@ export const V040_TOOLS: McpToolDef[] = [ limit: { type: "number", description: "Max entries (default 50)" }, }, }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_governance_delete", @@ -350,6 +379,7 @@ export const V040_TOOLS: McpToolDef[] = [ }, required: ["memoryIds"], }, + annotations: { readOnlyHint: false, destructiveHint: true }, }, { name: "memory_snapshot_create", @@ -360,6 +390,7 @@ export const V040_TOOLS: McpToolDef[] = [ message: { type: "string", description: "Snapshot description" }, }, }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, ]; @@ -397,6 +428,7 @@ export const V050_TOOLS: McpToolDef[] = [ }, required: ["title"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_action_update", @@ -418,6 +450,7 @@ export const V050_TOOLS: McpToolDef[] = [ }, required: ["actionId"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_frontier", @@ -434,6 +467,7 @@ export const V050_TOOLS: McpToolDef[] = [ limit: { type: "number", description: "Max results (default 20)" }, }, }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_next", @@ -446,6 +480,7 @@ export const V050_TOOLS: McpToolDef[] = [ agentId: { type: "string", description: "Current agent ID" }, }, }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_lease", @@ -471,6 +506,7 @@ export const V050_TOOLS: McpToolDef[] = [ }, required: ["actionId", "agentId", "operation"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_routine_run", @@ -485,6 +521,7 @@ export const V050_TOOLS: McpToolDef[] = [ }, required: ["routineId"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_signal_send", @@ -510,6 +547,7 @@ export const V050_TOOLS: McpToolDef[] = [ }, required: ["from", "content"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_signal_read", @@ -531,6 +569,7 @@ export const V050_TOOLS: McpToolDef[] = [ }, required: ["agentId"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_checkpoint", @@ -564,6 +603,7 @@ export const V050_TOOLS: McpToolDef[] = [ }, required: ["operation"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_mesh_sync", @@ -582,6 +622,7 @@ export const V050_TOOLS: McpToolDef[] = [ }, }, }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, ]; @@ -610,6 +651,7 @@ export const V051_TOOLS: McpToolDef[] = [ }, required: ["name", "type"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_sentinel_trigger", @@ -623,6 +665,7 @@ export const V051_TOOLS: McpToolDef[] = [ }, required: ["sentinelId"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_sketch_create", @@ -638,6 +681,7 @@ export const V051_TOOLS: McpToolDef[] = [ }, required: ["title"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_sketch_promote", @@ -651,6 +695,7 @@ export const V051_TOOLS: McpToolDef[] = [ }, required: ["sketchId"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_crystallize", @@ -668,6 +713,7 @@ export const V051_TOOLS: McpToolDef[] = [ }, required: ["actionIds"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_diagnose", @@ -682,6 +728,7 @@ export const V051_TOOLS: McpToolDef[] = [ }, }, }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_heal", @@ -700,6 +747,7 @@ export const V051_TOOLS: McpToolDef[] = [ }, }, }, + annotations: { readOnlyHint: false, destructiveHint: true }, }, { name: "memory_facet_tag", @@ -718,6 +766,7 @@ export const V051_TOOLS: McpToolDef[] = [ }, required: ["targetId", "targetType", "dimension", "value"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_facet_query", @@ -740,6 +789,7 @@ export const V051_TOOLS: McpToolDef[] = [ }, }, }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, ]; @@ -758,6 +808,7 @@ export const V061_TOOLS: McpToolDef[] = [ }, required: ["id"], }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, ]; @@ -786,6 +837,7 @@ export const V070_TOOLS: McpToolDef[] = [ }, required: ["content"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_lesson_recall", @@ -804,6 +856,7 @@ export const V070_TOOLS: McpToolDef[] = [ }, required: ["query"], }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_lesson_delete", @@ -834,6 +887,7 @@ export const V070_TOOLS: McpToolDef[] = [ }, }, }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, ]; @@ -852,6 +906,7 @@ export const V073_TOOLS: McpToolDef[] = [ }, }, }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_insight_list", @@ -868,6 +923,7 @@ export const V073_TOOLS: McpToolDef[] = [ limit: { type: "number", description: "Max results (default 50)" }, }, }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, ]; @@ -877,6 +933,7 @@ export const V010_SLOTS_TOOLS: McpToolDef[] = [ description: "List all memory slots (pinned + project + global). Slots are editable, size-limited memory units the agent can read and modify across sessions.", inputSchema: { type: "object", properties: {} }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_slot_get", @@ -888,6 +945,7 @@ export const V010_SLOTS_TOOLS: McpToolDef[] = [ }, required: ["label"], }, + annotations: { readOnlyHint: true, destructiveHint: false }, }, { name: "memory_slot_create", @@ -904,6 +962,7 @@ export const V010_SLOTS_TOOLS: McpToolDef[] = [ }, required: ["label"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_slot_append", @@ -917,6 +976,7 @@ export const V010_SLOTS_TOOLS: McpToolDef[] = [ }, required: ["label", "text"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_slot_replace", @@ -929,6 +989,7 @@ export const V010_SLOTS_TOOLS: McpToolDef[] = [ }, required: ["label", "content"], }, + annotations: { readOnlyHint: false, destructiveHint: false }, }, { name: "memory_slot_delete", @@ -940,6 +1001,7 @@ export const V010_SLOTS_TOOLS: McpToolDef[] = [ }, required: ["label"], }, + annotations: { readOnlyHint: false, destructiveHint: true }, }, ]; diff --git a/test/mcp-tool-annotations.test.ts b/test/mcp-tool-annotations.test.ts new file mode 100644 index 000000000..799e77146 --- /dev/null +++ b/test/mcp-tool-annotations.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn().mockReturnValue(false), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock("../src/mcp/transport.js", () => ({ + createStdioTransport: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), +})); + +vi.mock("../src/config.js", () => ({ + getStandalonePersistPath: vi.fn(() => "/tmp/test-annotations.json"), +})); + +import { + getAllTools, + getVisibleTools, + type McpToolDef, +} from "../src/mcp/tools-registry.js"; +import { handleToolsList } from "../src/mcp/standalone.js"; +import { + resetHandleForTests, + setLivezProbe, +} from "../src/mcp/rest-proxy.js"; + +const READ_ONLY_TOOLS = new Set([ + "memory_recall", + "memory_file_history", + "memory_patterns", + "memory_sessions", + "memory_smart_search", + "memory_vision_search", + "memory_timeline", + "memory_export", + "memory_relations", + "memory_commit_lookup", + "memory_commits", + "memory_graph_query", + "memory_team_feed", + "memory_audit", + "memory_frontier", + "memory_next", + "memory_diagnose", + "memory_facet_query", + "memory_verify", + "memory_lesson_recall", + "memory_insight_list", + "memory_slot_list", + "memory_slot_get", +]); + +const DESTRUCTIVE_TOOLS = new Set([ + "memory_governance_delete", + "memory_heal", + "memory_slot_delete", +]); + +const instantLocalFallbackProbe = vi.fn(async () => ({ + ok: false, + status: 0, + statusText: "stubbed: forced local fallback", +})); + +const fetchTrap = vi.fn(async (url: unknown) => { + throw new Error( + `unexpected real fetch() in mcp-tool-annotations.test.ts: ${String(url)}`, + ); +}); + +function classification(tool: McpToolDef): string { + const ro = tool.annotations?.readOnlyHint === true; + const de = tool.annotations?.destructiveHint === true; + if (ro && !de) return "read-only"; + if (!ro && de) return "destructive"; + if (!ro && !de) return "state-changing"; + return "invalid"; +} + +describe("MCP tool risk annotations", () => { + it("every tool carries an annotations object", () => { + for (const tool of getAllTools()) { + expect(tool.annotations, `tool ${tool.name} missing annotations`).toBeDefined(); + expect(typeof tool.annotations).toBe("object"); + } + }); + + it("no tool is both read-only and destructive", () => { + for (const tool of getAllTools()) { + const a = tool.annotations; + if (a?.readOnlyHint && a?.destructiveHint) { + throw new Error(`tool ${tool.name} is both readOnly and destructive`); + } + } + }); + + it("read-only set is exactly 23 tools with readOnlyHint true + destructiveHint false", () => { + const tools = getAllTools(); + const ro = tools.filter((t) => classification(t) === "read-only"); + expect(new Set(ro.map((t) => t.name))).toEqual(READ_ONLY_TOOLS); + expect(ro.length).toBe(23); + for (const t of ro) { + expect(t.annotations?.readOnlyHint).toBe(true); + expect(t.annotations?.destructiveHint).toBe(false); + } + }); + + it("destructive set is exactly 3 tools with destructiveHint true + readOnlyHint false", () => { + const tools = getAllTools(); + const de = tools.filter((t) => classification(t) === "destructive"); + expect(new Set(de.map((t) => t.name))).toEqual(DESTRUCTIVE_TOOLS); + expect(de.length).toBe(3); + for (const t of de) { + expect(t.annotations?.destructiveHint).toBe(true); + expect(t.annotations?.readOnlyHint).toBe(false); + } + }); + + it("remaining 27 tools are state-changing (readOnlyHint false, destructiveHint false)", () => { + const tools = getAllTools(); + const sc = tools.filter((t) => classification(t) === "state-changing"); + expect(sc.length).toBe(27); + for (const t of sc) { + expect(t.annotations?.readOnlyHint).toBe(false); + expect(t.annotations?.destructiveHint).toBe(false); + expect(READ_ONLY_TOOLS.has(t.name)).toBe(false); + expect(DESTRUCTIVE_TOOLS.has(t.name)).toBe(false); + } + }); + + it("every tool falls into exactly one classification (23 + 3 + 27 covers total)", () => { + const tools = getAllTools(); + expect(tools.length).toBe(53); + const counts = { "read-only": 0, destructive: 0, "state-changing": 0, invalid: 0 }; + for (const t of tools) counts[classification(t) as keyof typeof counts]++; + expect(counts["read-only"]).toBe(23); + expect(counts.destructive).toBe(3); + expect(counts["state-changing"]).toBe(27); + expect(counts.invalid).toBe(0); + }); + + it("getVisibleTools under AGENTMEMORY_TOOLS=all preserves annotations on every tool", () => { + const prev = process.env["AGENTMEMORY_TOOLS"]; + process.env["AGENTMEMORY_TOOLS"] = "all"; + try { + const visible = getVisibleTools(); + expect(visible.length).toBe(53); + for (const tool of visible) { + expect(tool.annotations, `tool ${tool.name} lost annotations`).toBeDefined(); + } + } finally { + if (prev === undefined) delete process.env["AGENTMEMORY_TOOLS"]; + else process.env["AGENTMEMORY_TOOLS"] = prev; + } + }); +}); + +describe("MCP tools/list wire response carries annotations", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + instantLocalFallbackProbe.mockClear(); + fetchTrap.mockClear(); + resetHandleForTests(); + setLivezProbe(instantLocalFallbackProbe); + (globalThis as { fetch: typeof fetch }).fetch = + fetchTrap as unknown as typeof fetch; + }); + + afterEach(() => { + (globalThis as { fetch: typeof fetch }).fetch = originalFetch; + resetHandleForTests(); + }); + + it("handleToolsList returns tools whose annotations match the registry", async () => { + const registry = new Map(getAllTools().map((t) => [t.name, t])); + const res = await handleToolsList(); + expect(Array.isArray(res.tools)).toBe(true); + expect(res.tools.length).toBeGreaterThan(0); + for (const wire of res.tools as McpToolDef[]) { + const reg = registry.get(wire.name); + expect(reg, `wire tool ${wire.name} not in registry`).toBeDefined(); + expect(wire.annotations).toEqual(reg?.annotations); + } + }); +}); From 5470087c46d3ec9c6f6c5098aa34d4df5a4cbb7f Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Tue, 21 Jul 2026 23:00:00 -0700 Subject: [PATCH 2/4] test(mcp): cover core visibility path in annotations test Address CodeRabbit feedback on #1098: the getVisibleTools test only exercised AGENTMEMORY_TOOLS=all. Also assert AGENTMEMORY_TOOLS=core returns the 8 essential tools and that every filtered tool retains its annotations, so the core code path is covered. Refs #1097 Signed-off-by: AJ Alon --- test/mcp-tool-annotations.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/test/mcp-tool-annotations.test.ts b/test/mcp-tool-annotations.test.ts index 799e77146..28896dde0 100644 --- a/test/mcp-tool-annotations.test.ts +++ b/test/mcp-tool-annotations.test.ts @@ -145,14 +145,17 @@ describe("MCP tool risk annotations", () => { expect(counts.invalid).toBe(0); }); - it("getVisibleTools under AGENTMEMORY_TOOLS=all preserves annotations on every tool", () => { + it("getVisibleTools preserves annotations in every visibility mode", () => { const prev = process.env["AGENTMEMORY_TOOLS"]; - process.env["AGENTMEMORY_TOOLS"] = "all"; try { - const visible = getVisibleTools(); - expect(visible.length).toBe(53); - for (const tool of visible) { - expect(tool.annotations, `tool ${tool.name} lost annotations`).toBeDefined(); + for (const mode of ["all", "core"]) { + process.env["AGENTMEMORY_TOOLS"] = mode; + const visible = getVisibleTools(); + if (mode === "all") expect(visible.length).toBe(53); + if (mode === "core") expect(visible.length).toBe(8); + for (const tool of visible) { + expect(tool.annotations, `tool ${tool.name} lost annotations in ${mode} mode`).toBeDefined(); + } } } finally { if (prev === undefined) delete process.env["AGENTMEMORY_TOOLS"]; From 9e3e482b8ec2638447dd8adee7ec1c0ecefaabd8 Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Tue, 21 Jul 2026 23:06:46 -0700 Subject: [PATCH 3/4] test(mcp): derive size counts dynamically in annotations test Follow-up to CodeRabbit feedback on #1098: hardcoding 53 and 8 duplicated contracts owned elsewhere (tool-count-consistency.test.ts owns the 53; ESSENTIAL_TOOLS.size is the source of truth for core). Derive total via getAllTools().length and core via ESSENTIAL_TOOLS.size, keeping the 23/26/4 classification counts and frozen name sets hardcoded since those are the contract this test verifies. Adds a partition sum invariant (23+4+26 === total) that now meaningfully catches a new tool added without classification. Refs #1097 Signed-off-by: AJ Alon --- test/mcp-tool-annotations.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/mcp-tool-annotations.test.ts b/test/mcp-tool-annotations.test.ts index 28896dde0..d8a778f75 100644 --- a/test/mcp-tool-annotations.test.ts +++ b/test/mcp-tool-annotations.test.ts @@ -22,6 +22,7 @@ vi.mock("../src/config.js", () => ({ import { getAllTools, getVisibleTools, + ESSENTIAL_TOOLS, type McpToolDef, } from "../src/mcp/tools-registry.js"; import { handleToolsList } from "../src/mcp/standalone.js"; @@ -62,6 +63,8 @@ const DESTRUCTIVE_TOOLS = new Set([ "memory_slot_delete", ]); +const TOTAL = getAllTools().length; + const instantLocalFallbackProbe = vi.fn(async () => ({ ok: false, status: 0, @@ -136,13 +139,14 @@ describe("MCP tool risk annotations", () => { it("every tool falls into exactly one classification (23 + 3 + 27 covers total)", () => { const tools = getAllTools(); - expect(tools.length).toBe(53); + expect(tools.length).toBe(TOTAL); const counts = { "read-only": 0, destructive: 0, "state-changing": 0, invalid: 0 }; for (const t of tools) counts[classification(t) as keyof typeof counts]++; expect(counts["read-only"]).toBe(23); expect(counts.destructive).toBe(3); expect(counts["state-changing"]).toBe(27); expect(counts.invalid).toBe(0); + expect(counts["read-only"] + counts.destructive + counts["state-changing"]).toBe(TOTAL); }); it("getVisibleTools preserves annotations in every visibility mode", () => { @@ -151,8 +155,8 @@ describe("MCP tool risk annotations", () => { for (const mode of ["all", "core"]) { process.env["AGENTMEMORY_TOOLS"] = mode; const visible = getVisibleTools(); - if (mode === "all") expect(visible.length).toBe(53); - if (mode === "core") expect(visible.length).toBe(8); + if (mode === "all") expect(visible.length).toBe(TOTAL); + if (mode === "core") expect(visible.length).toBe(ESSENTIAL_TOOLS.size); for (const tool of visible) { expect(tool.annotations, `tool ${tool.name} lost annotations in ${mode} mode`).toBeDefined(); } From c1c1f85d14749656ea02cb0f65297cfc51b4f2ba Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Sun, 16 Aug 2026 14:49:53 -0700 Subject: [PATCH 4/4] fix(mcp): annotate memory_lesson_delete as destructive Upstream added memory_lesson_delete after the PR was opened. Add readOnlyHint/destructiveHint annotations and update the annotation test counts so the suite stays green on current main. --- src/mcp/tools-registry.ts | 1 + test/mcp-tool-annotations.test.ts | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/mcp/tools-registry.ts b/src/mcp/tools-registry.ts index 791bb533c..fbda65663 100644 --- a/src/mcp/tools-registry.ts +++ b/src/mcp/tools-registry.ts @@ -869,6 +869,7 @@ export const V070_TOOLS: McpToolDef[] = [ }, required: ["lessonId"], }, + annotations: { readOnlyHint: false, destructiveHint: true }, }, { name: "memory_obsidian_export", diff --git a/test/mcp-tool-annotations.test.ts b/test/mcp-tool-annotations.test.ts index d8a778f75..a734c922b 100644 --- a/test/mcp-tool-annotations.test.ts +++ b/test/mcp-tool-annotations.test.ts @@ -60,6 +60,7 @@ const READ_ONLY_TOOLS = new Set([ const DESTRUCTIVE_TOOLS = new Set([ "memory_governance_delete", "memory_heal", + "memory_lesson_delete", "memory_slot_delete", ]); @@ -114,11 +115,11 @@ describe("MCP tool risk annotations", () => { } }); - it("destructive set is exactly 3 tools with destructiveHint true + readOnlyHint false", () => { + it("destructive set is exactly 4 tools with destructiveHint true + readOnlyHint false", () => { const tools = getAllTools(); const de = tools.filter((t) => classification(t) === "destructive"); expect(new Set(de.map((t) => t.name))).toEqual(DESTRUCTIVE_TOOLS); - expect(de.length).toBe(3); + expect(de.length).toBe(4); for (const t of de) { expect(t.annotations?.destructiveHint).toBe(true); expect(t.annotations?.readOnlyHint).toBe(false); @@ -137,13 +138,13 @@ describe("MCP tool risk annotations", () => { } }); - it("every tool falls into exactly one classification (23 + 3 + 27 covers total)", () => { + it("every tool falls into exactly one classification (23 + 4 + 27 covers total)", () => { const tools = getAllTools(); expect(tools.length).toBe(TOTAL); const counts = { "read-only": 0, destructive: 0, "state-changing": 0, invalid: 0 }; for (const t of tools) counts[classification(t) as keyof typeof counts]++; expect(counts["read-only"]).toBe(23); - expect(counts.destructive).toBe(3); + expect(counts.destructive).toBe(4); expect(counts["state-changing"]).toBe(27); expect(counts.invalid).toBe(0); expect(counts["read-only"] + counts.destructive + counts["state-changing"]).toBe(TOTAL);