Support external vault roots across tooling

This commit is contained in:
2026-09-20 17:05:21 +02:00
parent 1cb6369282
commit 293b582e97
16 changed files with 573 additions and 204 deletions
+4 -2
View File
@@ -18,6 +18,8 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Tuple
from vault_root import parse_vault_args
# Obsidian-style wiki link: [[target#anchor|alias]]
WIKI_LINK_RE = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]")
@@ -438,7 +440,7 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="write index/graph.json (default unless --validate is used alone)",
)
return parser.parse_args()
return parse_vault_args(parser)
def print_unresolved(unresolved: List[UnresolvedLink]) -> None:
@@ -502,7 +504,7 @@ def print_invalid_node_types(invalid: List[Dict[str, object]]) -> None:
def main() -> None:
args = parse_args()
root = Path(__file__).resolve().parents[1]
root = args.vault_root
output = root / "index" / "graph.json"
should_write = args.write or not args.validate
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Install a vault-local hook that invokes this framework without copying its tools."""
import argparse
import shlex
import subprocess
from pathlib import Path
from vault_root import FRAMEWORK_ROOT, parse_vault_args
def main():
parser = argparse.ArgumentParser(description=__doc__)
root = parse_vault_args(parser).vault_root
repo = subprocess.run(["git", "-C", str(root), "rev-parse", "--show-toplevel"],
capture_output=True, text=True)
if repo.returncode or repo.stdout.strip() != str(root):
parser.error("vault root must be the Git repository root to install hooks")
result = subprocess.run(
["git", "-C", str(root), "rev-parse", "--path-format=absolute", "--git-path", "hooks"],
capture_output=True, text=True, check=True,
)
hook = Path(result.stdout.strip()) / "pre-commit"
marker = "# knowledge-base framework hook"
if hook.exists() or hook.is_symlink():
legacy = hook.is_symlink() and hook.resolve() in {
root / "scripts/pre-commit", FRAMEWORK_ROOT / "scripts/pre-commit",
}
managed = not hook.is_symlink() and marker in hook.read_text()
if not legacy and not managed:
parser.error(f"refusing to replace unmanaged hook: {hook}")
script = FRAMEWORK_ROOT / "scripts/pre-commit"
content = f"#!/bin/sh\n{marker}\nexec bash {shlex.quote(str(script))} {shlex.quote(str(root))}\n"
hook.parent.mkdir(parents=True, exist_ok=True)
if hook.is_symlink():
hook.unlink()
hook.write_text(content)
hook.chmod(0o755)
print(f"Installed {hook} -> {script} (vault: {root})")
if __name__ == "__main__":
main()
+4 -2
View File
@@ -21,6 +21,8 @@ from collections import defaultdict
from pathlib import Path
from typing import Dict, List
from vault_root import parse_vault_args
# Reuse the parser/resolver from generate-graph.py (hyphenated name -> load by path).
_GG_PATH = Path(__file__).resolve().with_name("generate-graph.py")
_spec = importlib.util.spec_from_file_location("generate_graph", _GG_PATH)
@@ -102,9 +104,9 @@ def main() -> None:
default="grouped",
help="output format (default: grouped text)",
)
args = parser.parse_args()
args = parse_vault_args(parser)
root = Path(__file__).resolve().parents[1]
root = args.vault_root
rows = collect(root)
if args.format == "tsv":
print_tsv(rows)
+48 -5
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env python3
"""MCP server for the knowledge-base repository.
"""MCP server for a selected knowledge-base vault, using framework-owned tooling.
This server is the single interface an agent needs to work the vault over stdio
with JSON-RPC 2.0. It exposes graph and note *querying* (graph_summary,
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*
(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
@@ -18,6 +19,7 @@ It auto-detects the transport layer:
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
@@ -30,6 +32,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List
from vault_root import FRAMEWORK_ROOT, parse_vault_args
SERVER_NAME = "knowledge-base-mcp"
SERVER_VERSION = "0.1.0"
@@ -227,6 +230,12 @@ def _relative_to_root(state: AppState, target: Path) -> str:
def _run_git(state: AppState, args: List[str]) -> subprocess.CompletedProcess:
repo = subprocess.run(
["git", "rev-parse", "--show-toplevel"], cwd=state.root,
check=False, capture_output=True, text=True,
)
if repo.returncode != 0 or Path(repo.stdout.strip()).resolve() != state.root.resolve():
raise ValueError("vault root must be the Git repository root for Git operations")
return subprocess.run(
["git", *args],
cwd=state.root,
@@ -249,6 +258,19 @@ 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": (
@@ -474,6 +496,15 @@ 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", [])
@@ -624,7 +655,10 @@ def _tool_read_note(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
def _tool_refresh_graph(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
command = [sys.executable, "scripts/generate-graph.py"]
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")
@@ -884,6 +918,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":
@@ -908,7 +944,7 @@ def _call_tool(state: AppState, name: str, arguments: Dict[str, Any]) -> Dict[st
def _list_resources(state: AppState) -> Dict[str, Any]:
return {
result = {
"resources": [
{
"uri": "repo://graph",
@@ -924,6 +960,12 @@ def _list_resources(state: AppState) -> Dict[str, Any]:
},
]
}
if not (state.root / "README.md").is_file():
result["resources"] = [
resource for resource in result["resources"]
if resource["uri"] != "repo://readme"
]
return result
def _read_resource(state: AppState, uri: str) -> Dict[str, Any]:
@@ -994,7 +1036,8 @@ def _handle_request(state: AppState, message: Dict[str, Any]) -> Dict[str, Any]
def main() -> None:
root = Path(__file__).resolve().parents[1]
args = parse_vault_args(argparse.ArgumentParser(description=__doc__))
root = args.vault_root
state = AppState(root=root, graph_path=root / "index" / "graph.json")
reader = _StdinReader(sys.stdin.buffer)
+7 -3
View File
@@ -1,11 +1,15 @@
#!/usr/bin/env bash
# Generate and validate the selected vault graph before accepting a commit.
set -euo pipefail
framework_root="$(cd -- "$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/.." && pwd)"
vault_root="${1:-${KNOWLEDGE_BASE_ROOT:-$framework_root}}"
echo "[pre-commit] Generating and validating graph..."
make graph
"${PYTHON:-python3}" "$framework_root/scripts/generate-graph.py" --vault-root "$vault_root" --write --validate
if ! git diff --quiet -- index/graph.json; then
if ! git -C "$vault_root" ls-files --error-unmatch index/graph.json >/dev/null 2>&1 || \
! git -C "$vault_root" diff --quiet -- index/graph.json; then
echo "[pre-commit] index/graph.json was modified by generation."
echo "[pre-commit] Run 'make graph' and stage index/graph.json before committing."
echo "[pre-commit] Stage the generated index/graph.json before committing."
exit 1
fi
+27
View File
@@ -0,0 +1,27 @@
"""Resolve the selected data directory consistently for framework command-line tools."""
import argparse
import os
from pathlib import Path
FRAMEWORK_ROOT = Path(__file__).resolve().parents[1]
def parse_vault_args(parser: argparse.ArgumentParser) -> argparse.Namespace:
"""Add the shared CLI contract and return a validated absolute vault root."""
parser.add_argument(
"--vault-root",
help="data directory (default: KNOWLEDGE_BASE_ROOT, then framework root)",
)
args = parser.parse_args()
raw = args.vault_root if args.vault_root is not None else os.environ.get(
"KNOWLEDGE_BASE_ROOT", str(FRAMEWORK_ROOT)
)
if not raw.strip():
parser.error("vault root must not be empty")
root = Path(raw).expanduser().resolve()
if not root.is_dir():
parser.error(f"vault root must be an existing directory: {root}")
args.vault_root = root
return args