Skip to content

Commit 1781c4f

Browse files
cdeustclaude
andcommitted
feat(mcp): add title + annotations + outputSchema to every tool
Glama tool-score audit (2026-04-23) showed every Cortex MCP tool sitting in C/D territory (remember: D 1.5, recall/checkpoint: C 2.4, explore_features: C 2.3 …) because FastMCP registrations passed only ``name`` and ``description``. Glama scores on the presence of: - ``title`` (human-readable tool name) - ``annotations.readOnlyHint / destructiveHint / idempotentHint / openWorldHint`` (MCP 2024-11-05+ capability hints) - ``output_schema`` (declared return-shape JSON Schema) All three are now populated across every registered tool. Implementation 1. New ``handlers/_tool_meta.py`` exports five annotation presets (READ_ONLY, IDEMPOTENT_WRITE, NON_IDEMPOTENT_WRITE, DESTRUCTIVE, READ_ONLY_EXTERNAL) plus ``tool_kwargs(schema)`` that extracts the FastMCP-accepted fields (description, title, output_schema, annotations, tags) from a handler schema dict. 2. Every tool_registry_*.py now uses ``@mcp.tool(name=…, **tool_kwargs(handler.schema))`` instead of the old ``description=handler.schema["description"]`` one-liner. Forwards whatever metadata the handler chooses to declare. 3. Each handler's ``schema`` dict gains ``title`` + ``annotations`` (mandatory) and, where available, a bespoke ``outputSchema``. Five D/low-C handlers (remember, recall, checkpoint, record_session_end, explore_features) got hand-written output schemas describing their full response shape; the remaining 36 got title+annotations bulk-applied from a curated mapping. Verified by probing the assembled FastMCP instance: every registered tool reports a non-empty title, populated annotations, and — for the five priority handlers — a valid output_schema. 278/278 handler + server tests pass; ruff clean across mcp_server/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 601eb27 commit 1781c4f

60 files changed

Lines changed: 746 additions & 54 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<p align="center">
2-
<img src="docs/assets/cortex-workflow-graph.png" alt="Cortex workflow graph — each project becomes a dense brain-region cloud whose shape IS its code: files, commands, agents, memories and AST symbols (functions, methods, classes, modules, constants across 27 languages) are pulled into position by the real edges between them (defined_in, calls, imports, member_of, tool_used_file). Symbols touched by two projects sit in the inter-project space between their hubs; long threads mark shared files and MCPs." width="100%"/>
2+
<img src="docs/assets/cortex-workflow-graph.png" alt="Cortex workflow graph — each project becomes a dense brain-region cloud whose shape IS its code: files, commands, agents, memories and AST symbols (functions, methods, classes, modules, constants across 10 languages) are pulled into position by the real edges between them (defined_in, calls, imports, member_of, tool_used_file). Symbols touched by two projects sit in the inter-project space between their hubs; long threads mark shared files and MCPs." width="100%"/>
33
</p>
44

55
<p align="center">
@@ -31,7 +31,7 @@ Cortex is a persistent memory engine for Claude Code built on computational neur
3131

3232
**20 biological mechanisms. 33 MCP tools. 7 automatic hooks. Runs entirely on your machine. PostgreSQL + pgvector.**
3333

