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:
@@ -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}'")
|
||||
Reference in New Issue
Block a user