Files
cleveragents-core/tools/mcp_graphify_server.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
Applies `ruff format` to the accumulated formatting debt on this branch.
Formatting-only — no behavioral changes. Required for CI/lint's format
gate (`nox -s format -- --check`), which the branch was failing on 288
tracked files that drifted from ruff's canonical style.

In-progress WIP files are intentionally excluded so this commit stays a
clean formatting-only diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:09:17 -04:00

240 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""MCP server wrapping the local graphify code-knowledge-graph CLI.
Spawned by OpenCode via ``.opencode/opencode.json``'s ``mcp`` block
and exposed to agents that have ``mcp__graphify*: allow`` in their
permission block.
Replaces the v2 ``.opencode/plugins/graphify.js`` reminder plugin
(which broke deny-by-default bash allowlists by prepending
``echo "..." && `` to every command — see the plugin file's header
comment for the post-mortem) and the per-agent boilerplate that
allowed ``"graphify *"`` and granted ``external_directory`` read
access to ``graphify-out/``. Agents now need a single permission
line — ``"mcp__graphify*": allow`` — to use the graph.
Tools exposed
-------------
``report(head_lines: int = 200)``
Return the first ``head_lines`` lines of ``GRAPH_REPORT.md`` (god
nodes + community structure + cross-module summary). The default
matches the historical ``cat … | head -200`` pattern that the
task-implementor used to run on every session.
``query(question: str, budget: int = 2000)``
BFS traversal of the graph for a natural-language question.
Returns ``graphify``'s text output verbatim — typically node
citations with file:line references, ranked by graph distance.
``path(a: str, b: str)``
Shortest path between two concept nodes (e.g. between
``SessionContext`` and ``post_session_action``).
``explain(concept: str)``
Neighborhood summary for a single node.
State
-----
Stateless. Every call re-reads ``graphify-out/graph.json`` — which
``graphify update`` (run by the user / git commit hook) rewrites on
code changes. No caching here; the underlying CLI is fast and the
graph is small.
Configuration
-------------
``GRAPHIFY_OUT_DIR`` (default
``/home/drew/repos/cleveragents-core/graphify-out``)
Where ``graph.json`` and ``GRAPH_REPORT.md`` live.
``GRAPHIFY_BIN`` (default ``graphify`` on PATH)
The graphify CLI binary. Override if multiple installs exist.
``GRAPHIFY_TIMEOUT_S`` (default ``30``)
Per-call subprocess timeout in seconds. ``query`` and ``path``
can take a few seconds on larger graphs; the default leaves
headroom without letting a hung CLI stall a worker session.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
from mcp.server.fastmcp import FastMCP
sys.path.insert(0, str(Path(__file__).parent))
from _mcp_common import make_main # noqa: E402
# ─── Configuration ─────────────────────────────────────────────────
GRAPHIFY_OUT = Path(
os.environ.get(
"GRAPHIFY_OUT_DIR",
"/home/drew/repos/cleveragents-core/graphify-out",
)
)
GRAPHIFY_BIN = os.environ.get("GRAPHIFY_BIN", "graphify")
GRAPHIFY_TIMEOUT_S = int(os.environ.get("GRAPHIFY_TIMEOUT_S", "30"))
GRAPH_JSON = GRAPHIFY_OUT / "graph.json"
GRAPH_REPORT = GRAPHIFY_OUT / "GRAPH_REPORT.md"
# ─── Server ────────────────────────────────────────────────────────
server = FastMCP("graphify")
def _check_environment() -> str | None:
"""Return a human-readable error string if the environment is not
usable, or ``None`` if everything is in place. Called at the top
of every tool so the agent gets a clear, actionable message
instead of an opaque subprocess failure.
"""
if not GRAPHIFY_OUT.is_dir():
return (
f"graphify-out directory not found at {GRAPHIFY_OUT}. "
"Set GRAPHIFY_OUT_DIR or run `graphify update <repo>` to generate it."
)
if not GRAPH_JSON.is_file():
return (
f"graph.json not found at {GRAPH_JSON}. "
"Run `graphify update <repo>` to (re-)generate the graph."
)
if shutil.which(GRAPHIFY_BIN) is None and not Path(GRAPHIFY_BIN).is_file():
return (
f"graphify CLI not found (looked for {GRAPHIFY_BIN!r}). "
"Set GRAPHIFY_BIN or install with `uv tool install graphifyy`."
)
return None
def _run_graphify(args: list[str]) -> str:
"""Invoke the graphify CLI and return its stdout. On non-zero
exit or timeout, returns a formatted error string the agent can
read directly — never raises through MCP, so a single misuse
doesn't crash the server."""
cmd = [GRAPHIFY_BIN, *args]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=GRAPHIFY_TIMEOUT_S,
check=False,
)
except subprocess.TimeoutExpired:
return (
f"ERROR: graphify timed out after {GRAPHIFY_TIMEOUT_S}s. "
f"Command: {' '.join(cmd)!r}"
)
except FileNotFoundError:
return (
f"ERROR: graphify binary not found at {GRAPHIFY_BIN!r}. "
"Set GRAPHIFY_BIN env var or install graphify."
)
if result.returncode != 0:
return (
f"ERROR: graphify exited {result.returncode}. "
f"stderr: {result.stderr.strip()[:500]}"
)
return result.stdout
@server.tool()
def report(head_lines: int = 200) -> str:
"""First N lines of GRAPH_REPORT.md (god nodes, communities,
surprising cross-module connections). Call this once per session
to orient before grep/find — the report tells you the shape of
the codebase. Default 200 lines matches the historical
``cat … | head -200`` pattern.
"""
env_err = _check_environment()
if env_err is not None:
return f"ERROR: {env_err}"
if not GRAPH_REPORT.is_file():
return (
f"ERROR: GRAPH_REPORT.md not found at {GRAPH_REPORT}. "
"Run `graphify update <repo>` to regenerate it."
)
try:
with GRAPH_REPORT.open("r", encoding="utf-8") as fh:
lines = [next(fh) for _ in range(max(1, head_lines))]
except StopIteration:
# File is shorter than head_lines — fall through with what we have.
pass
except OSError as exc:
return f"ERROR: reading {GRAPH_REPORT}: {exc}"
return "".join(lines)
@server.tool()
def query(question: str, budget: int = 2000) -> str:
"""BFS traversal of the graph for a natural-language question.
Returns ranked node citations with file:line references, capped
at ``budget`` tokens. Use this **instead of** ``grep -r`` for
cross-module questions ("how does X relate to Y", "what depends
on Z", "what's downstream of file F"). Default budget 2000 is
the same default the CLI uses.
"""
env_err = _check_environment()
if env_err is not None:
return f"ERROR: {env_err}"
if not question.strip():
return "ERROR: question must be non-empty"
return _run_graphify(
[
"query",
question,
"--graph",
str(GRAPH_JSON),
"--budget",
str(max(1, int(budget))),
]
)
@server.tool()
def path(a: str, b: str) -> str:
"""Shortest path between two concept nodes in the graph. Use
when you need to understand the dependency chain between two
specific things (e.g. ``path("SessionContext",
"post_session_action")``).
"""
env_err = _check_environment()
if env_err is not None:
return f"ERROR: {env_err}"
if not a.strip() or not b.strip():
return "ERROR: both `a` and `b` must be non-empty"
return _run_graphify(["path", a, b, "--graph", str(GRAPH_JSON)])
@server.tool()
def explain(concept: str) -> str:
"""Plain-language neighborhood summary for a single concept node
(its direct neighbors, types of edges, file:line citations). Use
when you've located a node and want to understand what's around
it before reading source.
"""
env_err = _check_environment()
if env_err is not None:
return f"ERROR: {env_err}"
if not concept.strip():
return "ERROR: concept must be non-empty"
return _run_graphify(["explain", concept, "--graph", str(GRAPH_JSON)])
# No startup fail-fast on missing graphify-out: the operator may
# spawn OpenCode before running the first ``graphify update``, and
# :func:`_check_environment` gives a clearer in-band error than a
# startup crash.
main = make_main(server, "mcp_graphify_server")
if __name__ == "__main__":
sys.exit(main())