34-
**v3.14.0 neural-graph & AST-integration release**: the workflow graph now reveals itself one layer at a time — first your projects, then their tools, then the files those tools touched, then the code itself (functions, methods, classes) parsed from 27 languages. A symbol that is imported by two projects literally sits in the space between those two projects on the map, so the picture of *what connects to what* is the picture of your codebase. Each project is indexed once and cached on disk; reopening the graph hydrates in milliseconds, and only projects whose source actually changed are re-read. Click any node — a file, a function, a command — and the side panel lists the *named* things it is connected to (callers, imports, the files that used it) instead of a bare count. [Release notes →](https://github.com/cdeust/Cortex/releases/tag/v3.14.0)
34+
**v3.14.0 neural-graph & AST-integration release**: the workflow graph now reveals itself one layer at a time — first your projects, then their tools, then the files those tools touched, then the code itself (functions, methods, classes) parsed from 10 languages (Rust, Python, TypeScript, Java, Kotlin, Swift, Objective-C, C, C++, Go) via the [automatised-pipeline](https://github.com/cdeust/automatised-pipeline) Rust AST backend. A symbol that is imported by two projects literally sits in the space between those two projects on the map, so the picture of *what connects to what* is the picture of your codebase. Each project is indexed once and cached on disk; reopening the graph hydrates in milliseconds, and only projects whose source actually changed are re-read. Click any node — a file, a function, a command — and the side panel lists the *named* things it is connected to (callers, imports, the files that used it) instead of a bare count. [Release notes →](https://github.com/cdeust/Cortex/releases/tag/v3.14.0)
3535

3636
## Getting Started
3737

@@ -290,7 +290,7 @@ Launch with `/cortex-visualize`. The default landing view is **Graph** — a liv
290290
| **L3 · Files** | Every file Claude ever opened, read, edited, searched, or referenced in a Bash command — colored by primary tool (green edited / cyan read / fuchsia searched / orange bash-only) | Click for `first_seen`, `last_accessed`, `last_modified`, and a **See diff against HEAD** button that renders new/modified/deleted/historical content inline |
291291
| **L4 · Discussions** | One node per Claude Code session | Click for `started_at`, duration, message count, and a **View full conversation** button that replays every turn (including tool calls) |
292292
| **L5 · Memories** | Persistent memories, colored by consolidation stage (labile → early LTP → late LTP → consolidated → semantic) | Click for full content, tags, and every scientific measurement |
293-
| **L6 · AST symbols** | The code itself — functions (cyan), methods (sky), classes/structs/enums/traits/protocols (violet), modules/packages/namespaces (amber), constants/fields/properties (slate) — parsed from 27 languages and laid out as petals around their parent file in L3 | Click for qualified name, symbol type, parent file, and the named edges: `defined_in`, `calls`, `imports`, `member_of`. A symbol imported by two projects sits in the space between their clouds, making `what connects to what` literally the shape of the code |
293+
| **L6 · AST symbols** | The code itself — functions (cyan), methods (sky), classes/structs/enums/traits/protocols (violet), modules/packages/namespaces (amber), constants/fields/properties (slate) — parsed from 10 languages (Rust, Python, TypeScript, Java, Kotlin, Swift, Objective-C, C, C++, Go) and laid out as petals around their parent file in L3 | Click for qualified name, symbol type, parent file, and the named edges: `defined_in`, `calls`, `imports`, `member_of`. A symbol imported by two projects sits in the space between their clouds, making `what connects to what` literally the shape of the code |
294294

295295
**What L6 is for.** L5 and below tell you *what Claude did*; L6 tells you *what the code is*. Once AST symbols are on the map, three things become visible for free: (1) **shared code** — any function, class or module referenced by two projects drifts into the inter-project gap, so reused primitives reveal themselves without a dependency audit; (2) **impact** — clicking a symbol surfaces every caller, importer, and member edge, so "if I change this, what breaks?" is a graph neighbourhood, not a grep; (3) **the picture of the codebase itself** — because the forces come from real `defined_in` / `calls` / `imports` / `member_of` edges, a dense petal around a file means a fat internal API and a thin one means a leaf module. Click any node and the side panel lists the *named* callers, imports, and members instead of a bare count. L6 nodes are the only ones without a fixed radial slot — they orbit their parent file, so the layer collapses cleanly when you filter it out.
296296

mcp_server/core/workflow_graph_builder.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
from collections import Counter, defaultdict
2323
from typing import Iterable
2424

25+
from mcp_server.core.graph_builder_nodes import ENTITY_COLORS
2526
from mcp_server.core.workflow_graph_builder_relational import (
27+
ingest_about_entity,
2628
ingest_ast_edge,
2729
ingest_command_file,
2830
ingest_discussion_agent,
@@ -116,6 +118,8 @@ def build(
116118
discussion_command_events=None,
117119
ast_symbols=None,
118120
ast_edges=None,
121+
entities=None,
122+
memory_entity_edges=None,
119123
):
120124
self._ensure_domain(GLOBAL_DOMAIN_ID, "global")
121125
# Phase 1: node ingestion (self-bound).
@@ -127,6 +131,7 @@ def build(
127131
(command_events, self._ingest_command),
128132
(memories, self._ingest_memory),
129133
(discussions, self._ingest_discussion),
134+
(entities, self._ingest_entity),
130135
):
131136
for ev in events or []:
132137
fn(ev)
@@ -141,6 +146,7 @@ def build(
141146
(discussion_tool_events, ingest_discussion_tool),
142147
(discussion_agent_events, ingest_discussion_agent),
143148
(discussion_command_events, ingest_discussion_command),
149+
(memory_entity_edges, ingest_about_entity),
144150
):
145151
for ev in events or []:
146152
fn(self, ev)
@@ -320,6 +326,30 @@ def _ingest_memory(self, mem):
320326
**science,
321327
)
322328

329+
def _ingest_entity(self, ent):
330+
"""Project a knowledge-graph entity row into the workflow graph.
331+
332+
Each entity becomes a single ENTITY node anchored to its domain
333+
via ``IN_DOMAIN`` (satisfying the "exactly one in_domain edge"
334+
invariant). Size tracks heat; colour comes from the shared
335+
``ENTITY_COLORS`` palette keyed on ``entityType``.
336+
"""
337+
pg_id = _require(ent, "id", "entity")
338+
dom = self._assign_domain(ent.get("domain"))
339+
self._ensure_domain(dom)
340+
ent_type = ent.get("type") or "concept"
341+
heat = float(ent.get("heat") or 0.0)
342+
self._add_child(
343+
NodeIdFactory.entity_id(pg_id),
344+
NodeKind.ENTITY,
345+
ent.get("name") or f"entity {pg_id}",
346+
ENTITY_COLORS.get(ent_type, "#50B0C8"),
347+
dom,
348+
1.0 + min(3.0, heat * 3.0),
349+
entityType=ent_type,
350+
heat=heat,
351+
)
352+
323353
def _ingest_discussion(self, dc):
324354
sid = str(_require(dc, "session_id", "discussion"))
325355
dom = self._assign_domain(dc.get("domain"))

mcp_server/core/workflow_graph_builder_relational.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,3 +386,31 @@ def ingest_ast_edge(b, edge: dict) -> None:
386386
kind=edge_kind,
387387
)
388388
)
389+
390+
391+
# ── Knowledge-graph entity edges ────────────────────────────────────────
392+
393+
394+
def ingest_about_entity(b, link: dict) -> None:
395+
"""Create an ABOUT_ENTITY edge from a MEMORY to an ENTITY node.
396+
397+
``link`` carries ``memory_id`` + ``entity_id`` (both PG primary keys
398+
— the same format as the ``memory_entities`` join table). Silently
399+
skips any link whose endpoints are not already present in the graph
400+
(a memory under ``min_heat`` or an archived entity), matching the
401+
existing relational helpers' "skip-missing-endpoint" contract."""
402+
mem_pg = link.get("memory_id")
403+
ent_pg = link.get("entity_id")
404+
if mem_pg is None or ent_pg is None:
405+
return
406+
mem_id = NodeIdFactory.memory_id(mem_pg)
407+
ent_id = NodeIdFactory.entity_id(ent_pg)
408+
if mem_id not in b._nodes or ent_id not in b._nodes:
409+
return
410+
b._edges.append(
411+
WorkflowEdge(
412+
source=mem_id,
413+
target=ent_id,
414+
kind=EdgeKind.ABOUT_ENTITY,
415+
)
416+
)

