"""Knowtation MCP namespace — 9 tools, 8 resources, 4 prompts (§KD-5a.B/C/D).""" from __future__ import annotations import json import pathlib import re from datetime import datetime, timedelta, timezone from typing import Any, Callable from muse.core.attestation import ( AttestationRequiredError, get_attestation_provider, ) from muse.core.store import get_commit_snapshot_manifest, read_commit from muse.core.validation import sanitize_display from muse.mcp.errors import MCPApplicationError from muse.plugins.code._query import walk_commits_bfs from muse.plugins.knowtation.code_intel import note_impact from muse.plugins.knowtation.link_index import build_link_index from muse.plugins.knowtation.mcp_helpers import ( PathValidationError, clamp_tool_int, consolidate_receipt, create_hub_proposal, enrich_prime, link_index_to_json, list_vault_notes, projects_summary, read_note_at_ref, recent_memory_events, resolve_ref, validate_note_path, ) from muse.plugins.knowtation.note_metrics import make_snapshot_loader, note_dead, note_hotspots from muse.plugins.knowtation.search import hybrid_search from muse.cli.commands.commit import _attestation_path_for_commit from muse.cli.commands.symbol_log import SymbolEvent, _find_events_in_commit TOOL_NAMES: tuple[str, ...] = ( "knowtation/search", "knowtation/get-note", "knowtation/history", "knowtation/consolidate", "knowtation/impact", "knowtation/dead", "knowtation/prime", "knowtation/propose", "knowtation/attest", ) RESOURCE_URIS: tuple[str, ...] = ( "knowtation://vault", "knowtation://prime", "knowtation://graph", "knowtation://memory/recent", "knowtation://projects", "knowtation://hotspots", ) PROMPT_NAMES: tuple[str, ...] = ( "vault/daily-brief", "vault/search-synthesis", "vault/memory-session", "vault/knowledge-gaps", ) _VAULT_PATH_RE = re.compile(r"^knowtation://vault/(.+?)(?:@(.+))?$") def _tool_error(reason: str, message: str, detail: str | None = None) -> MCPApplicationError: return MCPApplicationError(reason, message, detail=detail) def _parse_path_error(exc: PathValidationError) -> MCPApplicationError: return _tool_error(exc.reason, str(exc)) class KnowtationNamespace: """Registers and dispatches the frozen knowtation MCP surface.""" def __init__(self, root: pathlib.Path) -> None: self.root = root # ------------------------------------------------------------------ # Catalogues # ------------------------------------------------------------------ def list_tools(self) -> list[dict[str, Any]]: return [ {"name": n, "description": f"Knowtation domain tool: {n}", "inputSchema": {"type": "object"}} for n in TOOL_NAMES ] def list_resources(self) -> list[dict[str, Any]]: static = [ {"uri": u, "name": u.replace("knowtation://", ""), "mimeType": "application/json"} for u in RESOURCE_URIS ] static.append( { "uri": "knowtation://vault/{path}", "name": "vault-note", "mimeType": "application/json", "description": "Template: knowtation://vault/{path} or @{ref}", } ) return static def list_prompts(self) -> list[dict[str, Any]]: return [{"name": n, "description": f"Knowtation vault prompt: {n}"} for n in PROMPT_NAMES] # ------------------------------------------------------------------ # Dispatch # ------------------------------------------------------------------ def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: handlers: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = { "knowtation/search": self._tool_search, "knowtation/get-note": self._tool_get_note, "knowtation/history": self._tool_history, "knowtation/consolidate": self._tool_consolidate, "knowtation/impact": self._tool_impact, "knowtation/dead": self._tool_dead, "knowtation/prime": self._tool_prime, "knowtation/propose": self._tool_propose, "knowtation/attest": self._tool_attest, } handler = handlers.get(name) if handler is None: raise _tool_error("METHOD_NOT_FOUND", f"unknown tool: {name}") return handler(arguments or {}) def read_resource(self, uri: str) -> dict[str, Any]: if uri == "knowtation://vault": return list_vault_notes(self.root) if uri == "knowtation://prime": return enrich_prime(self.root) if uri == "knowtation://graph": idx = build_link_index(self.root) return link_index_to_json(idx) if uri == "knowtation://memory/recent": return recent_memory_events(self.root) if uri == "knowtation://projects": return projects_summary(self.root) if uri == "knowtation://hotspots": head = resolve_ref(self.root, "HEAD") commits, _ = walk_commits_bfs(self.root, head.commit_id, 500) loader = make_snapshot_loader(self.root) return note_hotspots(commits, loader, top=10) m = _VAULT_PATH_RE.match(uri) if m: path_part = m.group(1) ref = m.group(2) or "HEAD" try: path = validate_note_path(path_part) except PathValidationError as exc: raise _parse_path_error(exc) from exc note = read_note_at_ref(self.root, path, ref) if not note.get("exists"): raise _tool_error("NOTE_NOT_FOUND", "note not found at ref") return note raise _tool_error("RESOURCE_NOT_FOUND", "unknown resource uri") def get_prompt(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: args = arguments or {} if name == "vault/daily-brief": return self._prompt_daily_brief(args) if name == "vault/search-synthesis": return self._prompt_search_synthesis(args) if name == "vault/memory-session": return self._prompt_memory_session(args) if name == "vault/knowledge-gaps": return self._prompt_knowledge_gaps(args) raise _tool_error("PROMPT_NOT_FOUND", f"unknown prompt: {name}") # ------------------------------------------------------------------ # Tools # ------------------------------------------------------------------ def _tool_search(self, args: dict[str, Any]) -> dict[str, Any]: query = args.get("query") if not isinstance(query, str) or not query.strip(): raise _tool_error("QUERY_INVALID", "query is required") top_k = clamp_tool_int(args.get("top_k"), 10, 1, 100, "top_k") mode = str(args.get("mode") or "hybrid") if mode not in ("hybrid", "lexical", "semantic"): raise _tool_error("QUERY_INVALID", "invalid mode") ref = str(args.get("ref") or "HEAD") try: resolve_ref(self.root, ref) except PathValidationError as exc: raise _parse_path_error(exc) from exc project = args.get("project") project_s = str(project) if project is not None else None return hybrid_search( self.root, query.strip(), top_k=top_k, mode=mode, project=project_s, ) def _tool_get_note(self, args: dict[str, Any]) -> dict[str, Any]: path = args.get("path") if not isinstance(path, str): raise _tool_error("PATH_INVALID", "path is required") ref = str(args.get("ref") or "HEAD") fmt = str(args.get("format") or "json") try: validate_note_path(path) resolve_ref(self.root, ref) note = read_note_at_ref(self.root, path, ref, fmt=fmt) if not note.get("exists"): raise _tool_error("NOTE_NOT_FOUND", "note not found at ref") return note except PathValidationError as exc: if exc.reason == "REF_NOT_FOUND": raise _parse_path_error(exc) from exc if exc.reason == "NOT_A_NOTE": raise _parse_path_error(exc) from exc raise _parse_path_error(exc) from exc def _tool_history(self, args: dict[str, Any]) -> dict[str, Any]: address = args.get("address") if not isinstance(address, str) or "::" not in address: raise _tool_error("ADDRESS_INVALID", "address must contain '::'") from_ref = str(args.get("from_ref") or "HEAD") max_commits = clamp_tool_int(args.get("max"), 500, 1, 100000, "max") try: start = resolve_ref(self.root, from_ref) except PathValidationError as exc: raise _parse_path_error(exc) from exc commits, truncated = walk_commits_bfs(self.root, start.commit_id, max_commits) current = address all_events: list[SymbolEvent] = [] for commit in commits: evs, current = _find_events_in_commit(commit, current) all_events.extend(evs) return { "address": address, "start_ref": from_ref, "total_commits_scanned": len(commits), "truncated": truncated, "events": [e.to_dict() for e in reversed(all_events)], } def _tool_consolidate(self, args: dict[str, Any]) -> dict[str, Any]: return consolidate_receipt(self.root, **args) def _tool_impact(self, args: dict[str, Any]) -> dict[str, Any]: path = args.get("path") if not isinstance(path, str): raise _tool_error("PATH_INVALID", "path is required") try: normalised = validate_note_path(path) except PathValidationError as exc: raise _parse_path_error(exc) from exc direction = str(args.get("direction") or "reverse") forward = direction == "forward" depth = args.get("depth", 0) try: depth_i = int(depth) except (TypeError, ValueError): depth_i = 0 if depth_i < 0: raise _tool_error("DEPTH_NEGATIVE", "depth must be non-negative") include_attachments = bool(args.get("include_attachments", True)) idx = build_link_index(self.root) if normalised not in idx.forward and normalised not in idx.backward: # note may exist without edges — still valid pass try: return note_impact( idx, normalised, forward=forward, depth=depth_i, include_attachments=include_attachments, ) except ValueError as exc: raise _tool_error("NOTE_NOT_FOUND", str(exc)) from exc def _tool_dead(self, args: dict[str, Any]) -> dict[str, Any]: from muse.plugins.registry import read_domain if read_domain(self.root) != "knowtation": raise _tool_error("NOT_KNOWTATION_REPO", "active domain is not knowtation") use_mtime = bool(args.get("use_mtime", True)) threshold = clamp_tool_int(args.get("mtime_days_threshold"), 180, 0, 100000, "mtime_days_threshold") idx = build_link_index(self.root) result = note_dead( idx, root=self.root, use_mtime=use_mtime, mtime_days_threshold=threshold, ) project = args.get("project") if project: prefix = str(project) filtered = [n for n in result.get("dead_notes", []) if n.get("path", "").startswith(prefix)] result = {**result, "dead_notes": filtered, "dead_count": len(filtered)} return result def _tool_prime(self, args: dict[str, Any]) -> dict[str, Any]: max_commits = clamp_tool_int(args.get("max_commits"), 100, 1, 100000, "max_commits") hot_note_count = clamp_tool_int(args.get("hot_note_count"), 10, 1, 100, "hot_note_count") return enrich_prime( self.root, max_commits=max_commits, hot_note_count=hot_note_count, ) def _tool_propose(self, args: dict[str, Any]) -> dict[str, Any]: path = args.get("path") title = args.get("title") if not isinstance(path, str): raise _tool_error("PATH_INVALID", "path is required") if not isinstance(title, str) or not title.strip(): raise _tool_error("VALIDATION_FAILED", "title is required") scope = str(args.get("scope") or "personal") if scope not in ("personal", "project", "org"): raise _tool_error("VALIDATION_FAILED", "invalid scope") try: return create_hub_proposal( self.root, path=path, title=title, proposed_body=args.get("proposed_body") if isinstance(args.get("proposed_body"), str) else None, proposed_frontmatter=args.get("proposed_frontmatter") if isinstance(args.get("proposed_frontmatter"), dict) else None, base_ref=str(args.get("base_ref") or "HEAD"), scope=scope, rationale=str(args.get("rationale")) if args.get("rationale") else None, ) except PathValidationError as exc: raise _parse_path_error(exc) from exc def _tool_attest(self, args: dict[str, Any]) -> dict[str, Any]: commit_id = args.get("commit_id") if not isinstance(commit_id, str) or not commit_id.strip(): raise _tool_error("COMMIT_NOT_FOUND", "commit_id is required") provider = get_attestation_provider("knowtation") if provider is None: raise _tool_error("PROVIDER_UNREGISTERED", "attestation provider not registered") commit = read_commit(self.root, commit_id.strip()) if commit is None: raise _tool_error("COMMIT_NOT_FOUND", "commit not found") manifest = get_commit_snapshot_manifest(self.root, commit.commit_id) or {} parent_manifest: dict[str, str] = {} if commit.parent_commit_id: parent_manifest = get_commit_snapshot_manifest(self.root, commit.parent_commit_id) or {} action = str(args.get("action") or "write") required_override = args.get("required") cfg_required = bool(getattr(provider, "config", {}).get("required", False)) required = bool(required_override) if required_override is not None else cfg_required commit_meta: dict[str, object] = { "action": action, "path": _attestation_path_for_commit(manifest, parent_manifest), "content_hash": commit.snapshot_id, "timestamp": commit.committed_at.isoformat(), } try: record = provider.compute_attestation(commit.snapshot_id, commit_meta) except AttestationRequiredError as exc: raise _tool_error("ATTESTATION_REQUIRED", str(exc)) from exc anchored = False anchor: dict[str, str] | None = None anchor_error: str | None = None try: receipt = provider.anchor(record) anchored = True anchor = { "tx_id": receipt.get("tx_id", ""), "anchor_url": receipt.get("anchor_url", ""), "provider_id": record.get("provider_id", "knowtation"), } except Exception as exc: anchor_error = sanitize_display(str(exc)) return { "record": record, "anchored": anchored, "anchor": anchor, "anchor_error": anchor_error, "provider_registered": True, } # ------------------------------------------------------------------ # Prompts # ------------------------------------------------------------------ def _prompt_daily_brief(self, args: dict[str, Any]) -> dict[str, Any]: since_hours = clamp_tool_int(args.get("since_hours"), 24, 1, 168, "since_hours") cutoff = datetime.now(tz=timezone.utc) - timedelta(hours=since_hours) head = resolve_ref(self.root, "HEAD") commits, _ = walk_commits_bfs(self.root, head.commit_id, 500) lines: list[str] = [] for commit in commits: if commit.committed_at < cutoff: continue lines.append( f"- {commit.commit_id[:8]} {commit.committed_at.isoformat()}: " f"{sanitize_display(commit.message)}" ) recent = recent_memory_events(self.root) text = ( f"Daily brief (last {since_hours}h):\n" + "\n".join(lines[:50]) + "\n\nRecent memory events:\n" + json.dumps(recent.get("events", [])[:10], indent=2) ) return {"messages": [{"role": "user", "content": {"type": "text", "text": text}}]} def _prompt_search_synthesis(self, args: dict[str, Any]) -> dict[str, Any]: query = args.get("query") if not isinstance(query, str) or not query.strip(): raise _tool_error("QUERY_INVALID", "query is required") top_k = clamp_tool_int(args.get("top_k"), 5, 1, 20, "top_k") results = hybrid_search(self.root, query.strip(), top_k=top_k, mode="hybrid") text = ( f"Synthesise an answer from these vault search results for query: {query!r}\n\n" + json.dumps(results.get("results", []), indent=2) + "\n\nProvide a concise synthesis citing only the listed note paths." ) return {"messages": [{"role": "user", "content": {"type": "text", "text": text}}]} def _prompt_memory_session(self, args: dict[str, Any]) -> dict[str, Any]: del args prime = enrich_prime(self.root) recent = recent_memory_events(self.root) text = ( "Resume this knowtation vault session using prime context and recent memory:\n\n" f"Prime:\n{json.dumps(prime, indent=2)}\n\n" f"Recent memory:\n{json.dumps(recent, indent=2)}" ) return {"messages": [{"role": "user", "content": {"type": "text", "text": text}}]} def _prompt_knowledge_gaps(self, args: dict[str, Any]) -> dict[str, Any]: del args idx = build_link_index(self.root) dead = note_dead(idx, root=self.root, use_mtime=True) entities = {k: sorted(v) for k, v in idx.entity_index.items()} text = ( "Identify knowledge gaps by cross-referencing dead notes and entity coverage:\n\n" f"Dead notes:\n{json.dumps(dead.get('dead_notes', []), indent=2)}\n\n" f"Entity index:\n{json.dumps(entities, indent=2)}" ) return {"messages": [{"role": "user", "content": {"type": "text", "text": text}}]} __all__ = [ "KnowtationNamespace", "PROMPT_NAMES", "RESOURCE_URIS", "TOOL_NAMES", ]