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>
165 lines
6.1 KiB
Python
165 lines
6.1 KiB
Python
"""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}'")
|