mcp_server/core/workflow_graph_schema.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,14 @@ def symbol_id(file_abs_path: str, qualified_name: str) -> str:
153153
key = f"{file_abs_path}::{qualified_name}"
154154
return f"symbol:{_short_hash(key, width=12)}"
155155

156+
@staticmethod
157+
def entity_id(pg_id: str | int) -> str:
158+
"""Deterministic id for a knowledge-graph entity, keyed on the
159+
entities-table primary key. Used by the ``_entity`` loader so
160+
memory→entity ``about_entity`` edges stay stable across runs.
161+
"""
162+
return f"entity:{pg_id}"
163+
156164

157165
# ── Validation (meta-rules that decide well-formedness) ────────────────
158166

mcp_server/core/workflow_graph_schema_enums.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@ class NodeKind(str, Enum):
2020
FILE = "file"
2121
MEMORY = "memory"
2222
DISCUSSION = "discussion"
23-
# ENTITY is reserved for the future knowledge-graph entity projection.
24-
# No ingest path currently produces entity nodes; the JS renderer
25-
# palette includes it so the schema stays forward-compatible.
23+
# ENTITY — projects a knowledge-graph entity (entities table row)
24+
# into the workflow graph. Produced by
25+
# ``workflow_graph_source_pg.load_entities`` and ingested by
26+
# ``WorkflowGraphBuilder._ingest_entity``. Linked to memories via
27+
# the ``about_entity`` edge. Colour comes from the legacy palette
28+
# (ENTITY_COLORS) matched on ``entityType``.
2629
ENTITY = "entity"
2730
MCP = "mcp"
2831
# SYMBOL — function / class / module / import extracted from the
@@ -41,8 +44,10 @@ class EdgeKind(str, Enum):
4144
INVOKED_SKILL = "invoked_skill"
4245
TRIGGERED_HOOK = "triggered_hook"
4346
SPAWNED_AGENT = "spawned_agent"
44-
# ABOUT_ENTITY — paired with NodeKind.ENTITY. Reserved for the
45-
# future knowledge-graph entity projection; no current producer.
47+
# ABOUT_ENTITY — MEMORY → ENTITY link. Produced by
48+
# ``WorkflowGraphBuilder._ingest_memory_entity_edge`` which reads
49+
# the ``memory_entities`` join table. Styled in
50+
# ``ui/unified/workflow_graph.css`` (``.wfg-link--about_entity``).
4651
ABOUT_ENTITY = "about_entity"
4752
DISCUSSION_TOUCHED_FILE = "discussion_touched_file"
4853
DISCUSSION_USED_TOOL = "discussion_used_tool"

