0bc734c020
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>
240 lines
8.3 KiB
Python
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())
|