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
+10
View File
@@ -0,0 +1,10 @@
"""kb_framework: the importable core of the knowledge-base framework.
Exists so the stdio MCP server (`scripts/mcp-server.py`) and the hosted
read-only connector share one implementation of graph building, graph
queries, note access policy and framework instructions. The graph is built
in memory from the vault's Markdown; nothing here writes to disk.
"""
__all__ = ["graph", "query", "query_tools", "vault", "instructions"]
__version__ = "0.2.0"
+481
View File
@@ -0,0 +1,481 @@
"""Build and validate the vault graph in memory.
One node per Markdown note (frontmatter plus link counts), one edge per typed
markdown link. `build_graph` walks a vault root and returns a `GraphBuild`; it
never touches `index/graph.json`. `validation_problems` is the gate the
pre-commit hook and `--validate` apply: unresolved targets, edge relationship
types or note `type` values outside their controlled vocabularies, and legacy
`[[wiki links]]` are all rejected.
"""
from __future__ import annotations
import json
import re
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Tuple
# Obsidian-style wiki link: [[target#anchor|alias]]
WIKI_LINK_RE = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]")
# Standard markdown link: [display](target); (?<!!) excludes ![img](...)
MD_LINK_RE = re.compile(r"(?<!!)\[([^\]]*)\]\(([^)]+)\)")
# Trailing "— gloss" (em-dash / en-dash / hyphen) used as an annotation fallback
GLOSS_RE = re.compile(r"^\s*[—–-]\s+(\S.*)$")
FENCED_CODE_RE = re.compile(r"```.*?```", re.DOTALL)
INLINE_CODE_RE = re.compile(r"`[^`\n]+`")
# Markdown targets that are external (not vault edges, not unresolved-link errors)
EXTERNAL_TARGET_RE = re.compile(r"^(?:[a-z][a-z0-9+.\-]*://|mailto:|tel:|#)", re.IGNORECASE)
# Strict controlled vocabulary for edge relationship types. A link with no brace
# (or no "type") defaults to "related"; --validate fails on any type not in this set.
RELATION_TYPES = {
"related",
"supersedes",
"superseded-by",
"depends-on",
"part-of",
"parent",
"derived-from",
"see-also",
"source",
}
# 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
class LinkRef:
target: str # raw target text (resolution key: id / rel-path / title / stem, or a URL)
anchor: str # display / alias text -> edge anchor_text
kind: str # "wiki_link" | "markdown_link"
rel_type: str # semantic relationship type (default "related")
annotation: str # per-edge "what to expect there" note
lineno: int # 1-based line number in the note body
raw: str # the raw link text as written (for the inventory script)
is_external: bool # markdown link whose target is a URL/anchor -> no edge
@dataclass
class Note:
path: Path
rel_path: str
stem: str
meta: Dict[str, object]
title: str
note_id: str
links: List[LinkRef]
@dataclass(frozen=True)
class UnresolvedLink:
from_id: str
from_path: str
target: str
@dataclass(frozen=True)
class WikiLinkUse:
from_id: str
from_path: str
target: str
lineno: int
raw: str
def parse_frontmatter(text: str) -> Tuple[Dict[str, object], str]:
if not text.startswith("---\n"):
return {}, text
end = text.find("\n---\n", 4)
if end == -1:
return {}, text
fm_block = text[4:end]
body = text[end + 5 :]
meta: Dict[str, object] = {}
for raw in fm_block.splitlines():
line = raw.strip()
if not line or line.startswith("#") or ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if value.startswith("[") and value.endswith("]"):
inner = value[1:-1].strip()
if inner:
meta[key] = [v.strip().strip('"').strip("'") for v in inner.split(",")]
else:
meta[key] = []
else:
meta[key] = value.strip('"').strip("'")
return meta, body
def derive_title(meta: Dict[str, object], body: str, stem: str) -> str:
if isinstance(meta.get("title"), str) and meta["title"].strip():
return str(meta["title"]).strip()
for line in body.splitlines():
if line.startswith("# "):
return line[2:].strip()
return stem.replace("-", " ").strip().title()
def mask_code(body: str) -> str:
"""Blank out code so links inside code samples are ignored, preserving line
boundaries so line-based parsing (gloss fallback, line numbers) stays aligned."""
body = FENCED_CODE_RE.sub(lambda m: "\n" * m.group(0).count("\n"), body)
body = INLINE_CODE_RE.sub(lambda m: " " * len(m.group(0)), body)
return body
def scan_brace(text: str, start: int) -> Tuple[object, int]:
"""Given text[start] == '{', scan to the matching '}' respecting JSON string
quoting, and json.loads the block. Returns (obj_or_None, end_index_after_brace).
On an unterminated or malformed brace, returns (None, start) so the caller
treats it as no brace."""
depth = 0
in_str = False
esc = False
i = start
while i < len(text):
c = text[i]
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
elif c == '"':
in_str = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
raw = text[start : i + 1]
try:
return json.loads(raw), i + 1
except (json.JSONDecodeError, ValueError):
return None, start
i += 1
return None, start
def clean_md_target(raw: str) -> str:
"""Normalize a markdown link target: drop a trailing ``"title"`` and <> wrappers."""
raw = raw.strip()
if raw.startswith("<") and raw.endswith(">"):
raw = raw[1:-1].strip()
# `[text](target "title")` -> target
for sep in (' "', " '"):
if sep in raw:
raw = raw.split(sep, 1)[0].strip()
return raw
def parse_links(body: str) -> List[LinkRef]:
masked = mask_code(body)
refs: List[LinkRef] = []
for lineno, line in enumerate(masked.splitlines(), start=1):
# Collect wiki + markdown link matches on this line, in order.
raw_matches: List[Tuple[int, int, str, str, str, str]] = []
for m in WIKI_LINK_RE.finditer(line):
target = m.group(1).strip()
alias = (m.group(2) or target).strip()
if target:
raw_matches.append(
(m.start(), m.end(), "wiki_link", target, alias, m.group(0))
)
for m in MD_LINK_RE.finditer(line):
target = clean_md_target(m.group(2))
display = m.group(1).strip()
if target:
raw_matches.append(
(
m.start(),
m.end(),
"markdown_link",
target,
display or target,
m.group(0),
)
)
raw_matches.sort(key=lambda x: x[0])
line_refs: List[Tuple[LinkRef, int]] = [] # (ref, end-index-after-brace)
for start, end, kind, target, anchor, raw in raw_matches:
rel_type, annotation = "related", ""
brace_end = end
if end < len(line) and line[end] == "{":
obj, be = scan_brace(line, end)
if isinstance(obj, dict):
brace_end = be
t = obj.get("type")
if isinstance(t, str) and t.strip():
rel_type = t.strip()
info = obj.get("link-info")
if isinstance(info, str) and info.strip():
annotation = info.strip()
is_external = kind == "markdown_link" and bool(
EXTERNAL_TARGET_RE.match(target)
)
ref = LinkRef(
target=target,
anchor=anchor,
kind=kind,
rel_type=rel_type,
annotation=annotation,
lineno=lineno,
raw=raw,
is_external=is_external,
)
line_refs.append((ref, brace_end))
# Annotation fallback: a trailing "— gloss" applies only when the link is
# the sole link on its line (so we don't mis-assign a shared gloss).
if len(line_refs) == 1:
ref, brace_end = line_refs[0]
if not ref.annotation:
gloss = GLOSS_RE.match(line[brace_end:])
if gloss:
ref.annotation = gloss.group(1).strip()
refs.extend(ref for ref, _ in line_refs)
return refs
def norm(s: str) -> str:
return s.strip().lower()
def build_notes(root: Path) -> List[Note]:
notes: List[Note] = []
for p in sorted(root.rglob("*.md")):
rel = p.relative_to(root)
if rel.parts and rel.parts[0] in {"index", "scripts", ".obsidian"}:
continue
# `raw/` holds untouched ingested originals (external READMEs, specs, …),
# not vault notes — skip them so their file-relative markdown links are not
# treated as (broken) graph edges.
if "raw" in rel.parts:
continue
text = p.read_text(encoding="utf-8")
meta, body = parse_frontmatter(text)
title = derive_title(meta, body, p.stem)
note_id = str(meta.get("id") or rel.with_suffix("")).strip()
links = parse_links(body)
# parse_links numbers lines within `body`; shift to absolute file line
# numbers by the number of lines the frontmatter block occupied.
line_offset = text.count("\n", 0, len(text) - len(body))
if line_offset:
for ref in links:
ref.lineno += line_offset
notes.append(
Note(
path=p,
rel_path=rel.as_posix(),
stem=p.stem,
meta=meta,
title=title,
note_id=note_id,
links=links,
)
)
return notes
def build_alias_map(notes: List[Note]) -> Dict[str, str]:
alias_map: Dict[str, str] = {}
stem_buckets: Dict[str, List[str]] = defaultdict(list)
for n in notes:
stem_buckets[norm(n.stem)].append(n.note_id)
for n in notes:
alias_map[norm(n.note_id)] = n.note_id
alias_map[norm(n.rel_path)] = n.note_id
alias_map[norm(Path(n.rel_path).with_suffix("").as_posix())] = n.note_id
alias_map[norm(n.title)] = n.note_id
for stem, ids in stem_buckets.items():
if len(ids) == 1:
alias_map[stem] = ids[0]
return alias_map
def generate_graph(
root: Path, existing_generated_at: str = ""
) -> Tuple[Dict[str, object], List[UnresolvedLink], List[WikiLinkUse]]:
notes = build_notes(root)
alias_map = build_alias_map(notes)
edges_set = set()
outbound: Dict[str, List[str]] = defaultdict(list)
inbound_count: Dict[str, int] = defaultdict(int)
unresolved_set = set()
wiki_uses_set = set()
for n in notes:
for ref in n.links:
# Legacy [[wiki links]] are still parsed (so we can flag them) but are
# no longer an accepted link form — --validate rejects any that remain.
if ref.kind == "wiki_link":
wiki_uses_set.add(
WikiLinkUse(
from_id=n.note_id,
from_path=n.rel_path,
target=ref.target,
lineno=ref.lineno,
raw=ref.raw,
)
)
if ref.is_external:
continue
target = alias_map.get(norm(ref.target))
if not target:
unresolved_set.add(
UnresolvedLink(
from_id=n.note_id,
from_path=n.rel_path,
target=ref.target,
)
)
continue
edge = (n.note_id, target, ref.kind, ref.rel_type, ref.annotation, ref.anchor)
if edge in edges_set:
continue
edges_set.add(edge)
outbound[n.note_id].append(target)
inbound_count[target] += 1
nodes = []
for n in sorted(notes, key=lambda x: x.note_id):
tags = n.meta.get("tags", [])
if not isinstance(tags, list):
tags = []
nodes.append(
{
"id": n.note_id,
"title": n.title,
"path": n.rel_path,
"repo": n.meta.get("repo", "global"),
"type": n.meta.get("type", "note"),
"status": n.meta.get("status", "active"),
"tags": tags,
"last_reviewed": n.meta.get("last_reviewed", ""),
"outbound_links": sorted(set(outbound.get(n.note_id, []))),
"inbound_count": inbound_count.get(n.note_id, 0),
}
)
edges = [
{
"from": src,
"to": dst,
"kind": kind,
"type": rel_type,
"anchor_text": anchor,
"annotation": annotation,
"weight": 1.0,
}
for src, dst, kind, rel_type, annotation, anchor in sorted(edges_set)
]
graph = {
"version": 1,
"generated_at": existing_generated_at
if existing_generated_at
else datetime.now(timezone.utc).isoformat(),
"nodes": nodes,
"edges": edges,
}
unresolved = sorted(unresolved_set, key=lambda x: (x.from_path, x.target))
wiki_uses = sorted(wiki_uses_set, key=lambda x: (x.from_path, x.lineno, x.raw))
return graph, unresolved, wiki_uses
def find_invalid_types(graph: Dict[str, object]) -> List[Dict[str, object]]:
return [
e
for e in graph.get("edges", []) # type: ignore[union-attr]
if e.get("type") not in RELATION_TYPES
]
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
]
@dataclass
class GraphBuild:
"""Result of one in-memory build: the graph plus everything --validate reports."""
graph: Dict[str, object]
unresolved: List[UnresolvedLink] = field(default_factory=list)
wiki_uses: List[WikiLinkUse] = field(default_factory=list)
@property
def invalid_edge_types(self) -> List[Dict[str, object]]:
return find_invalid_types(self.graph)
@property
def invalid_node_types(self) -> List[Dict[str, object]]:
return find_invalid_node_types(self.graph)
@property
def ok(self) -> bool:
return not validation_problems(self)
def build_graph(root: Path, existing_generated_at: str = "") -> GraphBuild:
"""Build the graph for `root` in memory."""
graph, unresolved, wiki_uses = generate_graph(Path(root), existing_generated_at)
return GraphBuild(graph=graph, unresolved=unresolved, wiki_uses=wiki_uses)
def validation_problems(build: GraphBuild) -> List[str]:
"""Human-readable validation failures, empty when the build is clean."""
problems: List[str] = []
for link in build.unresolved:
problems.append(f"unresolved link: {link.from_path}: [[{link.target}]]")
for e in build.invalid_edge_types:
problems.append(
f"unknown relationship type: {e.get('from')} -> {e.get('to')}: type={e.get('type')!r}"
)
for n in build.invalid_node_types:
problems.append(f"unknown note type: {n.get('id')} ({n.get('path')}): type={n.get('type')!r}")
for w in build.wiki_uses:
problems.append(f"legacy wiki link: {w.from_path}:{w.lineno}: {w.raw}")
return problems
+35
View File
@@ -0,0 +1,35 @@
"""Locate and read the framework's agent instructions.
`read_instructions` returns AGENTS.md and AGENT_QUERY_GUIDE.md so an agent
whose only access is MCP still gets the operating rules. In a source checkout
the documents live next to this package (the framework root); in an installed
wheel they are bundled under `kb_framework/_docs/`. Both lookups are explicit —
there is never a fallback to files inside the data vault.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict
DOCUMENT_NAMES = ("AGENTS.md", "AGENT_QUERY_GUIDE.md")
_PACKAGE_DIR = Path(__file__).resolve().parent
# Source checkout: the framework repository root (parent of this package).
FRAMEWORK_ROOT = _PACKAGE_DIR.parent
_BUNDLED_DOCS = _PACKAGE_DIR / "_docs"
def instructions_root() -> Path:
"""Directory holding the instruction documents for this installation."""
if (FRAMEWORK_ROOT / DOCUMENT_NAMES[0]).is_file():
return FRAMEWORK_ROOT
return _BUNDLED_DOCS
def read_instructions() -> Dict[str, Any]:
root = instructions_root()
documents = [
{"path": name, "content": (root / name).read_text(encoding="utf-8")}
for name in DOCUMENT_NAMES
]
return {"framework_root": str(root), "documents": documents}
+159
View File
@@ -0,0 +1,159 @@
"""Pure query functions over an in-memory graph dict.
These back the read-only MCP tools (`graph_summary`, `graph_search_nodes`,
`graph_neighbors`) and the id-to-path lookup `read_note` needs. They take the
graph as data and never read the filesystem, so the stdio server and the
connector return identical payloads for identical graphs.
"""
from __future__ import annotations
from collections import Counter
from typing import Any, Dict, List
# 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
DEFAULT_SEARCH_LIMIT = 30
DEFAULT_NEIGHBOR_LIMIT = 50
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
def summary(
graph: Dict[str, Any],
topLinked: int = DEFAULT_TOP_LINKED,
topTags: int = DEFAULT_TOP_TAGS,
) -> Dict[str, Any]:
nodes = graph.get("nodes", [])
edges = graph.get("edges", [])
top_n = int(topLinked)
top_tags = int(topTags)
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]
return {
"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
],
}
def search_nodes(graph: Dict[str, Any], args: Dict[str, Any]) -> Dict[str, Any]:
nodes = graph.get("nodes", [])
limit = int(args.get("limit", DEFAULT_SEARCH_LIMIT))
filtered = [node for node in nodes if match_filters(node, args)]
filtered.sort(key=lambda n: str(n.get("id", "")))
sliced = filtered[:limit]
return {
"total_matches": len(filtered),
"returned": len(sliced),
"items": sliced,
}
def neighbors(
graph: Dict[str, Any], id: str, limit: int = DEFAULT_NEIGHBOR_LIMIT
) -> Dict[str, Any]:
node_id = str(id)
limit = int(limit)
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)
result.append(
{
"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", ""),
}
)
return result[:limit]
return {
"node": nodes[node_id],
"inbound_count": len(inbound_edges),
"outbound_count": len(outbound_edges),
"inbound": describe(inbound_edges, "inbound"),
"outbound": describe(outbound_edges, "outbound"),
}
def node_path(graph: Dict[str, Any], id: str) -> str:
"""Vault-relative path of the note behind a node id."""
node = nodes_by_id(graph).get(str(id))
if not node:
raise ValueError(f"node '{id}' not found")
return str(node.get("path"))
+164
View File
@@ -0,0 +1,164 @@
"""The read-only MCP tool contract: definitions and dispatch.
Both front ends (the stdio server and the hosted connector) advertise exactly
these five tools with exactly these schemas, and `call` produces the result
dict (`content` + `structuredContent`) each returns. Keeping the contract here
means an agent skill written against one front end stays valid on the other.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List
from kb_framework import instructions, query, vault
READ_ONLY_TOOL_NAMES = (
"read_instructions",
"graph_summary",
"graph_search_nodes",
"graph_neighbors",
"read_note",
)
def 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` — 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,
},
},
]
def text_result(payload: Dict[str, Any]) -> Dict[str, Any]:
"""Wrap a payload as an MCP tool result: pretty JSON text plus structuredContent."""
return {
"content": [{"type": "text", "text": json.dumps(payload, indent=2)}],
"structuredContent": payload,
}
def read_note_path(graph: Dict[str, Any], args: Dict[str, Any]) -> str:
"""Resolve `read_note` arguments to the vault-relative path to read."""
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")
if has_id:
return query.node_path(graph, str(args["id"]))
return str(args["path"])
def call(
name: str, args: Dict[str, Any], *, graph: Dict[str, Any], root: Path
) -> Dict[str, Any]:
"""Run one read-only tool. Raises ValueError for unknown tools or bad input."""
if name == "read_instructions":
return text_result(instructions.read_instructions())
if name == "graph_summary":
return text_result(
query.summary(
graph,
topLinked=args.get("topLinked", query.DEFAULT_TOP_LINKED),
topTags=args.get("topTags", query.DEFAULT_TOP_TAGS),
)
)
if name == "graph_search_nodes":
return text_result(query.search_nodes(graph, args))
if name == "graph_neighbors":
return text_result(
query.neighbors(graph, str(args["id"]), args.get("limit", query.DEFAULT_NEIGHBOR_LIMIT))
)
if name == "read_note":
return text_result(vault.read_note_payload(root, read_note_path(graph, args)))
raise ValueError(f"unknown tool '{name}'")
+57
View File
@@ -0,0 +1,57 @@
"""Filesystem access to a vault, confined to its root.
`resolve_in_root` is the single place that turns a vault-relative path into an
absolute one and refuses empty, absolute or escaping values, so no tool can
reach outside the vault. `read_note_payload` returns the `read_note` payload
shape shared by the stdio server and the connector: UTF-8 text inline, or a
binary descriptor (size, sha256, hint) for captured originals under `raw/`.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from typing import Any, Dict
def resolve_in_root(root: Path, raw_path: Any, *, label: str = "path") -> Path:
"""Turn a vault-relative path into an absolute path inside `root`."""
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 = Path(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
def relative_to_root(root: Path, target: Path) -> str:
return target.relative_to(Path(root).resolve()).as_posix()
def read_note_payload(root: Path, raw_path: Any) -> Dict[str, Any]:
"""The `read_note` payload for a vault-relative path."""
target = resolve_in_root(root, raw_path)
if not target.exists():
raise FileNotFoundError(f"note not found at {target}")
relative = relative_to_root(root, target)
data = target.read_bytes()
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
return {
"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 {"path": relative, "binary": False, "content": text}