mcp_server/handlers/_tool_meta.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Shared tool-metadata helpers for MCP registration.
2+
3+
Glama's tool score is dominated by three fields most of our handlers
4+
were missing:
5+
6+
1. ``title`` — human-readable name shown in tool lists.
7+
2. ``output_schema`` — declared return-shape JSON Schema. Lets callers
8+
validate responses + enables type-aware completion in the client.
9+
3. ``annotations`` — ``readOnlyHint`` / ``destructiveHint`` /
10+
``idempotentHint`` / ``openWorldHint`` (spec: MCP 2024-11-05+).
11+
12+
Every handler schema dict may carry these keys; ``tool_kwargs()`` pulls
13+
whichever are present and returns a kwargs mapping ready to hand to
14+
``mcp.tool(**...)``. The helper tolerates missing keys so the upgrade
15+
is incremental — handlers that have been refreshed light up with the
16+
full metadata; older handlers keep their existing description-only
17+
registration until they're touched.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from typing import Any
23+
24+
# Named annotation presets so every handler converges on the same
25+
# semantics. Presets name the CAPABILITY, not the handler.
26+
27+
# Pure read. Safe to call repeatedly. No state change.
28+
READ_ONLY: dict[str, Any] = {
29+
"readOnlyHint": True,
30+
"destructiveHint": False,
31+
"idempotentHint": True,
32+
"openWorldHint": False,
33+
}
34+
35+
# Reads and produces new state, but running twice has the same effect
36+
# as running once (e.g. storing a memory that dedups / merges).
37+
IDEMPOTENT_WRITE: dict[str, Any] = {
38+
"readOnlyHint": False,
39+
"destructiveHint": False,
40+
"idempotentHint": True,
41+
"openWorldHint": False,
42+
}
43+
44+
# Writes new state on every call; subsequent calls produce new rows.
45+
NON_IDEMPOTENT_WRITE: dict[str, Any] = {
46+
"readOnlyHint": False,
47+
"destructiveHint": False,
48+
"idempotentHint": False,
49+
"openWorldHint": False,
50+
}
51+
52+
# Mutates or removes existing state in a way that can't be undone
53+
# without data loss.
54+
DESTRUCTIVE: dict[str, Any] = {
55+
"readOnlyHint": False,
56+
"destructiveHint": True,
57+
"idempotentHint": True,
58+
"openWorldHint": False,
59+
}
60+
61+
# Read-only but reaches to external state (browser, subprocess,
62+
# filesystem outside our DB).
63+
READ_ONLY_EXTERNAL: dict[str, Any] = {
64+
"readOnlyHint": True,
65+
"destructiveHint": False,
66+
"idempotentHint": True,
67+
"openWorldHint": True,
68+
}
69+
70+
71+
def tool_kwargs(schema: dict[str, Any]) -> dict[str, Any]:
72+
"""Extract mcp.tool(**kwargs) from a handler schema dict.
73+
74+
Returns the keys FastMCP accepts: ``description``, ``title``,
75+
``output_schema``, ``annotations``, ``tags``. Unknown keys are
76+
ignored so handlers can carry arbitrary auxiliary metadata.
77+
"""
78+
out: dict[str, Any] = {}
79+
if "description" in schema:
80+
out["description"] = schema["description"]
81+
if "title" in schema:
82+
out["title"] = schema["title"]
83+
# Support both ``output_schema`` (snake) and ``outputSchema`` (camel).
84+
if "output_schema" in schema:
85+
out["output_schema"] = schema["output_schema"]
86+
elif "outputSchema" in schema:
87+
out["output_schema"] = schema["outputSchema"]
88+
if "annotations" in schema:
89+
out["annotations"] = schema["annotations"]
90+
if "tags" in schema:
91+
out["tags"] = schema["tags"]
92+
return out

