From be26c3e8e680f4cc59f22d6a57b0ad54b5df2db9 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 6 Oct 2026 09:37:09 +0000 Subject: [PATCH 1/2] fix(mcp): advertise MCP tool annotations so clients stop treating every tool as destructive tools/list sent no annotations, so MCP clients applied the spec defaults (readOnlyHint: false, destructiveHint: true) and showed all 22 tools, memory.read included, as write + destructive. Each catalog entry now declares readOnlyHint, destructiveHint, idempotentHint and openWorldHint explicitly, and toWireTool passes them through to the edge server and the local stdio server. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- docs/mcp-tools.md | 31 ++++ packages/cli/src/surfaces.generated.mjs | 135 +++++++++++++++++- packages/cli/test/mcp-server.test.mjs | 9 ++ .../mcp-guards/tool-catalog-parity.spec.ts | 70 ++++++++- packages/schemas/src/llms/render.spec.ts | 10 ++ packages/schemas/src/llms/render.ts | 22 ++- packages/schemas/src/shared/tool-catalog.ts | 99 ++++++++++++- packages/web/public/llms.txt | 44 ++++++ scripts/codegen/gen-surfaces.mjs | 3 +- scripts/smoke/smoke-mcp-tools.mjs | 3 + .../functions/_shared/schemas/tool-catalog.ts | 99 ++++++++++++- 11 files changed, 516 insertions(+), 9 deletions(-) diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 7fb3e6811..61be517dc 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -33,6 +33,37 @@ already covered — `memory.list order=rank` answers the same question. **Endpoint:** `https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp` +### Tool annotations + +Every `tools/list` entry carries the MCP `annotations` object (`readOnlyHint`, +`destructiveHint`, `idempotentHint`, `openWorldHint`), declared per tool in +`packages/schemas/src/shared/tool-catalog.ts`. All four hints are always sent: +a hint that is left out takes the spec default (`readOnlyHint: false`, +`destructiveHint: true`), which is how every tool — `memory.read` included — +used to show up as write + destructive in MCP clients. + +| Class | Tools | +|-------|-------| +| Read-only | `memory.read`, `memory.list`, `memory.search`, `memory.scopes`, `memory.list_archived`, `org.list`, `policy.list`, `groom.preview` | +| Write, non-destructive | `memory.archive`, `memory.restore`, `memory.protect`, `groom.run`, `org.create`, `policy.create` | +| Write, destructive | `memory.write`, `memory.delete`, `memory.purge`, `memory.purge_expired`, `org.rename`, `org.delete`, `policy.update`, `policy.delete` | + +The rules behind the split: + +- **Read-only** is exactly the tools that need read token permission. + `tool-catalog-parity.spec.ts` holds the two together. Reads still bump the + read counters (`read_count`, `last_opened_at`), which records the call and + leaves the lore unchanged. +- **Destructive** means the call can delete data or overwrite existing data in + place. `memory.write` counts because an upsert onto an existing key replaces + its value. A soft-archive (`memory.archive`, `groom.run`) is non-destructive + because nothing is lost and `memory.restore` undoes it. +- **Idempotent** means repeating the same call has no further effect. The + exceptions are `memory.write` (each write bumps `seen_count`), `org.create`, + and `policy.create`. +- **Closed-world** applies to every tool, because each one acts only on the + LoreKit store. + --- ## memory.write diff --git a/packages/cli/src/surfaces.generated.mjs b/packages/cli/src/surfaces.generated.mjs index 08709168e..c4688fddf 100644 --- a/packages/cli/src/surfaces.generated.mjs +++ b/packages/cli/src/surfaces.generated.mjs @@ -56,7 +56,8 @@ export const ORG_TOOL_NAMES = [ ]; /** - * The `tools/list` payload: name, description and inputSchema per op. + * The `tools/list` payload: name, description, inputSchema and MCP + * annotations per op. * Identical projection to the edge server's, from the same declaration, so the * local stdio server and the hosted server advertise the same contract. */ @@ -156,6 +157,12 @@ export const MCP_TOOL_DEFS = [ "description": "The lessons that actually shaped this run, as `scope::key` strings — exactly the labels they were injected under. Name only the ones you applied; an empty or omitted list is the honest answer when none were. Silently ignored where a reference names nothing you can see, so a wrong guess costs nothing and the write always succeeds." } } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false } }, { @@ -180,6 +187,12 @@ export const MCP_TOOL_DEFS = [ "description": "Batch mode: one or more `scope::key` references, fetched in a single call. Cannot be combined with `scope`/`key`. Each entry is parsed by the same reference grammar `memory.write`'s `cited` field uses (`scope::key`, verbatim scope — never lowercased). Silently truncated past 32 entries." } } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -242,6 +255,12 @@ export const MCP_TOOL_DEFS = [ "description": "full (default) returns each entry's complete `value`. summary omits `value` and returns `value_bytes` + a 200-character `preview` instead — the cheap discovery read for deciding WHICH lessons to then fetch with `memory.read`." } } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -272,6 +291,12 @@ export const MCP_TOOL_DEFS = [ "description": "Org slug to delete under (org-owned delete). Omit for a personal memory. Soft-archive requires a member/admin/owner role; hard-delete (force: true) requires admin/owner — verified server-side." } } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -313,6 +338,12 @@ export const MCP_TOOL_DEFS = [ "description": "Opaque cursor from a previous response's `nextCursor`. Omit to start from the first page." } } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -334,6 +365,12 @@ export const MCP_TOOL_DEFS = [ "description": "Lesson identifier, unique within the scope. Max 512 characters." } } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -342,6 +379,12 @@ export const MCP_TOOL_DEFS = [ "inputSchema": { "type": "object", "properties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -362,6 +405,12 @@ export const MCP_TOOL_DEFS = [ "description": "Maximum entries to return." } } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -383,6 +432,12 @@ export const MCP_TOOL_DEFS = [ "description": "Lesson identifier, unique within the scope. Max 512 characters." } } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -399,6 +454,12 @@ export const MCP_TOOL_DEFS = [ "description": "Only purge archived lessons older than this many days." } } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -407,6 +468,12 @@ export const MCP_TOOL_DEFS = [ "inputSchema": { "type": "object", "properties": {} + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -428,6 +495,12 @@ export const MCP_TOOL_DEFS = [ "description": "Human-readable display name" } } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false } }, { @@ -436,6 +509,12 @@ export const MCP_TOOL_DEFS = [ "inputSchema": { "type": "object", "properties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -457,6 +536,12 @@ export const MCP_TOOL_DEFS = [ "description": "New display name" } } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -473,6 +558,12 @@ export const MCP_TOOL_DEFS = [ "description": "The org slug to delete" } } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -481,6 +572,12 @@ export const MCP_TOOL_DEFS = [ "inputSchema": { "type": "object", "properties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -675,6 +772,12 @@ export const MCP_TOOL_DEFS = [ "description": "How the origin_pr filter combines." } } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false } }, { @@ -866,6 +969,12 @@ export const MCP_TOOL_DEFS = [ "description": "How the origin_pr filter combines. Omit to leave unchanged; pass explicit null to clear." } } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -882,6 +991,12 @@ export const MCP_TOOL_DEFS = [ "description": "The policy id to delete." } } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -1058,6 +1173,12 @@ export const MCP_TOOL_DEFS = [ "description": "How the origin_pr filter combines." } } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -1234,6 +1355,12 @@ export const MCP_TOOL_DEFS = [ "description": "How the origin_pr filter combines." } } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false } }, { @@ -1260,6 +1387,12 @@ export const MCP_TOOL_DEFS = [ "description": "true to protect, false to unprotect." } } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false } } ]; diff --git a/packages/cli/test/mcp-server.test.mjs b/packages/cli/test/mcp-server.test.mjs index 36091351d..a415599b7 100644 --- a/packages/cli/test/mcp-server.test.mjs +++ b/packages/cli/test/mcp-server.test.mjs @@ -90,6 +90,15 @@ test('initialize → tools/list → write/read/list round-trip over stdio', asyn assert.ok(list.result.tools.some((t) => t.name === 'org.rename')); assert.ok(list.result.tools.some((t) => t.name === 'org.delete')); + // MCP annotations reach the local server too. Without them a client applies + // the spec defaults and shows every tool, reads included, as destructive. + const toolNamed = (name) => list.result.tools.find((t) => t.name === name); + assert.equal(toolNamed('memory.read').annotations.readOnlyHint, true); + assert.equal(toolNamed('memory.read').annotations.destructiveHint, false); + assert.equal(toolNamed('memory.archive').annotations.readOnlyHint, false); + assert.equal(toolNamed('memory.archive').annotations.destructiveHint, false); + assert.equal(toolNamed('memory.delete').annotations.destructiveHint, true); + // The notification produced no response — only ids 1..5 came back. assert.deepEqual([...m.keys()].sort((a, b) => a - b), [1, 2, 3, 4, 5]); diff --git a/packages/mcp-core/src/mcp-guards/tool-catalog-parity.spec.ts b/packages/mcp-core/src/mcp-guards/tool-catalog-parity.spec.ts index a18409e15..2666726ec 100644 --- a/packages/mcp-core/src/mcp-guards/tool-catalog-parity.spec.ts +++ b/packages/mcp-core/src/mcp-guards/tool-catalog-parity.spec.ts @@ -125,11 +125,77 @@ describe('tool catalog ↔ the generated dispatch maps', () => { }); describe('wire projection', () => { - it('exposes only name, description and inputSchema', () => { + it('exposes only name, description, inputSchema and annotations', () => { for (const tool of MCP_TOOLS) { - expect(Object.keys(toWireTool(tool)).sort()).toEqual(['description', 'inputSchema', 'name']); + expect(Object.keys(toWireTool(tool)).sort()).toEqual(['annotations', 'description', 'inputSchema', 'name']); } }); +}); + +/** + * MCP tool annotations. With none on the wire, every client applied the spec's + * defaults — `readOnlyHint: false`, `destructiveHint: true` — and showed all 22 + * tools, `memory.read` included, as write + destructive. These pin the + * classification so it cannot slide back onto a default, and so changing which + * tools count as destructive is a reviewed decision rather than a side effect. + */ +describe('tool annotations', () => { + const HINTS = ['destructiveHint', 'idempotentHint', 'openWorldHint', 'readOnlyHint']; + + it('states all four hints on every tool, so none falls back to the spec default', () => { + for (const tool of MCP_TOOLS) { + const annotations = toWireTool(tool).annotations as unknown as Record; + expect(Object.keys(annotations).sort(), `${tool.name} annotation keys`).toEqual(HINTS); + for (const hint of HINTS) { + expect(typeof annotations[hint], `${tool.name}.${hint}`).toBe('boolean'); + } + } + }); + + it('marks a tool read-only exactly when it needs read permission', () => { + // `permissions.ts` is what the server gates on, so it — not a second + // hand-kept list — decides which tools a client may treat as read-only. + for (const tool of MCP_TOOLS) { + expect(tool.annotations.readOnlyHint, tool.name).toBe(toolRequires(tool.name) === 'read'); + } + }); + + it('never calls a read-only tool destructive', () => { + for (const tool of MCP_TOOLS.filter((t) => t.annotations.readOnlyHint)) { + expect(tool.annotations.destructiveHint, tool.name).toBe(false); + } + }); + + it('marks exactly the tools that delete or overwrite in place as destructive', () => { + const destructive = MCP_TOOLS.filter((t) => t.annotations.destructiveHint).map((t) => t.name).sort(); + expect(destructive).toEqual([ + 'memory.delete', + 'memory.purge', + 'memory.purge_expired', + 'memory.write', + 'org.delete', + 'org.rename', + 'policy.delete', + 'policy.update', + ]); + }); + + it('treats a soft-archive as non-destructive, because memory.restore undoes it', () => { + for (const name of ['memory.archive', 'groom.run', 'memory.restore']) { + const tool = MCP_TOOLS.find((t) => t.name === name); + expect(tool?.annotations.readOnlyHint, name).toBe(false); + expect(tool?.annotations.destructiveHint, name).toBe(false); + } + }); + + it('declares every tool closed-world — each acts on the LoreKit store only', () => { + for (const tool of MCP_TOOLS) { + expect(tool.annotations.openWorldHint, tool.name).toBe(false); + } + }); +}); + +describe('wire projection (input schemas)', () => { it('gives every tool a described, object-typed input schema', () => { for (const tool of MCP_TOOLS) { diff --git a/packages/schemas/src/llms/render.spec.ts b/packages/schemas/src/llms/render.spec.ts index d17e5e4b5..b35c9f2c0 100644 --- a/packages/schemas/src/llms/render.spec.ts +++ b/packages/schemas/src/llms/render.spec.ts @@ -32,6 +32,16 @@ describe('renderTool', () => { expect(out).toContain('default `30`'); }); + it('states the MCP annotations a tool is advertised with', () => { + expect(renderTool(toolNamed('memory.read'))).toContain('MCP annotations: read-only, closed-world.'); + expect(renderTool(toolNamed('memory.write'))).toContain( + 'MCP annotations: write, destructive, not idempotent, closed-world.', + ); + expect(renderTool(toolNamed('memory.archive'))).toContain( + 'MCP annotations: write, non-destructive, idempotent, closed-world.', + ); + }); + it('states the permission a tool requires', () => { expect(renderTool(toolNamed('memory.read'))).toContain('Requires **read** permission.'); expect(renderTool(toolNamed('memory.write'))).toContain('Requires **write** permission.'); diff --git a/packages/schemas/src/llms/render.ts b/packages/schemas/src/llms/render.ts index e7ecdd75e..7691f947f 100644 --- a/packages/schemas/src/llms/render.ts +++ b/packages/schemas/src/llms/render.ts @@ -20,7 +20,7 @@ * tested directly. */ -import { MCP_TOOLS, type McpToolDoc, type JsonSchemaProperty } from '../shared/tool-catalog.ts'; +import { MCP_TOOLS, type McpToolDoc, type McpToolAnnotations, type JsonSchemaProperty } from '../shared/tool-catalog.ts'; /** One entry in the generated docs index, read from an MDX file's frontmatter. */ export interface DocsIndexEntry { @@ -66,6 +66,24 @@ function argumentRow(name: string, property: JsonSchemaProperty, required: boole return `| \`${name}\` | ${required ? '✓' : ''} | ${property.type} | ${bits.join(' ') || '—'} |`; } +/** + * Spell a tool's MCP annotations out in words, e.g. `write, destructive, not + * idempotent`. Words rather than the raw `readOnlyHint=…` flags because this + * line is read by people and models deciding whether a call is safe to make, + * and the spec's defaults make a raw flag that happens to be absent misleading. + */ +export function describeAnnotations(annotations: McpToolAnnotations): string { + const parts = annotations.readOnlyHint + ? ['read-only'] + : [ + 'write', + annotations.destructiveHint ? 'destructive' : 'non-destructive', + annotations.idempotentHint ? 'idempotent' : 'not idempotent', + ]; + parts.push(annotations.openWorldHint ? 'open-world' : 'closed-world'); + return parts.join(', '); +} + /** Render the full reference block for one tool. */ export function renderTool(tool: McpToolDoc): string { const lines: string[] = [`### ${tool.name}`, '', tool.description.replace(/\.?$/, '.'), '']; @@ -92,6 +110,8 @@ export function renderTool(tool: McpToolDoc): string { lines.push('Requires a dashboard session JWT — not available via `lk_*` tokens.', ''); } + lines.push(`MCP annotations: ${describeAnnotations(tool.annotations)}.`, ''); + if (tool.returns) lines.push(`Returns: ${tool.returns}`, ''); for (const note of tool.notes ?? []) lines.push(note, ''); diff --git a/packages/schemas/src/shared/tool-catalog.ts b/packages/schemas/src/shared/tool-catalog.ts index 043e58f09..3f3738b94 100644 --- a/packages/schemas/src/shared/tool-catalog.ts +++ b/packages/schemas/src/shared/tool-catalog.ts @@ -5,7 +5,8 @@ * Two consumers, deliberately: * * 1. `supabase/functions/mcp/mcp-handler.ts` renders `tools/list` from it - * (via `toWireTool`, which drops the docs-only fields). Before this + * (via `toWireTool`, which drops the docs-only fields and keeps the + * name, description, inputSchema and MCP annotations). Before this * module the tool list was an inline literal in that handler. * 2. `packages/schemas/src/llms/render.ts` renders the "MCP tools" and * "Permission matrix" sections of `packages/web/public/llms.txt`. Before @@ -127,6 +128,45 @@ export interface JsonSchemaObject { readonly properties?: Readonly>; } +/** + * The MCP `ToolAnnotations` a tool is advertised with — the behaviour hints a + * client reads to decide how to present and gate a tool (spec 2025-03-26 and + * later: `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`). + * + * All four are REQUIRED here even though the spec makes them optional, because + * an absent hint takes the spec's default — `readOnlyHint: false`, + * `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true` — + * which is the most dangerous reading of every tool. That is exactly what + * clients showed while the catalog sent no annotations at all: all 22 tools, + * `memory.read` and `memory.search` included, rendered as write + destructive. + * Stating every hint means no tool's classification rests on a default, and a + * new tool cannot be added without deciding. + * + * How each hint is decided: + * + * - `readOnlyHint` — true exactly when `permission === 'read'` + * (`tool-catalog-parity.spec.ts` pins the equivalence). Read tools do bump + * LoreKit's own read counters (`read_count`, `last_opened_at`); that is + * bookkeeping ABOUT the call, not a change to the lore the caller works + * with, so it does not make them writes. + * - `destructiveHint` — true when the call can delete data or overwrite + * existing data in place. Soft-archive (restorable with `memory.restore`), + * restore, protect and creating a new record are non-destructive. The spec + * calls this hint meaningful only for writes; read tools still state + * `false` so a client that reads it unconditionally never sees the default. + * - `idempotentHint` — true when repeating the same call with the same + * arguments has no further effect. Read tools state `true`. + * - `openWorldHint` — false on every tool. Each one acts on LoreKit's own + * store and nothing outside it; the spec's own example of a closed-world + * tool is a memory tool. + */ +export interface McpToolAnnotations { + readonly readOnlyHint: boolean; + readonly destructiveHint: boolean; + readonly idempotentHint: boolean; + readonly openWorldHint: boolean; +} + export interface McpToolDoc { /** Wire name, e.g. `memory.write`. */ readonly name: string; @@ -134,6 +174,8 @@ export interface McpToolDoc { readonly description: string; /** Sent verbatim as the MCP `inputSchema`. */ readonly inputSchema: JsonSchemaObject; + /** Sent verbatim as the MCP `annotations`. See `McpToolAnnotations`. */ + readonly annotations: McpToolAnnotations; /** Token permission family. `null` only if not token-gated at all. */ readonly permission: McpToolPermission; /** Auth tiers accepted. */ @@ -160,6 +202,17 @@ const readScope: JsonSchemaProperty = { type: 'string', description: 'Canonical const key: JsonSchemaProperty = { type: 'string', description: 'Lesson identifier, unique within the scope. Max 512 characters.' }; const limit: JsonSchemaProperty = { type: 'integer', minimum: 1, maximum: 100, default: 50, description: 'Maximum entries to return.' }; +/** Annotations for every `permission: 'read'` tool. See `McpToolAnnotations`. */ +const READ_ONLY: McpToolAnnotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }; + +/** + * Annotations for a `permission: 'write'` tool. Named arguments rather than two + * positional booleans, so each catalog entry reads as the decision it records. + */ +function writeHints({ destructive, idempotent }: { destructive: boolean; idempotent: boolean }): McpToolAnnotations { + return { readOnlyHint: false, destructiveHint: destructive, idempotentHint: idempotent, openWorldHint: false }; +} + /** * The EIGHT dimension filters a retention policy (or an inline groom call) * can carry (migration 00093) — the same set the Lore Explorer's filter bar @@ -226,6 +279,9 @@ export const MCP_TOOLS = [ name: 'memory.write', description: 'Store or update a lesson', permission: 'write', + // Destructive: an upsert onto an existing scope+key overwrites its value in + // place. Not idempotent: every write bumps the lesson's `seen_count`. + annotations: writeHints({ destructive: true, idempotent: false }), auth: 'token-or-jwt', surfaces: { mcp: true, cli: 'write', rest: 'POST /', handler: 'toolWrite' }, inputSchema: { @@ -313,6 +369,7 @@ export const MCP_TOOLS = [ description: 'Read one lesson by `key`, or several at once by `refs`. Pass exactly one of those two shapes: `key` (optionally narrowed with `scope`), or `refs` alone — a call carrying both is rejected, and so is one carrying neither. `scope` is optional: omit it and the key is resolved across every scope you can see, preferring the most specific one.', permission: 'read', + annotations: READ_ONLY, auth: 'token-or-jwt', surfaces: { mcp: true, cli: 'show', rest: 'GET /:id', handler: 'toolRead' }, inputSchema: { @@ -345,6 +402,7 @@ export const MCP_TOOLS = [ name: 'memory.list', description: 'List lessons, for one scope or across every scope you can see', permission: 'read', + annotations: READ_ONLY, auth: 'token-or-jwt', surfaces: { mcp: true, cli: 'list', cliAliases: ['ls'], rest: 'GET /', handler: 'toolList' }, inputSchema: { @@ -371,6 +429,8 @@ export const MCP_TOOLS = [ description: 'Soft-archive a lesson (default) or hard-delete it (force: true). Archived lessons are hidden from reads but can be restored.', permission: 'write', + // Destructive: `force: true` hard-deletes, unrecoverably. + annotations: writeHints({ destructive: true, idempotent: true }), auth: 'token-or-jwt', // `?force=true` is what tells this apart from `memory.archive` on the same route. surfaces: { mcp: true, cli: 'delete', cliAliases: ['rm'], rest: 'DELETE /?force=true', handler: 'toolDelete' }, @@ -395,6 +455,7 @@ export const MCP_TOOLS = [ name: 'memory.search', description: 'Full-text search across lessons', permission: 'read', + annotations: READ_ONLY, auth: 'token-or-jwt', surfaces: { mcp: true, cli: 'search', cliAliases: ['grep'], rest: 'POST /search', handler: 'toolSearch' }, inputSchema: { @@ -418,6 +479,8 @@ export const MCP_TOOLS = [ name: 'memory.archive', description: 'Soft-archive a lesson. Archived lessons are hidden from reads but can be restored via memory.restore.', permission: 'write', + // Not destructive: a soft-archive loses nothing and `memory.restore` undoes it. + annotations: writeHints({ destructive: false, idempotent: true }), auth: 'token-or-jwt', // Same route as `memory.delete`, distinguished by the ABSENCE of `?force=true`. surfaces: { mcp: true, cli: 'archive', rest: 'DELETE /', handler: 'toolArchive' }, @@ -433,6 +496,7 @@ export const MCP_TOOLS = [ + 'read tools answer questions about lore; this one answers "what is there?" — reach for it ' + 'when you want to NAME a scope, to narrow a list or to decide where a write belongs.', permission: 'read', + annotations: READ_ONLY, auth: 'token-or-jwt', surfaces: { mcp: true, cli: 'scopes', rest: 'GET /scopes', handler: 'toolScopes' }, inputSchema: { type: 'object', properties: {} }, @@ -442,6 +506,7 @@ export const MCP_TOOLS = [ name: 'memory.list_archived', description: 'List archived (soft-deleted) lessons, for one scope or across every scope you can see', permission: 'read', + annotations: READ_ONLY, auth: 'token-or-jwt', surfaces: { mcp: true, @@ -461,6 +526,7 @@ export const MCP_TOOLS = [ name: 'memory.restore', description: 'Restore an archived lesson back to active', permission: 'write', + annotations: writeHints({ destructive: false, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, cli: 'restore', rest: 'POST /restore', handler: 'toolRestore' }, inputSchema: { type: 'object', required: ['scope', 'key'], properties: { scope, key } }, @@ -470,6 +536,8 @@ export const MCP_TOOLS = [ name: 'memory.purge', description: `Permanently delete archived lessons older than retention_days (default ${PURGE_RETENTION_DAYS_DEFAULT}). Unrecoverable.`, permission: 'write', + // Destructive: permanently deletes archived lessons. + annotations: writeHints({ destructive: true, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, @@ -496,6 +564,8 @@ export const MCP_TOOLS = [ name: 'memory.purge_expired', description: 'Permanently delete all TTL-expired memories for the current user. Unrecoverable.', permission: 'write', + // Destructive: permanently deletes TTL-expired lessons. + annotations: writeHints({ destructive: true, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, @@ -512,6 +582,8 @@ export const MCP_TOOLS = [ description: 'Create a new organization. You become its owner automatically. The slug must be globally unique and lowercase.', permission: 'write', + // Not idempotent: a repeated call is refused on the now-taken slug. + annotations: writeHints({ destructive: false, idempotent: false }), auth: 'token-or-jwt', surfaces: { mcp: true, cli: null, cliExempt: ORG_CLI_EXEMPT, rest: 'POST /orgs', handler: 'toolOrgCreate' }, inputSchema: { @@ -528,6 +600,7 @@ export const MCP_TOOLS = [ name: 'org.list', description: 'List all organizations you are a member of, with your role in each.', permission: 'read', + annotations: READ_ONLY, auth: 'token-or-jwt', surfaces: { mcp: true, cli: null, cliExempt: ORG_CLI_EXEMPT, rest: 'GET /orgs', handler: 'toolOrgList' }, inputSchema: { type: 'object', properties: {} }, @@ -537,6 +610,8 @@ export const MCP_TOOLS = [ name: 'org.rename', description: "Rename an organization's display name. Requires admin or owner role.", permission: 'write', + // Destructive: overwrites the display name in place. + annotations: writeHints({ destructive: true, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, cli: null, cliExempt: ORG_CLI_EXEMPT, rest: 'PATCH /orgs/:slug', handler: 'toolOrgRename' }, inputSchema: { @@ -554,6 +629,8 @@ export const MCP_TOOLS = [ description: 'Delete an organization. Requires owner role. Soft-deletes the org — all org lore is immediately hidden from reads. Unrecoverable via MCP.', permission: 'write', + // Destructive: the org and its lore are gone from reads, unrecoverably via MCP. + annotations: writeHints({ destructive: true, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, cli: null, cliExempt: ORG_CLI_EXEMPT, rest: 'DELETE /orgs/:slug', handler: 'toolOrgDelete' }, inputSchema: { @@ -567,6 +644,7 @@ export const MCP_TOOLS = [ name: 'policy.list', description: 'List every retention policy you own', permission: 'read', + annotations: READ_ONLY, auth: 'token-or-jwt', surfaces: { mcp: true, @@ -582,6 +660,8 @@ export const MCP_TOOLS = [ name: 'policy.create', description: 'Create a scoped retention policy that auto-archives (never hard-deletes) matching lessons', permission: 'write', + // Not idempotent: policies have no natural key, so a repeat creates a duplicate. + annotations: writeHints({ destructive: false, idempotent: false }), auth: 'token-or-jwt', surfaces: { mcp: true, @@ -614,6 +694,8 @@ export const MCP_TOOLS = [ name: 'policy.update', description: 'Update a retention policy. Every field but id is optional', permission: 'write', + // Destructive: overwrites the policy's conditions in place. + annotations: writeHints({ destructive: true, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, @@ -646,6 +728,8 @@ export const MCP_TOOLS = [ name: 'policy.delete', description: 'Delete a retention policy. Deletes the rule only — never touches the lessons it matched', permission: 'write', + // Destructive: hard-deletes the rule (never the lessons it matched). + annotations: writeHints({ destructive: true, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, @@ -662,6 +746,7 @@ export const MCP_TOOLS = [ name: 'groom.preview', description: 'Preview the lessons a saved policy or an inline condition set would archive, without changing anything', permission: 'read', + annotations: READ_ONLY, auth: 'token-or-jwt', surfaces: { mcp: true, @@ -690,6 +775,9 @@ export const MCP_TOOLS = [ name: 'groom.run', description: 'Archive every lesson a saved policy or an inline condition set matches. Soft-archive only — never hard-deletes', permission: 'write', + // Not destructive: soft-archive only, and every archived key is returned for + // `memory.restore`. Same reasoning as `memory.archive`. + annotations: writeHints({ destructive: false, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, @@ -719,6 +807,7 @@ export const MCP_TOOLS = [ name: 'memory.protect', description: 'Mark or unmark a lesson as protected — excluded from every grooming candidate set regardless of policy', permission: 'write', + annotations: writeHints({ destructive: false, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, @@ -765,11 +854,17 @@ export interface WireTool { readonly name: string; readonly description: string; readonly inputSchema: JsonSchemaObject; + readonly annotations: McpToolAnnotations; } /** Project a catalog entry onto the MCP wire shape. */ export function toWireTool(tool: McpToolDoc): WireTool { - return { name: tool.name, description: tool.description, inputSchema: tool.inputSchema }; + return { + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: tool.annotations, + }; } /** The full `tools/list` payload. */ diff --git a/packages/web/public/llms.txt b/packages/web/public/llms.txt index 7e4e7fa9f..236b5ef9c 100644 --- a/packages/web/public/llms.txt +++ b/packages/web/public/llms.txt @@ -227,6 +227,8 @@ Store or update a lesson. Requires **write** permission. +MCP annotations: write, destructive, not idempotent, closed-world. + Returns: `{ "id": "", "created_at": "" }` — plus optional `"expires_at"` and `"notice"` (when a write fell back to personal because the scope is bound to an org the caller cannot write to). **Provenance (`origin_*`):** `scope` says where a lesson *applies*; the four `origin_*` fields say where it was *recorded from*. Each is independently optional and the last KNOWN value wins — on an update, a field you omit keeps whatever a previous write recorded rather than being erased. A malformed value is rejected, never silently dropped. @@ -249,6 +251,8 @@ Read one lesson by `key`, or several at once by `refs`. Pass exactly one of thos Requires **read** permission. +MCP annotations: read-only, closed-world. + Returns: `{ "value": "", "updated_at": "", "scope": "" }` or `null` if not found. `scope` names the scope that ANSWERED — always present, so an unscoped read can tell a `global` hit from a `repo::…` one. When the key also existed in other scopes, an additional `other_scopes` array names them in precedence order; it is omitted entirely when the key was unambiguous. With `refs`, instead returns `{ "entries": [{ "scope", "key", "value", "updated_at" }], "missing": ["scope::key", …] }` — `missing` names every well-formed reference within the first 32 that matched no lesson. **Scope is optional.** `memory.read { key }` with no scope resolves that key across every scope you can see and returns the most specific match — `project` beats `branch` beats `repo` beats `global`, with ties broken by most-recently-updated then scope ascending. This is the shape to use when a key reached you through a session-start injection and you do not know which scope it came from. Pass `scope` when you DO know it: it is one indexed row instead of a fan-out, and it removes the ambiguity entirely. Either way the response names the scope that answered. @@ -276,6 +280,8 @@ List lessons, for one scope or across every scope you can see. Requires **read** permission. +MCP annotations: read-only, closed-world. + Returns: `{ "entries": [{ "scope", "key", "value", "tags", "updated_at" }], "hasMore": boolean, "nextCursor": string | null }` — every entry names its own `scope`, so an unscoped listing stays readable; newest-first (recency mode) or ranked by salience+recency then MMR-diversified (rank mode). Because rank mode diversifies, entries are NOT strictly score-descending — a more diverse lower-scored lesson can precede a higher-scored near-duplicate. Pass `nextCursor` back as `cursor` to paginate — recency mode only. Rank mode is a single bounded top-N page: `hasMore` is always false and `nextCursor` always null. With `view: "summary"` each entry is `{ "scope", "key", "tags", "updated_at", "value_bytes", "preview" }` — `value` is omitted entirely. --- @@ -293,6 +299,8 @@ Soft-archive a lesson (default) or hard-delete it (force: true). Archived lesson Requires **write** permission. +MCP annotations: write, destructive, idempotent, closed-world. + Returns: `{ "deleted": boolean, "archived": boolean }` — soft-archive returns `{ deleted: false, archived: true }`, hard-delete returns `{ deleted: true, archived: false }`, both `false` when the lesson was not found. --- @@ -311,6 +319,8 @@ Full-text search across lessons. Requires **read** permission. +MCP annotations: read-only, closed-world. + Returns: `{ "entries": [{ "key", "value", "scope", "tags", "rank" }], "hasMore": boolean, "nextCursor": string | null }`. Pass `nextCursor` back as `cursor` to retrieve the next page. --- @@ -326,6 +336,8 @@ Soft-archive a lesson. Archived lessons are hidden from reads but can be restore Requires **write** permission. +MCP annotations: write, non-destructive, idempotent, closed-world. + Returns: `{ "archived": true }` if found and archived, `{ "archived": false }` if already archived or not found. --- @@ -338,6 +350,8 @@ No arguments. Requires **read** permission. +MCP annotations: read-only, closed-world. + Returns: `{ "scopes": [{ "scope", "count", "last_activity" }] }`, sorted by count desc then scope asc (busiest scope first). `count` is active (non-archived, non-expired) memories; `last_activity` is the newest `created_at` among them, or `null`. --- @@ -353,6 +367,8 @@ List archived (soft-deleted) lessons, for one scope or across every scope you ca Requires **read** permission. +MCP annotations: read-only, closed-world. + Returns: `{ "entries": [{ "scope", "key", "value", "tags", "updated_at", "archived_at" }] }` — every entry names its own `scope`, so an unscoped listing stays readable. --- @@ -368,6 +384,8 @@ Restore an archived lesson back to active. Requires **write** permission. +MCP annotations: write, non-destructive, idempotent, closed-world. + Returns: `{ "restored": true }` if restored, `{ "restored": false }` if already active or not found. --- @@ -382,6 +400,8 @@ Permanently delete archived lessons older than retention_days (default 30). Unre Requires **write** permission. +MCP annotations: write, destructive, idempotent, closed-world. + Returns: `{ "purged": }` --- @@ -394,6 +414,8 @@ No arguments. Requires **write** permission. +MCP annotations: write, destructive, idempotent, closed-world. + Returns: `{ "purged": }` --- @@ -409,6 +431,8 @@ Create a new organization. You become its owner automatically. The slug must be Requires **write** permission. +MCP annotations: write, non-destructive, not idempotent, closed-world. + Returns: `{ "id", "slug", "name" }` --- @@ -421,6 +445,8 @@ No arguments. Requires **read** permission. +MCP annotations: read-only, closed-world. + Returns: `{ "entries": [{ "id", "slug", "name", "role", "created_at" }] }` — roles: `owner`, `admin`, `member`, `viewer`. --- @@ -436,6 +462,8 @@ Rename an organization's display name. Requires admin or owner role. Requires **write** permission. +MCP annotations: write, destructive, idempotent, closed-world. + Returns: `{ "id", "slug", "name" }` --- @@ -450,6 +478,8 @@ Delete an organization. Requires owner role. Soft-deletes the org — all org lo Requires **write** permission. +MCP annotations: write, destructive, idempotent, closed-world. + Returns: `{ "deleted": true, "slug": "" }` --- @@ -462,6 +492,8 @@ No arguments. Requires **read** permission. +MCP annotations: read-only, closed-world. + Returns: `{ "entries": [{ "id", "scope", "name", "mode", "enabled", "min_age_days", "unseen_days", "max_seen_count", "max_read_count", "max_opened_count", "created_at", "updated_at" }] }` --- @@ -500,6 +532,8 @@ Create a scoped retention policy that auto-archives (never hard-deletes) matchin Requires **write** permission. +MCP annotations: write, non-destructive, not idempotent, closed-world. + Returns: The created policy object. Every condition is AND-ed together; a policy with no conditions at all matches every non-protected lesson in scope. @@ -540,6 +574,8 @@ Update a retention policy. Every field but id is optional. Requires **write** permission. +MCP annotations: write, destructive, idempotent, closed-world. + Returns: The updated policy object. An omitted field is left unchanged; an explicit `null` clears that condition. @@ -556,6 +592,8 @@ Delete a retention policy. Deletes the rule only — never touches the lessons i Requires **write** permission. +MCP annotations: write, destructive, idempotent, closed-world. + Returns: `{ "deleted": boolean }` --- @@ -592,6 +630,8 @@ Preview the lessons a saved policy or an inline condition set would archive, wit Requires **read** permission. +MCP annotations: read-only, closed-world. + Returns: `{ "count": , "keys": [{ "scope", "key" }] }` — the SAME candidates `groom.run` would archive. Pass either `policy_id` or `scope` (with optional conditions), never both. @@ -630,6 +670,8 @@ Archive every lesson a saved policy or an inline condition set matches. Soft-arc Requires **write** permission. +MCP annotations: write, non-destructive, idempotent, closed-world. + Returns: `{ "archived": , "keys": [{ "scope", "key" }] }` Resolves and archives the SAME candidates `groom.preview` shows, in one transaction. Archived lessons are recoverable via `memory.restore`. @@ -648,6 +690,8 @@ Mark or unmark a lesson as protected — excluded from every grooming candidate Requires **write** permission. +MCP annotations: write, non-destructive, idempotent, closed-world. + Returns: `{ "protected": boolean }` --- diff --git a/scripts/codegen/gen-surfaces.mjs b/scripts/codegen/gen-surfaces.mjs index dd1f3e2c5..70599035e 100644 --- a/scripts/codegen/gen-surfaces.mjs +++ b/scripts/codegen/gen-surfaces.mjs @@ -127,7 +127,8 @@ export const MEMORY_TOOL_NAMES = ${literal(memory)}; export const ORG_TOOL_NAMES = ${literal(org)}; /** - * The \`tools/list\` payload: name, description and inputSchema per op. + * The \`tools/list\` payload: name, description, inputSchema and MCP + * annotations per op. * Identical projection to the edge server's, from the same declaration, so the * local stdio server and the hosted server advertise the same contract. */ diff --git a/scripts/smoke/smoke-mcp-tools.mjs b/scripts/smoke/smoke-mcp-tools.mjs index 1c9c3db81..a89ba9f60 100644 --- a/scripts/smoke/smoke-mcp-tools.mjs +++ b/scripts/smoke/smoke-mcp-tools.mjs @@ -475,6 +475,9 @@ check('every advertised description and schema matches the catalog', async () => ok(live, `${expected.name} is missing from tools/list`); eq(live.description, expected.description, `${expected.name} description`); eq(live.inputSchema, expected.inputSchema, `${expected.name} inputSchema`); + // Without annotations a client applies the MCP defaults and shows every + // tool, reads included, as write + destructive. + eq(live.annotations, expected.annotations, `${expected.name} annotations`); } }); diff --git a/supabase/functions/_shared/schemas/tool-catalog.ts b/supabase/functions/_shared/schemas/tool-catalog.ts index 4bf82c766..c5c0fb733 100644 --- a/supabase/functions/_shared/schemas/tool-catalog.ts +++ b/supabase/functions/_shared/schemas/tool-catalog.ts @@ -10,7 +10,8 @@ * Two consumers, deliberately: * * 1. `supabase/functions/mcp/mcp-handler.ts` renders `tools/list` from it - * (via `toWireTool`, which drops the docs-only fields). Before this + * (via `toWireTool`, which drops the docs-only fields and keeps the + * name, description, inputSchema and MCP annotations). Before this * module the tool list was an inline literal in that handler. * 2. `packages/schemas/src/llms/render.ts` renders the "MCP tools" and * "Permission matrix" sections of `packages/web/public/llms.txt`. Before @@ -132,6 +133,45 @@ export interface JsonSchemaObject { readonly properties?: Readonly>; } +/** + * The MCP `ToolAnnotations` a tool is advertised with — the behaviour hints a + * client reads to decide how to present and gate a tool (spec 2025-03-26 and + * later: `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`). + * + * All four are REQUIRED here even though the spec makes them optional, because + * an absent hint takes the spec's default — `readOnlyHint: false`, + * `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true` — + * which is the most dangerous reading of every tool. That is exactly what + * clients showed while the catalog sent no annotations at all: all 22 tools, + * `memory.read` and `memory.search` included, rendered as write + destructive. + * Stating every hint means no tool's classification rests on a default, and a + * new tool cannot be added without deciding. + * + * How each hint is decided: + * + * - `readOnlyHint` — true exactly when `permission === 'read'` + * (`tool-catalog-parity.spec.ts` pins the equivalence). Read tools do bump + * LoreKit's own read counters (`read_count`, `last_opened_at`); that is + * bookkeeping ABOUT the call, not a change to the lore the caller works + * with, so it does not make them writes. + * - `destructiveHint` — true when the call can delete data or overwrite + * existing data in place. Soft-archive (restorable with `memory.restore`), + * restore, protect and creating a new record are non-destructive. The spec + * calls this hint meaningful only for writes; read tools still state + * `false` so a client that reads it unconditionally never sees the default. + * - `idempotentHint` — true when repeating the same call with the same + * arguments has no further effect. Read tools state `true`. + * - `openWorldHint` — false on every tool. Each one acts on LoreKit's own + * store and nothing outside it; the spec's own example of a closed-world + * tool is a memory tool. + */ +export interface McpToolAnnotations { + readonly readOnlyHint: boolean; + readonly destructiveHint: boolean; + readonly idempotentHint: boolean; + readonly openWorldHint: boolean; +} + export interface McpToolDoc { /** Wire name, e.g. `memory.write`. */ readonly name: string; @@ -139,6 +179,8 @@ export interface McpToolDoc { readonly description: string; /** Sent verbatim as the MCP `inputSchema`. */ readonly inputSchema: JsonSchemaObject; + /** Sent verbatim as the MCP `annotations`. See `McpToolAnnotations`. */ + readonly annotations: McpToolAnnotations; /** Token permission family. `null` only if not token-gated at all. */ readonly permission: McpToolPermission; /** Auth tiers accepted. */ @@ -165,6 +207,17 @@ const readScope: JsonSchemaProperty = { type: 'string', description: 'Canonical const key: JsonSchemaProperty = { type: 'string', description: 'Lesson identifier, unique within the scope. Max 512 characters.' }; const limit: JsonSchemaProperty = { type: 'integer', minimum: 1, maximum: 100, default: 50, description: 'Maximum entries to return.' }; +/** Annotations for every `permission: 'read'` tool. See `McpToolAnnotations`. */ +const READ_ONLY: McpToolAnnotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }; + +/** + * Annotations for a `permission: 'write'` tool. Named arguments rather than two + * positional booleans, so each catalog entry reads as the decision it records. + */ +function writeHints({ destructive, idempotent }: { destructive: boolean; idempotent: boolean }): McpToolAnnotations { + return { readOnlyHint: false, destructiveHint: destructive, idempotentHint: idempotent, openWorldHint: false }; +} + /** * The EIGHT dimension filters a retention policy (or an inline groom call) * can carry (migration 00093) — the same set the Lore Explorer's filter bar @@ -231,6 +284,9 @@ export const MCP_TOOLS = [ name: 'memory.write', description: 'Store or update a lesson', permission: 'write', + // Destructive: an upsert onto an existing scope+key overwrites its value in + // place. Not idempotent: every write bumps the lesson's `seen_count`. + annotations: writeHints({ destructive: true, idempotent: false }), auth: 'token-or-jwt', surfaces: { mcp: true, cli: 'write', rest: 'POST /', handler: 'toolWrite' }, inputSchema: { @@ -318,6 +374,7 @@ export const MCP_TOOLS = [ description: 'Read one lesson by `key`, or several at once by `refs`. Pass exactly one of those two shapes: `key` (optionally narrowed with `scope`), or `refs` alone — a call carrying both is rejected, and so is one carrying neither. `scope` is optional: omit it and the key is resolved across every scope you can see, preferring the most specific one.', permission: 'read', + annotations: READ_ONLY, auth: 'token-or-jwt', surfaces: { mcp: true, cli: 'show', rest: 'GET /:id', handler: 'toolRead' }, inputSchema: { @@ -350,6 +407,7 @@ export const MCP_TOOLS = [ name: 'memory.list', description: 'List lessons, for one scope or across every scope you can see', permission: 'read', + annotations: READ_ONLY, auth: 'token-or-jwt', surfaces: { mcp: true, cli: 'list', cliAliases: ['ls'], rest: 'GET /', handler: 'toolList' }, inputSchema: { @@ -376,6 +434,8 @@ export const MCP_TOOLS = [ description: 'Soft-archive a lesson (default) or hard-delete it (force: true). Archived lessons are hidden from reads but can be restored.', permission: 'write', + // Destructive: `force: true` hard-deletes, unrecoverably. + annotations: writeHints({ destructive: true, idempotent: true }), auth: 'token-or-jwt', // `?force=true` is what tells this apart from `memory.archive` on the same route. surfaces: { mcp: true, cli: 'delete', cliAliases: ['rm'], rest: 'DELETE /?force=true', handler: 'toolDelete' }, @@ -400,6 +460,7 @@ export const MCP_TOOLS = [ name: 'memory.search', description: 'Full-text search across lessons', permission: 'read', + annotations: READ_ONLY, auth: 'token-or-jwt', surfaces: { mcp: true, cli: 'search', cliAliases: ['grep'], rest: 'POST /search', handler: 'toolSearch' }, inputSchema: { @@ -423,6 +484,8 @@ export const MCP_TOOLS = [ name: 'memory.archive', description: 'Soft-archive a lesson. Archived lessons are hidden from reads but can be restored via memory.restore.', permission: 'write', + // Not destructive: a soft-archive loses nothing and `memory.restore` undoes it. + annotations: writeHints({ destructive: false, idempotent: true }), auth: 'token-or-jwt', // Same route as `memory.delete`, distinguished by the ABSENCE of `?force=true`. surfaces: { mcp: true, cli: 'archive', rest: 'DELETE /', handler: 'toolArchive' }, @@ -438,6 +501,7 @@ export const MCP_TOOLS = [ + 'read tools answer questions about lore; this one answers "what is there?" — reach for it ' + 'when you want to NAME a scope, to narrow a list or to decide where a write belongs.', permission: 'read', + annotations: READ_ONLY, auth: 'token-or-jwt', surfaces: { mcp: true, cli: 'scopes', rest: 'GET /scopes', handler: 'toolScopes' }, inputSchema: { type: 'object', properties: {} }, @@ -447,6 +511,7 @@ export const MCP_TOOLS = [ name: 'memory.list_archived', description: 'List archived (soft-deleted) lessons, for one scope or across every scope you can see', permission: 'read', + annotations: READ_ONLY, auth: 'token-or-jwt', surfaces: { mcp: true, @@ -466,6 +531,7 @@ export const MCP_TOOLS = [ name: 'memory.restore', description: 'Restore an archived lesson back to active', permission: 'write', + annotations: writeHints({ destructive: false, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, cli: 'restore', rest: 'POST /restore', handler: 'toolRestore' }, inputSchema: { type: 'object', required: ['scope', 'key'], properties: { scope, key } }, @@ -475,6 +541,8 @@ export const MCP_TOOLS = [ name: 'memory.purge', description: `Permanently delete archived lessons older than retention_days (default ${PURGE_RETENTION_DAYS_DEFAULT}). Unrecoverable.`, permission: 'write', + // Destructive: permanently deletes archived lessons. + annotations: writeHints({ destructive: true, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, @@ -501,6 +569,8 @@ export const MCP_TOOLS = [ name: 'memory.purge_expired', description: 'Permanently delete all TTL-expired memories for the current user. Unrecoverable.', permission: 'write', + // Destructive: permanently deletes TTL-expired lessons. + annotations: writeHints({ destructive: true, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, @@ -517,6 +587,8 @@ export const MCP_TOOLS = [ description: 'Create a new organization. You become its owner automatically. The slug must be globally unique and lowercase.', permission: 'write', + // Not idempotent: a repeated call is refused on the now-taken slug. + annotations: writeHints({ destructive: false, idempotent: false }), auth: 'token-or-jwt', surfaces: { mcp: true, cli: null, cliExempt: ORG_CLI_EXEMPT, rest: 'POST /orgs', handler: 'toolOrgCreate' }, inputSchema: { @@ -533,6 +605,7 @@ export const MCP_TOOLS = [ name: 'org.list', description: 'List all organizations you are a member of, with your role in each.', permission: 'read', + annotations: READ_ONLY, auth: 'token-or-jwt', surfaces: { mcp: true, cli: null, cliExempt: ORG_CLI_EXEMPT, rest: 'GET /orgs', handler: 'toolOrgList' }, inputSchema: { type: 'object', properties: {} }, @@ -542,6 +615,8 @@ export const MCP_TOOLS = [ name: 'org.rename', description: "Rename an organization's display name. Requires admin or owner role.", permission: 'write', + // Destructive: overwrites the display name in place. + annotations: writeHints({ destructive: true, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, cli: null, cliExempt: ORG_CLI_EXEMPT, rest: 'PATCH /orgs/:slug', handler: 'toolOrgRename' }, inputSchema: { @@ -559,6 +634,8 @@ export const MCP_TOOLS = [ description: 'Delete an organization. Requires owner role. Soft-deletes the org — all org lore is immediately hidden from reads. Unrecoverable via MCP.', permission: 'write', + // Destructive: the org and its lore are gone from reads, unrecoverably via MCP. + annotations: writeHints({ destructive: true, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, cli: null, cliExempt: ORG_CLI_EXEMPT, rest: 'DELETE /orgs/:slug', handler: 'toolOrgDelete' }, inputSchema: { @@ -572,6 +649,7 @@ export const MCP_TOOLS = [ name: 'policy.list', description: 'List every retention policy you own', permission: 'read', + annotations: READ_ONLY, auth: 'token-or-jwt', surfaces: { mcp: true, @@ -587,6 +665,8 @@ export const MCP_TOOLS = [ name: 'policy.create', description: 'Create a scoped retention policy that auto-archives (never hard-deletes) matching lessons', permission: 'write', + // Not idempotent: policies have no natural key, so a repeat creates a duplicate. + annotations: writeHints({ destructive: false, idempotent: false }), auth: 'token-or-jwt', surfaces: { mcp: true, @@ -619,6 +699,8 @@ export const MCP_TOOLS = [ name: 'policy.update', description: 'Update a retention policy. Every field but id is optional', permission: 'write', + // Destructive: overwrites the policy's conditions in place. + annotations: writeHints({ destructive: true, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, @@ -651,6 +733,8 @@ export const MCP_TOOLS = [ name: 'policy.delete', description: 'Delete a retention policy. Deletes the rule only — never touches the lessons it matched', permission: 'write', + // Destructive: hard-deletes the rule (never the lessons it matched). + annotations: writeHints({ destructive: true, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, @@ -667,6 +751,7 @@ export const MCP_TOOLS = [ name: 'groom.preview', description: 'Preview the lessons a saved policy or an inline condition set would archive, without changing anything', permission: 'read', + annotations: READ_ONLY, auth: 'token-or-jwt', surfaces: { mcp: true, @@ -695,6 +780,9 @@ export const MCP_TOOLS = [ name: 'groom.run', description: 'Archive every lesson a saved policy or an inline condition set matches. Soft-archive only — never hard-deletes', permission: 'write', + // Not destructive: soft-archive only, and every archived key is returned for + // `memory.restore`. Same reasoning as `memory.archive`. + annotations: writeHints({ destructive: false, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, @@ -724,6 +812,7 @@ export const MCP_TOOLS = [ name: 'memory.protect', description: 'Mark or unmark a lesson as protected — excluded from every grooming candidate set regardless of policy', permission: 'write', + annotations: writeHints({ destructive: false, idempotent: true }), auth: 'token-or-jwt', surfaces: { mcp: true, @@ -770,11 +859,17 @@ export interface WireTool { readonly name: string; readonly description: string; readonly inputSchema: JsonSchemaObject; + readonly annotations: McpToolAnnotations; } /** Project a catalog entry onto the MCP wire shape. */ export function toWireTool(tool: McpToolDoc): WireTool { - return { name: tool.name, description: tool.description, inputSchema: tool.inputSchema }; + return { + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: tool.annotations, + }; } /** The full `tools/list` payload. */ From 4fc651a7e58999aaf341a69a35d580fe128cecf2 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 6 Oct 2026 10:08:47 +0000 Subject: [PATCH 2/2] address review comment: add the required annotations field to the add-operation guide Addresses the pr-reviewer bot's comment: https://github.com/mthines/lorekit/pull/683#discussion_r4193980317 Refs: https://github.com/mthines/lorekit/pull/683 Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- docs/adding-an-operation.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/adding-an-operation.md b/docs/adding-an-operation.md index 2a30a7ff2..efa075c26 100644 --- a/docs/adding-an-operation.md +++ b/docs/adding-an-operation.md @@ -47,11 +47,17 @@ catch a mistake at that step. 1. **Catalog** — add the operation to `MCP_TOOLS` in `packages/schemas/src/shared/tool-catalog.ts`: `name`, `description`, - `inputSchema`, `permission` (`'read' | 'write' | null`), `auth` + `inputSchema`, `annotations`, `permission` (`'read' | 'write' | null`), `auth` (`'token-or-jwt' | 'jwt-only'`), `surfaces` (with `cliExempt`/`localMcpExempt` as needed per step 1), `returns`, `notes`. This file is zero-import by construction (mirrored into the self-contained Deno edge runtime and read by a generator on a bare checkout) — never add an import to it. + `annotations` is the MCP tool annotations object and is required: use + `READ_ONLY` for a read tool, otherwise + `writeHints({ destructive, idempotent })`. The rules for each hint live in + the `McpToolAnnotations` doc comment in the same file. A tool marked + destructive must also be added to the pinned list in + `tool-catalog-parity.spec.ts`, which fails until it is. 2. **Regenerate the surface projections** — the catalog cannot be imported by two consumers, so a generator projects it for them: ```bash @@ -152,6 +158,7 @@ Adding a hypothetical `memory.pin` (write) that has no CLI verb of its own { name: 'memory.protect', description: 'Mark or unmark a lesson as protected from automated grooming', + annotations: writeHints({ destructive: false, idempotent: true }), permission: 'write', auth: 'token-or-jwt', surfaces: { mcp: true, cli: 'protect', rest: 'POST /protect', handler: 'toolProtect' },