refactor: extract kb_framework package, build graph in memory

Scripts become thin CLIs over kb_framework (graph, query, query_tools,
vault, instructions) so the hosted read-only connector shares one
implementation with the stdio server. The MCP server builds the graph
from Markdown on demand and invalidates it on writes; refresh_graph
rebuilds in memory and reports validation problems. Pre-commit hook
validates only; index/graph.json is no longer read, written or staged
(generate-graph.py --write remains for operators). Golden fixtures pin
graph and tool payloads to the previous output. Agent and skill docs
updated to match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 20:40:13 +02:00
co-authored by Claude Fable 5.1
parent 4fec637599
commit 96780317a7
37 changed files with 1858 additions and 899 deletions
+52 -347
View File
@@ -4,7 +4,9 @@
This server is the single interface an agent needs to work the vault over stdio
with JSON-RPC 2.0. It provides framework operating instructions (read_instructions),
and exposes graph and note *querying* (graph_summary,
graph_search_nodes, graph_neighbors, read_note, refresh_graph), *writing*
graph_search_nodes, graph_neighbors, read_note — all served by the shared
`kb_framework` package over a graph built in memory from the vault's Markdown
and cached until the next write; refresh_graph rebuilds it), *writing*
(write_note for text, capture_raw for verbatim bytes from a local file or an
http(s) download) and *publishing* (edit_note, git_commit, git_push), so an
agent without shell or filesystem tools can ingest a resource end to end.
@@ -27,21 +29,35 @@ import subprocess
import sys
import urllib.error
import urllib.request
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List
from vault_root import FRAMEWORK_ROOT, parse_vault_args
from vault_root import FRAMEWORK_ROOT, parse_vault_args # noqa: E402
from kb_framework import graph as kb_graph # noqa: E402
from kb_framework import query_tools # noqa: E402
from kb_framework import vault as kb_vault # noqa: E402
SERVER_NAME = "knowledge-base-mcp"
SERVER_VERSION = "0.1.0"
@dataclass
@dataclass
class AppState:
"""The selected vault plus the graph built from it, cached until a write."""
root: Path
graph_path: Path
graph_cache: Dict[str, Any] | None = None
def graph(self) -> Dict[str, Any]:
if self.graph_cache is None:
self.graph_cache = kb_graph.build_graph(self.root).graph
return self.graph_cache
def invalidate(self) -> None:
self.graph_cache = None
class _StdinReader:
@@ -140,7 +156,6 @@ def _write_message_ndjson(stdout: Any, payload: Dict[str, Any]) -> None:
stdout.buffer.flush()
def _response(message_id: Any, result: Dict[str, Any]) -> Dict[str, Any]:
return {"jsonrpc": "2.0", "id": message_id, "result": result}
@@ -153,47 +168,9 @@ def _error(message_id: Any, code: int, message: str) -> Dict[str, Any]:
}
def _load_graph(state: AppState) -> Dict[str, Any]:
if not state.graph_path.exists():
raise FileNotFoundError(
f"graph not found at {state.graph_path}. Run scripts/generate-graph.py first."
)
return json.loads(state.graph_path.read_text(encoding="utf-8"))
def _nodes_by_id(graph: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
nodes = graph.get("nodes", [])
return {str(node.get("id")): node for node in nodes if isinstance(node, dict)}
def _match_filters(node: Dict[str, Any], args: Dict[str, Any]) -> bool:
repo = args.get("repo")
note_type = args.get("type")
status = args.get("status")
tag = args.get("tag")
query = str(args.get("query", "")).strip().lower()
if repo and node.get("repo") != repo:
return False
if note_type and node.get("type") != note_type:
return False
if status and node.get("status") != status:
return False
if tag and tag not in (node.get("tags") or []):
return False
if query:
haystacks = [
str(node.get("id", "")).lower(),
str(node.get("title", "")).lower(),
str(node.get("path", "")).lower(),
" ".join(str(t).lower() for t in (node.get("tags") or [])),
]
if not any(query in h for h in haystacks):
return False
return True
_WILDCARD_PATHS = {".", "./", "-A", "--all", "*", "-a", ":/"}
# Reserved for operator-written graph dumps (generate-graph.py --write); never a note.
_GENERATED_GRAPH_PATH = "index/graph.json"
# Ceiling for a single capture_raw call; papers and slide decks fit, a video
# download that would bloat the vault does not.
@@ -202,31 +179,15 @@ _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:
"""Turn a vault-relative path into an absolute path inside the repo root.
Rejects empty values, absolute paths and anything escaping the root, so a
tool call can never touch a file outside the vault.
"""
value = str(raw_path or "").strip()
if not value:
raise ValueError(f"'{label}' is required")
candidate = Path(value)
if candidate.is_absolute():
raise ValueError(f"'{label}' must be vault-relative, got absolute '{value}'")
root = state.root.resolve()
target = (root / candidate).resolve()
if target != root and not target.is_relative_to(root):
raise ValueError(f"'{label}' resolves outside repository root: '{value}'")
return target
"""Vault-relative path -> absolute path inside the repo root (never outside)."""
return kb_vault.resolve_in_root(state.root, raw_path, label=label)
def _relative_to_root(state: AppState, target: Path) -> str:
return target.relative_to(state.root.resolve()).as_posix()
return kb_vault.relative_to_root(state.root, target)
def _run_git(state: AppState, args: List[str]) -> subprocess.CompletedProcess:
@@ -258,99 +219,15 @@ def _git_result(payload: Dict[str, Any], exit_code: int) -> Dict[str, Any]:
def _tool_definitions() -> List[Dict[str, Any]]:
return [
{
"name": "read_instructions",
"description": (
"Read framework AGENTS.md and AGENT_QUERY_GUIDE.md before working "
"with the vault. These operating documents live outside an "
"external vault and do not require a generated graph."
),
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": False,
},
},
{
"name": "graph_summary",
"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": {
"type": "object",
"properties": {
"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,
},
},
{
"name": "graph_search_nodes",
"description": "Search graph nodes by metadata and free text.",
"inputSchema": {
"type": "object",
"properties": {
"repo": {"type": "string"},
"type": {"type": "string"},
"status": {"type": "string"},
"tag": {"type": "string"},
"query": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 200},
},
},
},
{
"name": "graph_neighbors",
"description": (
"Show inbound and outbound links for a node ID. Each neighbor "
"includes the edge `type` (relationship, e.g. supersedes, "
"depends-on), `anchor_text` (the link's display text), and "
"`annotation` (what to expect in that note)."
),
"inputSchema": {
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 200},
},
},
},
{
"name": "read_note",
"description": (
"Read note content by graph node ID or vault-relative path. "
"Provide exactly one of `id` or `path`. "
"If both or neither are provided, the call returns an error."
),
"inputSchema": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Graph node ID (e.g. 'AGENTS')."},
"path": {"type": "string", "description": "Vault-relative file path (e.g. 'README.md')."},
},
"additionalProperties": False,
},
},
*query_tools.definitions(),
{
"name": "refresh_graph",
"description": "Regenerate graph.json using scripts/generate-graph.py.",
"description": (
"Rebuild the in-memory graph from the vault's Markdown. With "
"validate=true also run the commit gate (unresolved links, "
"unknown edge/note types, legacy wiki links) and return an error "
"result listing the problems."
),
"inputSchema": {
"type": "object",
"properties": {
@@ -496,187 +373,20 @@ def _text_result(text: str, structured: Dict[str, Any] | None = None) -> Dict[st
return result
def _tool_read_instructions() -> Dict[str, Any]:
documents = [
{"path": name, "content": (FRAMEWORK_ROOT / name).read_text(encoding="utf-8")}
for name in ("AGENTS.md", "AGENT_QUERY_GUIDE.md")
]
payload = {"framework_root": str(FRAMEWORK_ROOT), "documents": documents}
return _text_result(json.dumps(payload, indent=2), structured=payload)
def _tool_graph_summary(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
graph = _load_graph(state)
nodes = graph.get("nodes", [])
edges = graph.get("edges", [])
top_n = int(args.get("topLinked", _DEFAULT_TOP_LINKED))
top_tags = int(args.get("topTags", _DEFAULT_TOP_TAGS))
by_repo: Counter[str] = Counter(str(node.get("repo", "unknown")) for node in nodes)
tag_counts: Counter[str] = Counter(
str(tag) for node in nodes for tag in (node.get("tags") or [])
)
# 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(
nodes,
key=lambda n: int(n.get("inbound_count", 0)),
reverse=True,
)[:top_n]
payload = {
"version": graph.get("version"),
"generated_at": graph.get("generated_at"),
"node_count": len(nodes),
"edge_count": len(edges),
"by_repo": dict(by_repo),
"tag_count": len(tag_counts),
"by_tag": [
{"tag": tag, "count": count} for tag, count in ranked_tags[:top_tags]
],
"top_linked": [
{
"id": node.get("id"),
"title": node.get("title"),
"path": node.get("path"),
"inbound_count": node.get("inbound_count", 0),
}
for node in sorted_nodes
],
}
return _text_result(json.dumps(payload, indent=2), structured=payload)
def _tool_graph_search_nodes(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
graph = _load_graph(state)
nodes = graph.get("nodes", [])
limit = int(args.get("limit", 30))
filtered = [node for node in nodes if _match_filters(node, args)]
filtered.sort(key=lambda n: str(n.get("id", "")))
sliced = filtered[:limit]
payload = {
"total_matches": len(filtered),
"returned": len(sliced),
"items": sliced,
}
return _text_result(json.dumps(payload, indent=2), structured=payload)
def _tool_graph_neighbors(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
graph = _load_graph(state)
node_id = str(args["id"])
limit = int(args.get("limit", 50))
nodes = _nodes_by_id(graph)
if node_id not in nodes:
raise ValueError(f"node '{node_id}' not found")
edges = graph.get("edges", [])
outbound_edges = [e for e in edges if e.get("from") == node_id]
inbound_edges = [e for e in edges if e.get("to") == node_id]
def describe(edge_list: List[Dict[str, Any]], direction: str) -> List[Dict[str, Any]]:
# neighbor id is the "other end" relative to node_id
other_key = "to" if direction == "outbound" else "from"
result: List[Dict[str, Any]] = []
for edge in edge_list:
item_id = str(edge.get(other_key))
node = nodes.get(item_id)
entry = {
"id": node.get("id") if node else item_id,
"title": node.get("title") if node else "<missing>",
"path": node.get("path") if node else "",
"direction": direction,
"type": edge.get("type", "related"),
"anchor_text": edge.get("anchor_text", ""),
"annotation": edge.get("annotation", ""),
}
result.append(entry)
return result[:limit]
payload = {
"node": nodes[node_id],
"inbound_count": len(inbound_edges),
"outbound_count": len(outbound_edges),
"inbound": describe(inbound_edges, "inbound"),
"outbound": describe(outbound_edges, "outbound"),
}
return _text_result(json.dumps(payload, indent=2), structured=payload)
def _tool_read_note(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
has_id = bool(args.get("id"))
has_path = bool(args.get("path"))
if has_id and has_path:
raise ValueError("provide exactly one of 'id' or 'path', not both")
if not has_id and not has_path:
raise ValueError("exactly one of 'id' or 'path' is required")
path_value = args.get("path")
if has_id:
nodes = _nodes_by_id(_load_graph(state))
node = nodes.get(str(args["id"]))
if not node:
raise ValueError(f"node '{args['id']}' not found")
path_value = node.get("path")
target = (state.root / str(path_value)).resolve()
if not target.is_relative_to(state.root.resolve()):
raise ValueError("note path resolves outside repository root")
if not target.exists():
raise FileNotFoundError(f"note not found at {target}")
relative = str(target.relative_to(state.root).as_posix())
data = target.read_bytes()
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
payload = {
"path": relative,
"binary": True,
"bytes": len(data),
"sha256": hashlib.sha256(data).hexdigest(),
"hint": (
f"{target} is not UTF-8 text (e.g. a PDF or image captured under "
"raw/). Open it with a file-reading tool that handles binaries; "
"this server returns text only."
),
}
return _text_result(json.dumps(payload, indent=2), structured=payload)
payload = {
"path": relative,
"binary": False,
"content": text,
}
return _text_result(json.dumps(payload, indent=2), structured=payload)
def _tool_refresh_graph(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
command = [
sys.executable, str(FRAMEWORK_ROOT / "scripts/generate-graph.py"),
"--vault-root", str(state.root), "--write",
]
if bool(args.get("validate", False)):
command.append("--validate")
proc = subprocess.run(
command,
cwd=state.root,
check=False,
capture_output=True,
text=True,
)
"""Rebuild the in-memory graph; with validate=true also apply the commit gate."""
build = kb_graph.build_graph(state.root)
state.graph_cache = build.graph
problems = kb_graph.validation_problems(build) if bool(args.get("validate", False)) else []
payload = {
"command": command,
"exit_code": proc.returncode,
"stdout": proc.stdout,
"stderr": proc.stderr,
"vault_root": str(state.root),
"generated_at": build.graph.get("generated_at"),
"node_count": len(build.graph.get("nodes", [])),
"edge_count": len(build.graph.get("edges", [])),
"validated": bool(args.get("validate", False)),
"problems": problems,
}
if proc.returncode != 0:
if problems:
return {
"isError": True,
"content": [{"type": "text", "text": json.dumps(payload, indent=2)}],
@@ -691,7 +401,7 @@ def _tool_write_note(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
content = str(args["content"])
target = _resolve_in_root(state, args.get("path"))
relative = _relative_to_root(state, target)
if relative == state.graph_path.relative_to(state.root.resolve()).as_posix():
if relative == _GENERATED_GRAPH_PATH:
raise ValueError(
"index/graph.json is generated \u2014 use refresh_graph instead of writing it"
)
@@ -706,6 +416,7 @@ def _tool_write_note(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
state.invalidate()
payload = {
"path": relative,
"created": not existed,
@@ -742,6 +453,7 @@ def _tool_edit_note(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
updated = text.replace(old_string, new_string)
target.write_text(updated, encoding="utf-8")
state.invalidate()
payload = {
"path": relative,
"replacements": occurrences if replace_all else 1,
@@ -818,6 +530,7 @@ def _tool_capture_raw(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
source_label = url
state.invalidate()
payload = {
"path": relative,
@@ -918,16 +631,8 @@ def _tool_git_push(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
def _call_tool(state: AppState, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
if name == "read_instructions":
return _tool_read_instructions()
if name == "graph_summary":
return _tool_graph_summary(state, arguments)
if name == "graph_search_nodes":
return _tool_graph_search_nodes(state, arguments)
if name == "graph_neighbors":
return _tool_graph_neighbors(state, arguments)
if name == "read_note":
return _tool_read_note(state, arguments)
if name in query_tools.READ_ONLY_TOOL_NAMES:
return query_tools.call(name, arguments, graph=state.graph(), root=state.root)
if name == "refresh_graph":
return _tool_refresh_graph(state, arguments)
if name == "write_note":
@@ -949,7 +654,7 @@ def _list_resources(state: AppState) -> Dict[str, Any]:
{
"uri": "repo://graph",
"name": "Repository Graph",
"description": "Raw index/graph.json",
"description": "The graph built in memory from the vault's Markdown",
"mimeType": "application/json",
},
{
@@ -970,7 +675,7 @@ def _list_resources(state: AppState) -> Dict[str, Any]:
def _read_resource(state: AppState, uri: str) -> Dict[str, Any]:
if uri == "repo://graph":
text = state.graph_path.read_text(encoding="utf-8")
text = json.dumps(state.graph(), indent=2)
return {
"contents": [
{
@@ -1038,7 +743,7 @@ def _handle_request(state: AppState, message: Dict[str, Any]) -> Dict[str, Any]
def main() -> None:
args = parse_vault_args(argparse.ArgumentParser(description=__doc__))
root = args.vault_root
state = AppState(root=root, graph_path=root / "index" / "graph.json")
state = AppState(root=root)
reader = _StdinReader(sys.stdin.buffer)
try: