Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/adding-an-operation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' },
Expand Down
31 changes: 31 additions & 0 deletions docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
135 changes: 134 additions & 1 deletion packages/cli/src/surfaces.generated.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
}
},
{
Expand All @@ -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
}
},
{
Expand Down Expand Up @@ -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
}
},
{
Expand Down Expand Up @@ -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
}
},
{
Expand Down Expand Up @@ -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
}
},
{
Expand All @@ -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
}
},
{
Expand All @@ -342,6 +379,12 @@ export const MCP_TOOL_DEFS = [
"inputSchema": {
"type": "object",
"properties": {}
},
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
},
{
Expand All @@ -362,6 +405,12 @@ export const MCP_TOOL_DEFS = [
"description": "Maximum entries to return."
}
}
},
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
},
{
Expand All @@ -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
}
},
{
Expand All @@ -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
}
},
{
Expand All @@ -407,6 +468,12 @@ export const MCP_TOOL_DEFS = [
"inputSchema": {
"type": "object",
"properties": {}
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": false
}
},
{
Expand All @@ -428,6 +495,12 @@ export const MCP_TOOL_DEFS = [
"description": "Human-readable display name"
}
}
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": false
}
},
{
Expand All @@ -436,6 +509,12 @@ export const MCP_TOOL_DEFS = [
"inputSchema": {
"type": "object",
"properties": {}
},
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
},
{
Expand All @@ -457,6 +536,12 @@ export const MCP_TOOL_DEFS = [
"description": "New display name"
}
}
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": false
}
},
{
Expand All @@ -473,6 +558,12 @@ export const MCP_TOOL_DEFS = [
"description": "The org slug to delete"
}
}
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": false
}
},
{
Expand All @@ -481,6 +572,12 @@ export const MCP_TOOL_DEFS = [
"inputSchema": {
"type": "object",
"properties": {}
},
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
},
{
Expand Down Expand Up @@ -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
}
},
{
Expand Down Expand Up @@ -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
}
},
{
Expand All @@ -882,6 +991,12 @@ export const MCP_TOOL_DEFS = [
"description": "The policy id to delete."
}
}
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": false
}
},
{
Expand Down Expand Up @@ -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
}
},
{
Expand Down Expand Up @@ -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
}
},
{
Expand All @@ -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
}
}
];
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/test/mcp-server.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
Loading
Loading