feat: mirror tag-vocabulary summary and note-type validation

Ports two changes from the knowledge-base vault this skeleton is extracted
from, keeping the shared scripts byte-identical across the pair:

- `graph_summary` reports `by_tag` and `tag_count`, sized by `topTags`, and
  drops the `by_type`/`by_status` breakdowns. An MCP-only agent could not
  otherwise discover the tag spellings that `graph_search_nodes` matches
  verbatim.
- `generate-graph.py --validate` now enforces a controlled vocabulary for a
  note's `type`, which is where the drift signal `by_type` used to give now
  lives. `AGENT_QUERY_GUIDE.md`, both skills and the-knowing-one quote the
  enforced enum.

`tests/test_generate_graph.py` is new; `make test` is 50 tests. Regenerating
the graph also drops a stale `GLOBAL_AGENTS` node for a file this skeleton
does not carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Manuel Ganter
2026-09-14 11:37:02 +02:00
co-authored by Claude Opus 5
parent 638cbe6b39
commit 1cb6369282
10 changed files with 316 additions and 52 deletions
+9 -3
View File
@@ -23,7 +23,9 @@ Schema:
- `title` (string): note title - `title` (string): note title
- `path` (string): vault-relative markdown path - `path` (string): vault-relative markdown path
- `repo` (string): repo scope (`tenant-controller`, `controller-util`, `global`, ...) - `repo` (string): repo scope (`tenant-controller`, `controller-util`, `global`, ...)
- `type` (string): note classification (`guideline`, `policy`, `project`, etc.) - `type` (string): note classification from a controlled vocabulary —
`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`;
absent frontmatter means `note`, and `--validate` fails on anything else
- `status` (string): lifecycle state (`active`, `draft`, `deprecated`) - `status` (string): lifecycle state (`active`, `draft`, `deprecated`)
- `tags` (string array): topic tags - `tags` (string array): topic tags
- `last_reviewed` (string): `YYYY-MM-DD` - `last_reviewed` (string): `YYYY-MM-DD`
@@ -64,6 +66,10 @@ Examples with `jq`:
# list repos # list repos
jq -r '.nodes[].repo' index/graph.json | sort -u jq -r '.nodes[].repo' index/graph.json | sort -u
# list the tag vocabulary, most frequent first (tags are matched verbatim)
jq -r '[.nodes[].tags[]?] | group_by(.) | map({tag: .[0], count: length})
| sort_by(-.count)[] | "\(.count)\t\(.tag)"' index/graph.json
# list top linked notes # list top linked notes
jq -r '.nodes | sort_by(-(.inbound_count // 0))[] | "\(.inbound_count // 0)\t\(.id)\t\(.path)"' index/graph.json jq -r '.nodes | sort_by(-(.inbound_count // 0))[] | "\(.inbound_count // 0)\t\(.id)\t\(.path)"' index/graph.json
@@ -96,8 +102,8 @@ For `jg`, use the same filters if your local `jg` is jq-compatible.
Validation: Validation:
```bash ```bash
# validate: non-zero exit on unresolved links, edge types outside the # validate: non-zero exit on unresolved links, edge types or note types
# vocabulary, OR any remaining legacy [[wiki links]] # outside their vocabularies, OR any remaining legacy [[wiki links]]
python3 scripts/generate-graph.py --validate python3 scripts/generate-graph.py --validate
# regenerate graph and validate # regenerate graph and validate
+11 -6
View File
@@ -26,7 +26,8 @@ through MCP.
## MCP tools ## MCP tools
- `graph_summary` — overview of the graph: counts, types, tags, central nodes. - `graph_summary` — overview of the graph: counts, repo scopes, the tag
vocabulary (`by_tag`, most frequent first) and central nodes.
- `graph_search_nodes` — find nodes by tag / type / repo / id / text. - `graph_search_nodes` — find nodes by tag / type / repo / id / text.
- `graph_neighbors` — outbound + inbound links for a node id, each with its edge - `graph_neighbors` — outbound + inbound links for a node id, each with its edge
`type` + `annotation` (traverse the graph). `type` + `annotation` (traverse the graph).
@@ -56,15 +57,18 @@ README.md vault hub
``` ```
Every note carries taxonomy frontmatter: `id`, `title`, `type` Every note carries taxonomy frontmatter: `id`, `title`, `type`
(`hub|project|guideline|policy|feature|roadmap|decision|note|meta`), `status` (`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`, enforced by
`generate-graph.py --validate`), `status`
(`active|draft|deprecated`), `tags`, `last_reviewed`, optional `repo`. Notes link (`active|draft|deprecated`), `tags`, `last_reviewed`, optional `repo`. Notes link
with typed markdown links — `[text](note-id){"type":"…"}` — resolved by `id`. with typed markdown links — `[text](note-id){"type":"…"}` — resolved by `id`.
Legacy `[[wiki links]]` are rejected by the graph validator and must not be written. Legacy `[[wiki links]]` are rejected by the graph validator and must not be written.
## 1. Orient ## 1. Orient
Start broad with `graph_summary` to see node counts, types, tags, and the Start broad with `graph_summary` to see node counts, repo scopes, the tag
most-central notes. Then `read_note` the entry points: vocabulary and the most-central notes. Its `by_tag` list is the only place the
tag spellings are visible — read it before filtering by `tag`, which matches
verbatim. Then `read_note` the entry points:
- vault hub (`README.md`) — entry points. - vault hub (`README.md`) — entry points.
- `03_resources/repos/repo-landscape.md` — index of documented repos, grouped. - `03_resources/repos/repo-landscape.md` — index of documented repos, grouped.
@@ -80,8 +84,9 @@ Use `graph_search_nodes` to locate notes:
- by **repo** — every note tied to a repo (e.g. `tenant-controller`). - by **repo** — every note tied to a repo (e.g. `tenant-controller`).
- by **type** — `policy`, `decision`, `feature`, `guideline`, `project`, ... - by **type** — `policy`, `decision`, `feature`, `guideline`, `project`, ...
- by **tag** — e.g. `kcp`. - by **tag** — e.g. `kcp`. Exact match; take the spelling from `by_tag`.
- by **text** — when tag/type/id unknown, full-text search the notes. - by **text** — `query` matches a node's id, title, path and tags, not the note
body. Widen with a tag or repo filter rather than a longer phrase.
Use `graph_summary` to spot central (most-linked) notes worth reading early. Use `graph_summary` to spot central (most-linked) notes worth reading early.
+1 -1
View File
@@ -25,7 +25,7 @@ already captured into `raw/` (a PDF, an image) at the absolute path
## MCP tools ## MCP tools
Read: Read:
- `graph_summary` — counts, types, tags, most-linked notes. - `graph_summary` — counts, repo scopes, the tag vocabulary (`by_tag`), most-linked notes.
- `graph_search_nodes` — find notes by tag / type / repo / id / free text. - `graph_search_nodes` — find notes by tag / type / repo / id / free text.
- `graph_neighbors` — inbound + outbound links of a node, with edge `type` and `annotation`. - `graph_neighbors` — inbound + outbound links of a node, with edge `type` and `annotation`.
- `read_note` — the Markdown of a note (by `id` or vault-relative `path`). On a - `read_note` — the Markdown of a note (by `id` or vault-relative `path`). On a
+1 -13
View File
@@ -1,6 +1,6 @@
{ {
"version": 1, "version": 1,
"generated_at": "2026-09-11T07:34:35.807809+00:00", "generated_at": "2026-09-14T09:33:34.687166+00:00",
"nodes": [ "nodes": [
{ {
"id": ".claude/agents/the-knowing-one", "id": ".claude/agents/the-knowing-one",
@@ -50,18 +50,6 @@
"outbound_links": [], "outbound_links": [],
"inbound_count": 0 "inbound_count": 0
}, },
{
"id": "GLOBAL_AGENTS",
"title": "GLOBAL_AGENTS.md",
"path": "GLOBAL_AGENTS.md",
"repo": "global",
"type": "note",
"status": "active",
"tags": [],
"last_reviewed": "",
"outbound_links": [],
"inbound_count": 0
},
{ {
"id": "agents/README", "id": "agents/README",
"title": "Agents", "title": "Agents",
+51 -2
View File
@@ -1,4 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Build `index/graph.json` from the vault's Markdown, and validate it.
The graph is the derived navigation layer agents query instead of walking the
tree: one node per note (frontmatter plus link counts) and one edge per typed
markdown link. `--validate` is the gate that keeps it trustworthy — it fails on
links that resolve to nothing, on edge relationship types or note `type` values
outside their controlled vocabularies, and on legacy `[[wiki links]]`.
"""
import argparse import argparse
import json import json
import re import re
@@ -36,6 +45,24 @@ RELATION_TYPES = {
} }
# Strict controlled vocabulary for a note's frontmatter `type`. A note without
# one is a "note"; --validate fails on any value outside this set, which is what
# keeps the facet meaningful as the vault grows.
NOTE_TYPES = {
"hub",
"project",
"area",
"guideline",
"policy",
"feature",
"roadmap",
"decision",
"note",
"meta",
"reference",
}
@dataclass @dataclass
class LinkRef: class LinkRef:
target: str # raw target text (resolution key: id / rel-path / title / stem, or a URL) target: str # raw target text (resolution key: id / rel-path / title / stem, or a URL)
@@ -402,7 +429,8 @@ def parse_args() -> argparse.Namespace:
parser.add_argument( parser.add_argument(
"--validate", "--validate",
action="store_true", action="store_true",
help="validate links: unresolved targets, edge relationship types, " help="validate the graph: unresolved targets, edge relationship types, "
"note types, "
"and legacy [[wiki links]] (all rejected)", "and legacy [[wiki links]] (all rejected)",
) )
parser.add_argument( parser.add_argument(
@@ -453,6 +481,25 @@ def print_invalid_types(invalid: List[Dict[str, object]]) -> None:
print(f"- {e.get('from')} -> {e.get('to')}: type={e.get('type')!r}") print(f"- {e.get('from')} -> {e.get('to')}: type={e.get('type')!r}")
def find_invalid_node_types(graph: Dict[str, object]) -> List[Dict[str, object]]:
return [
n
for n in graph.get("nodes", []) # type: ignore[union-attr]
if n.get("type") not in NOTE_TYPES
]
def print_invalid_node_types(invalid: List[Dict[str, object]]) -> None:
if not invalid:
print("Validation passed: all node types are in the controlled vocabulary")
return
print(f"Validation failed: {len(invalid)} note(s) with unknown type")
print(f" allowed: {', '.join(sorted(NOTE_TYPES))}")
for n in invalid:
print(f"- {n.get('id')} ({n.get('path')}): type={n.get('type')!r}")
def main() -> None: def main() -> None:
args = parse_args() args = parse_args()
root = Path(__file__).resolve().parents[1] root = Path(__file__).resolve().parents[1]
@@ -490,8 +537,10 @@ def main() -> None:
print_unresolved(unresolved) print_unresolved(unresolved)
invalid = find_invalid_types(graph) invalid = find_invalid_types(graph)
print_invalid_types(invalid) print_invalid_types(invalid)
invalid_nodes = find_invalid_node_types(graph)
print_invalid_node_types(invalid_nodes)
print_wiki_links(wiki_uses) print_wiki_links(wiki_uses)
if unresolved or invalid or wiki_uses: if unresolved or invalid or invalid_nodes or wiki_uses:
sys.exit(1) sys.exit(1)
+40 -18
View File
@@ -25,6 +25,7 @@ import subprocess
import sys import sys
import urllib.error import urllib.error
import urllib.request import urllib.request
from collections import Counter
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List from typing import Any, Dict, List
@@ -196,6 +197,11 @@ _WILDCARD_PATHS = {".", "./", "-A", "--all", "*", "-a", ":/"}
_DEFAULT_MAX_CAPTURE_BYTES = 100 * 1024 * 1024 _DEFAULT_MAX_CAPTURE_BYTES = 100 * 1024 * 1024
_DOWNLOAD_TIMEOUT_SECONDS = 60 _DOWNLOAD_TIMEOUT_SECONDS = 60
# graph_summary defaults: enough most-linked notes to spot the hubs, and enough
# tags to cover the vocabulary an agent actually filters on.
_DEFAULT_TOP_LINKED = 10
_DEFAULT_TOP_TAGS = 50
def _resolve_in_root(state: AppState, raw_path: Any, *, label: str = "path") -> Path: def _resolve_in_root(state: AppState, raw_path: Any, *, label: str = "path") -> Path:
"""Turn a vault-relative path into an absolute path inside the repo root. """Turn a vault-relative path into an absolute path inside the repo root.
@@ -245,12 +251,31 @@ def _tool_definitions() -> List[Dict[str, Any]]:
return [ return [
{ {
"name": "graph_summary", "name": "graph_summary",
"description": "Return aggregate counts from index/graph.json.", "description": (
"Orientation for a cold start: node and edge counts, the repo "
"scopes, the most-linked notes, and the tag vocabulary as "
"`by_tag` (most frequent first) plus `tag_count`, the number of "
"distinct tags. Read `by_tag` before filtering "
"`graph_search_nodes` by `tag` \u2014 that filter matches a tag "
"verbatim, so a guessed spelling returns nothing."
),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
"topLinked": {"type": "integer", "minimum": 1, "maximum": 50} "topLinked": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"description": "Most-linked notes to return (default 10).",
}, },
"topTags": {
"type": "integer",
"minimum": 1,
"maximum": 500,
"description": "Tags to return, most frequent first (default 50).",
},
},
"additionalProperties": False,
}, },
}, },
{ {
@@ -453,21 +478,16 @@ def _tool_graph_summary(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]
graph = _load_graph(state) graph = _load_graph(state)
nodes = graph.get("nodes", []) nodes = graph.get("nodes", [])
edges = graph.get("edges", []) edges = graph.get("edges", [])
top_n = int(args.get("topLinked", 10)) top_n = int(args.get("topLinked", _DEFAULT_TOP_LINKED))
top_tags = int(args.get("topTags", _DEFAULT_TOP_TAGS))
by_repo: Dict[str, int] = {} by_repo: Counter[str] = Counter(str(node.get("repo", "unknown")) for node in nodes)
by_type: Dict[str, int] = {} tag_counts: Counter[str] = Counter(
by_status: Dict[str, int] = {} str(tag) for node in nodes for tag in (node.get("tags") or [])
for node in nodes:
by_repo[str(node.get("repo", "unknown"))] = (
by_repo.get(str(node.get("repo", "unknown")), 0) + 1
)
by_type[str(node.get("type", "unknown"))] = (
by_type.get(str(node.get("type", "unknown")), 0) + 1
)
by_status[str(node.get("status", "unknown"))] = (
by_status.get(str(node.get("status", "unknown")), 0) + 1
) )
# Frequent tags first, alphabetical within a tie, so a truncated list is
# both the most useful slice and stable across calls.
ranked_tags = sorted(tag_counts.items(), key=lambda item: (-item[1], item[0]))
sorted_nodes = sorted( sorted_nodes = sorted(
nodes, nodes,
@@ -479,9 +499,11 @@ def _tool_graph_summary(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]
"generated_at": graph.get("generated_at"), "generated_at": graph.get("generated_at"),
"node_count": len(nodes), "node_count": len(nodes),
"edge_count": len(edges), "edge_count": len(edges),
"by_repo": by_repo, "by_repo": dict(by_repo),
"by_type": by_type, "tag_count": len(tag_counts),
"by_status": by_status, "by_tag": [
{"tag": tag, "count": count} for tag, count in ranked_tags[:top_tags]
],
"top_linked": [ "top_linked": [
{ {
"id": node.get("id"), "id": node.get("id"),
@@ -229,7 +229,8 @@ last_reviewed: <today, YYYY-MM-DD>
- `ingested` and `last_reviewed` = today (the `currentDate` from session context). - `ingested` and `last_reviewed` = today (the `currentDate` from session context).
- `type` must stay within the vault taxonomy enum - `type` must stay within the vault taxonomy enum
(`hub|project|guideline|policy|feature|roadmap|decision|note|meta`) — use `note`; (`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`),
which `generate-graph.py --validate` enforces — use `note`;
the `resource` tag is what marks it. the `resource` tag is what marks it.
- Markdown link targets resolve by `id`, rel-path, title, or unique stem — always - Markdown link targets resolve by `id`, rel-path, title, or unique stem — always
use the `id` as the target, e.g. `[Human title](resource/foo/bar)`. use the `id` as the target, e.g. `[Human title](resource/foo/bar)`.
+2 -1
View File
@@ -29,7 +29,8 @@ README.md vault hub; AGENT_QUERY_GUIDE.md graph query refere
``` ```
Every note carries taxonomy frontmatter: `id`, `title`, `type` Every note carries taxonomy frontmatter: `id`, `title`, `type`
(`hub|project|guideline|policy|feature|roadmap|decision|note|meta`), `status` (`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`, enforced by
`generate-graph.py --validate`), `status`
(`active|draft|deprecated`), `tags`, `last_reviewed`, optional `repo`. Notes are (`active|draft|deprecated`), `tags`, `last_reviewed`, optional `repo`. Notes are
linked with typed markdown links — `[text](note-id){"type":"…"}` — that resolve linked with typed markdown links — `[text](note-id){"type":"…"}` — that resolve
by `id`. Legacy `[[wiki links]]` are rejected by `make validate-graph`. by `id`. Legacy `[[wiki links]]` are rejected by `make validate-graph`.
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Tests for the graph generator's validation gate.
`--validate` is the only thing standing between a typo in a note's frontmatter
and a vault whose facets quietly stop meaning anything, so these tests pin the
controlled vocabularies it enforces: the edge relationship types and the node
`type` enum. Each filesystem test builds a throwaway vault, never the real one.
"""
from __future__ import annotations
import importlib
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
gen = importlib.import_module("generate-graph")
def _node(node_id: str, note_type: str) -> dict:
return {"id": node_id, "path": f"{node_id}.md", "type": note_type}
class TestNoteTypeVocabulary(unittest.TestCase):
"""The node `type` enum is the drift signal; it has to be enforced."""
def test_covers_every_type_the_taxonomy_documents(self) -> None:
self.assertLessEqual(
{
"hub",
"project",
"area",
"guideline",
"policy",
"feature",
"roadmap",
"decision",
"note",
"meta",
"reference",
},
gen.NOTE_TYPES,
)
def test_accepts_a_graph_whose_nodes_are_all_in_the_vocabulary(self) -> None:
graph = {"nodes": [_node("a", "note"), _node("b", "area")], "edges": []}
self.assertEqual(gen.find_invalid_node_types(graph), [])
def test_reports_a_node_whose_type_is_outside_the_vocabulary(self) -> None:
graph = {"nodes": [_node("a", "note"), _node("b", "refrence")], "edges": []}
invalid = gen.find_invalid_node_types(graph)
self.assertEqual([n["id"] for n in invalid], ["b"])
class TestValidateExitCode(unittest.TestCase):
"""--validate must fail the run, not just print, on an unknown node type."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.root = Path(self._tmp.name).resolve() / "vault"
(self.root / "scripts").mkdir(parents=True)
(self.root / "03_resources").mkdir()
(self.root / "scripts" / "generate-graph.py").write_text(
(SCRIPTS / "generate-graph.py").read_text(encoding="utf-8"),
encoding="utf-8",
)
def tearDown(self) -> None:
self._tmp.cleanup()
def write_note(self, name: str, note_type: str) -> None:
(self.root / "03_resources" / name).write_text(
f"---\nid: {name[:-3]}\ntitle: {name}\ntype: {note_type}\n---\n\n# {name}\n",
encoding="utf-8",
)
def validate(self) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "scripts/generate-graph.py", "--validate"],
cwd=self.root,
capture_output=True,
text=True,
)
def test_passes_on_a_vault_of_known_types(self) -> None:
self.write_note("good.md", "decision")
proc = self.validate()
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn("all node types are in the controlled vocabulary", proc.stdout)
def test_fails_and_names_the_offending_note(self) -> None:
self.write_note("good.md", "decision")
self.write_note("bad.md", "descision")
proc = self.validate()
self.assertEqual(proc.returncode, 1)
self.assertIn("descision", proc.stdout)
self.assertIn("bad", proc.stdout)
if __name__ == "__main__":
unittest.main()
+92 -6
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Tests for the knowledge-base MCP server's write and git tools. """Tests for the knowledge-base MCP server's query, write and git tools.
Covers the tools an ingesting agent needs when the MCP server is its only way Covers the tools an agent needs when the MCP server is its only way into the
into the vault: creating notes, editing notes, capturing raw bytes (local files vault: orienting with graph_summary, creating notes, editing notes, capturing
and downloads), committing staged paths, and pushing. Each test drives the raw bytes (local files and downloads), committing staged paths, and pushing.
server through `_call_tool` against a throwaway repository so no test touches Each test drives the server through `_call_tool` against a throwaway repository
the real vault. so no test touches the real vault.
""" """
from __future__ import annotations from __future__ import annotations
@@ -430,3 +430,89 @@ class TestReadNoteBinary(ToolTestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
class TestGraphSummary(ToolTestCase):
"""graph_summary is the agent's vocabulary lookup, not a stats dump.
Tags are the vault's only high-cardinality facet and the `tag` filter of
graph_search_nodes matches them verbatim, so the summary has to name them.
"""
def write_graph(self, nodes: list) -> None:
self.state.graph_path.parent.mkdir(parents=True, exist_ok=True)
self.state.graph_path.write_text(
json.dumps(
{
"version": 1,
"generated_at": "2026-09-14T00:00:00Z",
"nodes": nodes,
"edges": [],
}
),
encoding="utf-8",
)
def node(self, node_id: str, **fields) -> dict:
base = {
"id": node_id,
"title": node_id,
"path": f"03_resources/{node_id}.md",
"repo": "global",
"type": "note",
"status": "active",
"tags": [],
"inbound_count": 0,
}
base.update(fields)
return base
def test_counts_tags_and_orders_them_by_frequency(self) -> None:
self.write_graph(
[
self.node("a", tags=["kcp", "flux"]),
self.node("b", tags=["kcp"]),
self.node("c", tags=["kcp", "helm"]),
]
)
payload = self.structured("graph_summary")
self.assertEqual(
payload["by_tag"],
[
{"tag": "kcp", "count": 3},
{"tag": "flux", "count": 1},
{"tag": "helm", "count": 1},
],
)
def test_reports_the_distinct_tag_total_even_when_truncated(self) -> None:
self.write_graph(
[self.node(f"n{i}", tags=[f"tag-{i}"]) for i in range(10)]
)
payload = self.structured("graph_summary", topTags=3)
self.assertEqual(len(payload["by_tag"]), 3)
self.assertEqual(payload["tag_count"], 10)
def test_drops_the_type_and_status_facets(self) -> None:
self.write_graph([self.node("a"), self.node("b", status="deprecated")])
payload = self.structured("graph_summary")
self.assertNotIn("by_type", payload)
self.assertNotIn("by_status", payload)
def test_keeps_repo_counts_and_most_linked_notes(self) -> None:
self.write_graph(
[
self.node("hub", repo="global", inbound_count=9),
self.node("leaf", repo="kunnus", inbound_count=1),
]
)
payload = self.structured("graph_summary", topLinked=1)
self.assertEqual(payload["by_repo"], {"global": 1, "kunnus": 1})
self.assertEqual([n["id"] for n in payload["top_linked"]], ["hub"])
self.assertEqual(payload["node_count"], 2)
def test_advertises_the_top_tags_argument(self) -> None:
tool = next(
t for t in mcp._tool_definitions() if t["name"] == "graph_summary"
)
self.assertIn("topTags", tool["inputSchema"]["properties"])