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>
781 lines
28 KiB
Python
Executable File
781 lines
28 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""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 provides framework operating instructions (read_instructions),
|
|
and exposes graph and note *querying* (graph_summary,
|
|
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.
|
|
|
|
Every write is confined to the repository root, and staging is always explicit:
|
|
paths are named one by one, never `.` or `-A`.
|
|
|
|
It auto-detects the transport layer:
|
|
- Content-Length framing (MCP spec, used by most clients)
|
|
- Newline-delimited JSON (used by Claude Code ≥ 2.1)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List
|
|
|
|
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_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:
|
|
"""Buffered stdin reader that supports transport auto-detection."""
|
|
|
|
def __init__(self, raw: Any) -> None:
|
|
self._raw = raw
|
|
self._prefix: bytes = b""
|
|
|
|
def read(self, n: int) -> bytes:
|
|
if self._prefix:
|
|
chunk, self._prefix = self._prefix[:n], self._prefix[n:]
|
|
remaining = n - len(chunk)
|
|
return chunk + (self._raw.read(remaining) if remaining else b"")
|
|
return self._raw.read(n)
|
|
|
|
def readline(self) -> bytes:
|
|
if self._prefix:
|
|
nl = self._prefix.find(b"\n")
|
|
if nl != -1:
|
|
line, self._prefix = self._prefix[: nl + 1], self._prefix[nl + 1 :]
|
|
return line
|
|
prefix, self._prefix = self._prefix, b""
|
|
return prefix + self._raw.readline()
|
|
return self._raw.readline()
|
|
|
|
def peek_byte(self) -> bytes:
|
|
b = self._raw.read(1)
|
|
if b:
|
|
self._prefix = b + self._prefix
|
|
return b
|
|
|
|
|
|
_WHITESPACE_BYTES = {b"\r", b"\n", b"\t", b" "}
|
|
|
|
|
|
def _detect_transport(reader: _StdinReader) -> str:
|
|
"""Peek at the first non-whitespace byte to decide 'ndjson' vs 'framed'.
|
|
|
|
Leading ASCII whitespace bytes are discarded — they corrupt neither JSON
|
|
nor Content-Length headers, and some client wrappers emit stray newlines
|
|
before the first message. EOF before any non-whitespace defaults to
|
|
'ndjson' so the read loop exits cleanly on an empty stream.
|
|
"""
|
|
while True:
|
|
b = reader._raw.read(1)
|
|
if not b:
|
|
return "ndjson"
|
|
if b not in _WHITESPACE_BYTES:
|
|
reader._prefix = b + reader._prefix
|
|
return "ndjson" if b == b"{" else "framed"
|
|
|
|
|
|
def _read_message_framed(reader: _StdinReader) -> Dict[str, Any] | None:
|
|
headers: Dict[str, str] = {}
|
|
while True:
|
|
line = reader.readline()
|
|
if not line:
|
|
return None
|
|
if line in (b"\r\n", b"\n"):
|
|
break
|
|
key, _, value = line.decode("utf-8").partition(":")
|
|
headers[key.strip().lower()] = value.strip()
|
|
|
|
length_raw = headers.get("content-length")
|
|
if not length_raw:
|
|
raise ValueError("missing Content-Length header")
|
|
length = int(length_raw)
|
|
payload = reader.read(length)
|
|
if len(payload) != length:
|
|
raise ValueError("incomplete payload")
|
|
return json.loads(payload.decode("utf-8"))
|
|
|
|
|
|
def _read_message_ndjson(reader: _StdinReader) -> Dict[str, Any] | None:
|
|
while True:
|
|
line = reader.readline()
|
|
if not line:
|
|
return None
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
continue
|
|
return json.loads(stripped.decode("utf-8"))
|
|
|
|
|
|
def _write_message_framed(stdout: Any, payload: Dict[str, Any]) -> None:
|
|
data = json.dumps(payload, ensure_ascii=True).encode("utf-8")
|
|
stdout.buffer.write(f"Content-Length: {len(data)}\r\n\r\n".encode("utf-8"))
|
|
stdout.buffer.write(data)
|
|
stdout.buffer.flush()
|
|
|
|
|
|
def _write_message_ndjson(stdout: Any, payload: Dict[str, Any]) -> None:
|
|
data = json.dumps(payload, ensure_ascii=True).encode("utf-8") + b"\n"
|
|
stdout.buffer.write(data)
|
|
stdout.buffer.flush()
|
|
|
|
|
|
def _response(message_id: Any, result: Dict[str, Any]) -> Dict[str, Any]:
|
|
return {"jsonrpc": "2.0", "id": message_id, "result": result}
|
|
|
|
|
|
def _error(message_id: Any, code: int, message: str) -> Dict[str, Any]:
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": message_id,
|
|
"error": {"code": code, "message": message},
|
|
}
|
|
|
|
|
|
_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.
|
|
_DEFAULT_MAX_CAPTURE_BYTES = 100 * 1024 * 1024
|
|
_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.
|
|
|
|
|
|
def _resolve_in_root(state: AppState, raw_path: Any, *, label: str = "path") -> Path:
|
|
"""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 kb_vault.relative_to_root(state.root, target)
|
|
|
|
|
|
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,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
|
|
def _git_result(payload: Dict[str, Any], exit_code: int) -> Dict[str, Any]:
|
|
text = json.dumps(payload, indent=2)
|
|
if exit_code != 0:
|
|
return {
|
|
"isError": True,
|
|
"content": [{"type": "text", "text": text}],
|
|
"structuredContent": payload,
|
|
}
|
|
return _text_result(text, structured=payload)
|
|
|
|
|
|
def _tool_definitions() -> List[Dict[str, Any]]:
|
|
return [
|
|
*query_tools.definitions(),
|
|
{
|
|
"name": "refresh_graph",
|
|
"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": {
|
|
"validate": {"type": "boolean"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"name": "write_note",
|
|
"description": (
|
|
"Create a note (or any text file) at a vault-relative path, "
|
|
"creating parent directories as needed. Fails if the file "
|
|
"exists unless `overwrite` is true. The generated "
|
|
"`index/graph.json` is off limits — use `refresh_graph`."
|
|
),
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"required": ["path", "content"],
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": "Vault-relative path, e.g. '03_resources/demo/index.md'.",
|
|
},
|
|
"content": {"type": "string", "description": "Full file content."},
|
|
"overwrite": {
|
|
"type": "boolean",
|
|
"description": "Replace an existing file (default false).",
|
|
},
|
|
},
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
{
|
|
"name": "edit_note",
|
|
"description": (
|
|
"Replace an exact string inside an existing note. `old_string` "
|
|
"must occur exactly once unless `replace_all` is true."
|
|
),
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"required": ["path", "old_string", "new_string"],
|
|
"properties": {
|
|
"path": {"type": "string", "description": "Vault-relative path of the note."},
|
|
"old_string": {"type": "string", "description": "Text to replace, matched verbatim."},
|
|
"new_string": {"type": "string", "description": "Replacement text."},
|
|
"replace_all": {
|
|
"type": "boolean",
|
|
"description": "Replace every occurrence (default false).",
|
|
},
|
|
},
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
{
|
|
"name": "capture_raw",
|
|
"description": (
|
|
"Copy a local file or download an http(s) URL into the vault "
|
|
"byte-for-byte — the way an original PDF, image, or archive is "
|
|
"preserved under `03_resources/<slug>/raw/`. Provide exactly one "
|
|
"of `source_path` (absolute or CWD-relative path on this machine) "
|
|
"or `url`. Binary-safe; returns size and sha256. Read the "
|
|
"captured file back with `read_note`, which reports binary files "
|
|
"with an absolute path to open with a file-reading tool."
|
|
),
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"required": ["path"],
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": "Vault-relative destination, e.g. '03_resources/demo/raw/paper.pdf'.",
|
|
},
|
|
"source_path": {
|
|
"type": "string",
|
|
"description": "Path of the local file to copy.",
|
|
},
|
|
"url": {
|
|
"type": "string",
|
|
"description": "http(s) URL to download verbatim.",
|
|
},
|
|
"overwrite": {
|
|
"type": "boolean",
|
|
"description": "Replace an existing capture (default false).",
|
|
},
|
|
"max_bytes": {
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
"description": "Reject captures larger than this (default 100 MiB).",
|
|
},
|
|
},
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
{
|
|
"name": "git_commit",
|
|
"description": (
|
|
"Stage the named vault-relative paths individually and commit "
|
|
"them. `paths` must list every path explicitly — wildcard "
|
|
"staging ('.', '-A', '*') is rejected. Returns an error result "
|
|
"if git refuses the commit (e.g. nothing staged)."
|
|
),
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"required": ["message", "paths"],
|
|
"properties": {
|
|
"message": {"type": "string", "description": "Commit message (Conventional Commits)."},
|
|
"paths": {
|
|
"type": "array",
|
|
"minItems": 1,
|
|
"items": {"type": "string"},
|
|
"description": "Vault-relative paths to stage, one entry per file or directory.",
|
|
},
|
|
},
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
{
|
|
"name": "git_push",
|
|
"description": (
|
|
"Push commits to a remote. Defaults to 'origin' and the current "
|
|
"branch. Returns an error result if git refuses the push."
|
|
),
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"remote": {"type": "string", "description": "Remote name (default 'origin')."},
|
|
"branch": {"type": "string", "description": "Branch to push (default: current branch)."},
|
|
"set_upstream": {
|
|
"type": "boolean",
|
|
"description": "Pass --set-upstream (default false).",
|
|
},
|
|
},
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def _text_result(text: str, structured: Dict[str, Any] | None = None) -> Dict[str, Any]:
|
|
result: Dict[str, Any] = {"content": [{"type": "text", "text": text}]}
|
|
if structured is not None:
|
|
result["structuredContent"] = structured
|
|
return result
|
|
|
|
|
|
def _tool_refresh_graph(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""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 = {
|
|
"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 problems:
|
|
return {
|
|
"isError": True,
|
|
"content": [{"type": "text", "text": json.dumps(payload, indent=2)}],
|
|
"structuredContent": payload,
|
|
}
|
|
return _text_result(json.dumps(payload, indent=2), structured=payload)
|
|
|
|
|
|
def _tool_write_note(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
if "content" not in args:
|
|
raise ValueError("'content' is required")
|
|
content = str(args["content"])
|
|
target = _resolve_in_root(state, args.get("path"))
|
|
relative = _relative_to_root(state, target)
|
|
if relative == _GENERATED_GRAPH_PATH:
|
|
raise ValueError(
|
|
"index/graph.json is generated \u2014 use refresh_graph instead of writing it"
|
|
)
|
|
|
|
existed = target.exists()
|
|
if existed and not bool(args.get("overwrite", False)):
|
|
raise FileExistsError(
|
|
f"note already exists at {relative}; pass overwrite=true to replace it"
|
|
)
|
|
if existed and target.is_dir():
|
|
raise ValueError(f"'{relative}' is a directory")
|
|
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(content, encoding="utf-8")
|
|
state.invalidate()
|
|
payload = {
|
|
"path": relative,
|
|
"created": not existed,
|
|
"bytes": len(content.encode("utf-8")),
|
|
}
|
|
return _text_result(json.dumps(payload, indent=2), structured=payload)
|
|
|
|
|
|
def _tool_edit_note(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
if "old_string" not in args or "new_string" not in args:
|
|
raise ValueError("'old_string' and 'new_string' are required")
|
|
old_string = str(args["old_string"])
|
|
new_string = str(args["new_string"])
|
|
if not old_string:
|
|
raise ValueError("'old_string' must not be empty")
|
|
if old_string == new_string:
|
|
raise ValueError("'old_string' and 'new_string' are identical")
|
|
|
|
target = _resolve_in_root(state, args.get("path"))
|
|
relative = _relative_to_root(state, target)
|
|
if not target.is_file():
|
|
raise FileNotFoundError(f"note not found at {relative}")
|
|
|
|
text = target.read_text(encoding="utf-8")
|
|
occurrences = text.count(old_string)
|
|
if occurrences == 0:
|
|
raise ValueError(f"'old_string' not found in {relative}")
|
|
replace_all = bool(args.get("replace_all", False))
|
|
if occurrences > 1 and not replace_all:
|
|
raise ValueError(
|
|
f"'old_string' occurs {occurrences} times in {relative}; "
|
|
"make it unique or pass replace_all=true"
|
|
)
|
|
|
|
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,
|
|
}
|
|
return _text_result(json.dumps(payload, indent=2), structured=payload)
|
|
|
|
|
|
def _download_bytes(url: str, max_bytes: int) -> bytes:
|
|
"""Fetch an http(s) URL into memory, refusing anything over `max_bytes`."""
|
|
request = urllib.request.Request(
|
|
url, headers={"User-Agent": f"{SERVER_NAME}/{SERVER_VERSION}"}
|
|
)
|
|
with urllib.request.urlopen(request, timeout=_DOWNLOAD_TIMEOUT_SECONDS) as response:
|
|
declared = response.headers.get("Content-Length")
|
|
if declared and declared.isdigit() and int(declared) > max_bytes:
|
|
raise ValueError(
|
|
f"remote file is {declared} bytes, over the {max_bytes} byte limit"
|
|
)
|
|
data = response.read(max_bytes + 1)
|
|
if len(data) > max_bytes:
|
|
raise ValueError(f"download exceeds the {max_bytes} byte limit")
|
|
return data
|
|
|
|
|
|
def _tool_capture_raw(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
source_path = str(args.get("source_path") or "").strip()
|
|
url = str(args.get("url") or "").strip()
|
|
if bool(source_path) == bool(url):
|
|
raise ValueError("provide exactly one of 'source_path' or 'url'")
|
|
|
|
max_bytes = int(args.get("max_bytes", _DEFAULT_MAX_CAPTURE_BYTES))
|
|
if max_bytes < 1:
|
|
raise ValueError("'max_bytes' must be positive")
|
|
|
|
target = _resolve_in_root(state, args.get("path"))
|
|
relative = _relative_to_root(state, target)
|
|
existed = target.exists()
|
|
if existed and not bool(args.get("overwrite", False)):
|
|
raise FileExistsError(
|
|
f"capture already exists at {relative}; pass overwrite=true to replace it"
|
|
)
|
|
if existed and target.is_dir():
|
|
raise ValueError(f"'{relative}' is a directory")
|
|
|
|
if source_path:
|
|
origin = Path(source_path).expanduser()
|
|
if not origin.exists():
|
|
raise FileNotFoundError(f"source file not found at {origin}")
|
|
if not origin.is_file():
|
|
raise ValueError(f"source is not a file: {origin}")
|
|
size = origin.stat().st_size
|
|
if size > max_bytes:
|
|
raise ValueError(
|
|
f"source file is {size} bytes, over the {max_bytes} byte limit"
|
|
)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(origin, target)
|
|
data = target.read_bytes()
|
|
source_label = str(origin)
|
|
else:
|
|
scheme = url.split(":", 1)[0].lower()
|
|
if scheme not in ("http", "https"):
|
|
raise ValueError(f"only http(s) URLs can be captured, got '{url}'")
|
|
try:
|
|
data = _download_bytes(url, max_bytes)
|
|
except (urllib.error.URLError, urllib.error.HTTPError, OSError) as exc:
|
|
payload = {
|
|
"url": url,
|
|
"path": relative,
|
|
"exit_code": 1,
|
|
"stderr": f"download failed: {exc}",
|
|
}
|
|
return _git_result(payload, 1)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(data)
|
|
source_label = url
|
|
state.invalidate()
|
|
|
|
payload = {
|
|
"path": relative,
|
|
"absolute_path": str(target),
|
|
"source": source_label,
|
|
"created": not existed,
|
|
"bytes": len(data),
|
|
"sha256": hashlib.sha256(data).hexdigest(),
|
|
}
|
|
return _text_result(json.dumps(payload, indent=2), structured=payload)
|
|
|
|
|
|
def _tool_git_commit(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
message = str(args.get("message", "")).strip()
|
|
if not message:
|
|
raise ValueError("'message' is required")
|
|
|
|
raw_paths = args.get("paths")
|
|
if not isinstance(raw_paths, list) or not raw_paths:
|
|
raise ValueError("'paths' must list at least one vault-relative path")
|
|
|
|
relatives: List[str] = []
|
|
for raw in raw_paths:
|
|
value = str(raw or "").strip()
|
|
if value in _WILDCARD_PATHS or value.startswith("-"):
|
|
raise ValueError(
|
|
f"wildcard staging is not allowed: '{value}'. Name each path explicitly."
|
|
)
|
|
target = _resolve_in_root(state, value, label="paths[]")
|
|
relative = _relative_to_root(state, target)
|
|
if not target.exists():
|
|
raise FileNotFoundError(f"path not found at {relative}")
|
|
relatives.append(relative)
|
|
|
|
relatives = sorted(set(relatives))
|
|
for relative in relatives:
|
|
add = _run_git(state, ["add", "--", relative])
|
|
if add.returncode != 0:
|
|
payload = {
|
|
"command": ["git", "add", "--", relative],
|
|
"exit_code": add.returncode,
|
|
"stdout": add.stdout,
|
|
"stderr": add.stderr,
|
|
"committed_paths": [],
|
|
}
|
|
return _git_result(payload, add.returncode)
|
|
|
|
commit = _run_git(state, ["commit", "-m", message])
|
|
payload = {
|
|
"command": ["git", "commit", "-m", message],
|
|
"exit_code": commit.returncode,
|
|
"stdout": commit.stdout,
|
|
"stderr": commit.stderr,
|
|
"committed_paths": relatives if commit.returncode == 0 else [],
|
|
}
|
|
if commit.returncode == 0:
|
|
head = _run_git(state, ["rev-parse", "--short", "HEAD"])
|
|
payload["commit"] = head.stdout.strip()
|
|
return _git_result(payload, commit.returncode)
|
|
|
|
|
|
def _tool_git_push(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
remote = str(args.get("remote") or "origin").strip()
|
|
branch = str(args.get("branch") or "").strip()
|
|
for label, value in (("remote", remote), ("branch", branch)):
|
|
if value.startswith("-"):
|
|
raise ValueError(f"'{label}' must not look like an option: '{value}'")
|
|
if not remote:
|
|
raise ValueError("'remote' must not be empty")
|
|
|
|
if not branch:
|
|
head = _run_git(state, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
if head.returncode != 0:
|
|
payload = {
|
|
"command": ["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
"exit_code": head.returncode,
|
|
"stdout": head.stdout,
|
|
"stderr": head.stderr,
|
|
}
|
|
return _git_result(payload, head.returncode)
|
|
branch = head.stdout.strip()
|
|
|
|
command = ["push"]
|
|
if bool(args.get("set_upstream", False)):
|
|
command.append("--set-upstream")
|
|
command += [remote, branch]
|
|
|
|
push = _run_git(state, command)
|
|
payload = {
|
|
"command": ["git", *command],
|
|
"remote": remote,
|
|
"branch": branch,
|
|
"exit_code": push.returncode,
|
|
"stdout": push.stdout,
|
|
"stderr": push.stderr,
|
|
}
|
|
return _git_result(payload, push.returncode)
|
|
|
|
|
|
def _call_tool(state: AppState, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
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":
|
|
return _tool_write_note(state, arguments)
|
|
if name == "edit_note":
|
|
return _tool_edit_note(state, arguments)
|
|
if name == "capture_raw":
|
|
return _tool_capture_raw(state, arguments)
|
|
if name == "git_commit":
|
|
return _tool_git_commit(state, arguments)
|
|
if name == "git_push":
|
|
return _tool_git_push(state, arguments)
|
|
raise ValueError(f"unknown tool '{name}'")
|
|
|
|
|
|
def _list_resources(state: AppState) -> Dict[str, Any]:
|
|
result = {
|
|
"resources": [
|
|
{
|
|
"uri": "repo://graph",
|
|
"name": "Repository Graph",
|
|
"description": "The graph built in memory from the vault's Markdown",
|
|
"mimeType": "application/json",
|
|
},
|
|
{
|
|
"uri": "repo://readme",
|
|
"name": "Repository README",
|
|
"description": "Root README.md",
|
|
"mimeType": "text/markdown",
|
|
},
|
|
]
|
|
}
|
|
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]:
|
|
if uri == "repo://graph":
|
|
text = json.dumps(state.graph(), indent=2)
|
|
return {
|
|
"contents": [
|
|
{
|
|
"uri": uri,
|
|
"mimeType": "application/json",
|
|
"text": text,
|
|
}
|
|
]
|
|
}
|
|
if uri == "repo://readme":
|
|
readme = (state.root / "README.md").read_text(encoding="utf-8")
|
|
return {"contents": [{"uri": uri, "mimeType": "text/markdown", "text": readme}]}
|
|
raise ValueError(f"unknown resource '{uri}'")
|
|
|
|
|
|
def _handle_request(state: AppState, message: Dict[str, Any]) -> Dict[str, Any] | None:
|
|
method = message.get("method")
|
|
message_id = message.get("id")
|
|
params = message.get("params") or {}
|
|
|
|
if method == "notifications/initialized":
|
|
return None
|
|
|
|
if method == "initialize":
|
|
result = {
|
|
"protocolVersion": "2024-11-05",
|
|
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
|
"capabilities": {"tools": {}, "resources": {}},
|
|
}
|
|
return _response(message_id, result)
|
|
|
|
if method == "tools/list":
|
|
return _response(message_id, {"tools": _tool_definitions()})
|
|
|
|
if method == "tools/call":
|
|
try:
|
|
tool_name = str(params.get("name", ""))
|
|
arguments = params.get("arguments") or {}
|
|
if not isinstance(arguments, dict):
|
|
raise ValueError("tool arguments must be an object")
|
|
result = _call_tool(state, tool_name, arguments)
|
|
return _response(message_id, result)
|
|
except Exception as exc:
|
|
error_result = {
|
|
"isError": True,
|
|
"content": [{"type": "text", "text": str(exc)}],
|
|
}
|
|
return _response(message_id, error_result)
|
|
|
|
if method == "resources/list":
|
|
return _response(message_id, _list_resources(state))
|
|
|
|
if method == "resources/read":
|
|
try:
|
|
uri = str(params.get("uri", ""))
|
|
return _response(message_id, _read_resource(state, uri))
|
|
except Exception as exc:
|
|
return _error(message_id, -32602, str(exc))
|
|
|
|
if message_id is None:
|
|
return None
|
|
return _error(message_id, -32601, f"method not found: {method}")
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_vault_args(argparse.ArgumentParser(description=__doc__))
|
|
root = args.vault_root
|
|
state = AppState(root=root)
|
|
|
|
reader = _StdinReader(sys.stdin.buffer)
|
|
try:
|
|
transport = _detect_transport(reader)
|
|
except OSError as exc:
|
|
print(
|
|
json.dumps({"error": "transport detection failed", "detail": str(exc)}),
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
if transport == "ndjson":
|
|
read_msg = _read_message_ndjson
|
|
write_msg = _write_message_ndjson
|
|
else:
|
|
read_msg = _read_message_framed
|
|
write_msg = _write_message_framed
|
|
|
|
while True:
|
|
try:
|
|
message = read_msg(reader)
|
|
except Exception as exc:
|
|
write_msg(sys.stdout, _error(None, -32700, f"parse error: {exc}"))
|
|
continue
|
|
|
|
if message is None:
|
|
break
|
|
|
|
response = _handle_request(state, message)
|
|
if response is not None:
|
|
write_msg(sys.stdout, response)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|