mcp_server/handlers/add_rule.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@
2020

2121
from mcp_server.infrastructure.memory_config import get_memory_settings
2222
from mcp_server.infrastructure.memory_store import MemoryStore
23+
from mcp_server.handlers._tool_meta import IDEMPOTENT_WRITE
2324

2425
# ── Schema ────────────────────────────────────────────────────────────────────
2526

2627
schema = {
28+
"title": "Add rule",
29+
"annotations": IDEMPOTENT_WRITE,
2730
"description": (
2831
"Insert a neuro-symbolic rule into memory_rules so the "
2932
"`apply_rules` engine applies it on every subsequent recall — "

mcp_server/handlers/anchor.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@
1616

1717
from mcp_server.infrastructure.memory_config import get_memory_settings
1818
from mcp_server.infrastructure.memory_store import MemoryStore
19+
from mcp_server.handlers._tool_meta import IDEMPOTENT_WRITE
1920

2021
# ── Schema ────────────────────────────────────────────────────────────────────
2122

2223
schema = {
24+
"title": "Anchor memory",
25+
"annotations": IDEMPOTENT_WRITE,
2326
"description": (
2427
"Mark a memory as compaction-resistant by setting heat=1.0, "
2528
"is_protected=true, importance=1.0, and adding an `_anchor` tag — "

mcp_server/handlers/assess_coverage.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,13 @@
1818

1919
from mcp_server.infrastructure.memory_config import get_memory_settings
2020
from mcp_server.infrastructure.memory_store import MemoryStore
21+
from mcp_server.handlers._tool_meta import READ_ONLY
2122

2223
# ── Schema ────────────────────────────────────────────────────────────────────
2324

2425
schema = {
26+
"title": "Assess coverage",
27+
"annotations": READ_ONLY,
2528
"description": (
2629
"Score how well the memory store covers a project across five "
2730
"axes: file coverage (which key files are remembered), domain "

mcp_server/handlers/backfill_memories.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,13 @@
2626
from mcp_server.infrastructure.memory_config import get_memory_settings
2727
from mcp_server.infrastructure.memory_store import MemoryStore
2828
from mcp_server.infrastructure.scanner import read_head_tail
29+
from mcp_server.handlers._tool_meta import NON_IDEMPOTENT_WRITE
2930

3031
# -- Schema --
3132

3233
schema = {
34+
"title": "Backfill memories",
35+
"annotations": NON_IDEMPOTENT_WRITE,
3336
"description": (
3437
"Import prior Claude Code conversations from ~/.claude/projects/ "
3538
"into the memory store. Walks JSONL session transcripts, extracts "

0 commit comments

Comments
 (0)