Skip to content

Commit 4f3aa1e

Browse files
author
SIN-Agent
committed
feat: Graphify-Integration — Knowledge-Graph-gestützte Codebase-Analyse
- Neue MCP-Tools: graphify_query, graphify_update, graphify_explain, graphify_path - graphify_service.py: Wrapper für graphify CLI (Query, Update, Explain, Path) - project_overview zeigt automatisch graphify-Stats wenn Graph existiert - Schemas + Protocol registriert: Argument-Validierung, Aliases, Instructions
1 parent 60db1e0 commit 4f3aa1e

4 files changed

Lines changed: 362 additions & 1 deletion

File tree

src/simone_mcp/core.py

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,8 +279,130 @@
279279
},
280280
"execution": {"taskSupport": "forbidden"},
281281
},
282+
{
283+
"name": "sin_simone_mcp_graphify_query",
284+
"title": "Graphify Query",
285+
"description": "Ask a question about a codebase using its knowledge graph (graphify BFS traversal).",
286+
"inputSchema": {
287+
"$schema": _JSON_SCHEMA_2020_12,
288+
"type": "object",
289+
"properties": {
290+
"query": {"type": "string", "description": "Natural language question about the codebase"},
291+
"root": {"type": "string", "description": "Workspace root path"},
292+
"budget": {"type": "integer", "description": "Max output tokens (default 2000)"},
293+
},
294+
"required": ["query", "root"],
295+
},
296+
"outputSchema": {
297+
"$schema": _JSON_SCHEMA_2020_12,
298+
"type": "object",
299+
"properties": {
300+
"ok": {"type": "boolean"},
301+
"output": {"type": "string"},
302+
"error": {"type": "string"},
303+
},
304+
},
305+
"annotations": {
306+
"readOnlyHint": True,
307+
"destructiveHint": False,
308+
"idempotentHint": True,
309+
"openWorldHint": True,
310+
},
311+
"execution": {"taskSupport": "forbidden"},
312+
},
313+
{
314+
"name": "sin_simone_mcp_graphify_update",
315+
"title": "Graphify Update",
316+
"description": "Re-extract code files and update the knowledge graph for a workspace.",
317+
"inputSchema": {
318+
"$schema": _JSON_SCHEMA_2020_12,
319+
"type": "object",
320+
"properties": {
321+
"root": {"type": "string", "description": "Workspace root path"},
322+
},
323+
"required": ["root"],
324+
},
325+
"outputSchema": {
326+
"$schema": _JSON_SCHEMA_2020_12,
327+
"type": "object",
328+
"properties": {
329+
"ok": {"type": "boolean"},
330+
"output": {"type": "string"},
331+
"error": {"type": "string"},
332+
},
333+
},
334+
"annotations": {
335+
"readOnlyHint": False,
336+
"destructiveHint": True,
337+
"idempotentHint": False,
338+
"openWorldHint": True,
339+
},
340+
"execution": {"taskSupport": "forbidden"},
341+
},
342+
{
343+
"name": "sin_simone_mcp_graphify_explain",
344+
"title": "Graphify Explain",
345+
"description": "Plain-language explanation of a graph node and its neighbors in a codebase.",
346+
"inputSchema": {
347+
"$schema": _JSON_SCHEMA_2020_12,
348+
"type": "object",
349+
"properties": {
350+
"node": {"type": "string", "description": "Node name or label to explain"},
351+
"root": {"type": "string", "description": "Workspace root path"},
352+
},
353+
"required": ["node", "root"],
354+
},
355+
"outputSchema": {
356+
"$schema": _JSON_SCHEMA_2020_12,
357+
"type": "object",
358+
"properties": {
359+
"ok": {"type": "boolean"},
360+
"output": {"type": "string"},
361+
"error": {"type": "string"},
362+
},
363+
},
364+
"annotations": {
365+
"readOnlyHint": True,
366+
"destructiveHint": False,
367+
"idempotentHint": True,
368+
"openWorldHint": True,
369+
},
370+
"execution": {"taskSupport": "forbidden"},
371+
},
372+
{
373+
"name": "sin_simone_mcp_graphify_path",
374+
"title": "Graphify Path",
375+
"description": "Find the shortest path between two nodes in the codebase knowledge graph.",
376+
"inputSchema": {
377+
"$schema": _JSON_SCHEMA_2020_12,
378+
"type": "object",
379+
"properties": {
380+
"source": {"type": "string", "description": "Source node name"},
381+
"target": {"type": "string", "description": "Target node name"},
382+
"root": {"type": "string", "description": "Workspace root path"},
383+
},
384+
"required": ["source", "target", "root"],
385+
},
386+
"outputSchema": {
387+
"$schema": _JSON_SCHEMA_2020_12,
388+
"type": "object",
389+
"properties": {
390+
"ok": {"type": "boolean"},
391+
"output": {"type": "string"},
392+
"error": {"type": "string"},
393+
},
394+
},
395+
"annotations": {
396+
"readOnlyHint": True,
397+
"destructiveHint": False,
398+
"idempotentHint": True,
399+
"openWorldHint": True,
400+
},
401+
"execution": {"taskSupport": "forbidden"},
402+
},
282403
]
283404
CAPABILITIES = [tool["name"] for tool in TOOL_DEFINITIONS] + [
405+
"graphify",
284406
"memory.hybrid",
285407
"transport.streamable_http",
286408
"auth.oauth2.1",
@@ -316,6 +438,10 @@ def build_agent_card(base_url: str) -> dict[str, Any]:
316438
{"id": "sin_simone_mcp_memory_query", "name": "Memory Query"},
317439
{"id": "sin_simone_mcp_find_references", "name": "Find References"},
318440
{"id": "sin_simone_mcp_project_overview", "name": "Project Overview"},
441+
{"id": "sin_simone_mcp_graphify_query", "name": "Graphify Query"},
442+
{"id": "sin_simone_mcp_graphify_update", "name": "Graphify Update"},
443+
{"id": "sin_simone_mcp_graphify_explain", "name": "Graphify Explain"},
444+
{"id": "sin_simone_mcp_graphify_path", "name": "Graphify Path"},
319445
],
320446
"defaultInputModes": ["application/json", "text/plain"],
321447
"defaultOutputModes": ["application/json", "text/plain"],
@@ -765,7 +891,7 @@ def get_project_overview(payload: dict[str, Any]) -> dict[str, Any]:
765891
suffix = path.suffix or "[none]"
766892
counts[suffix] = counts.get(suffix, 0) + 1
767893
top_extensions = sorted(counts.items(), key=lambda item: (-item[1], item[0]))[:10]
768-
return {
894+
result: dict[str, Any] = {
769895
"ok": True,
770896
"root": str(root),
771897
"fileCount": file_count,
@@ -774,15 +900,52 @@ def get_project_overview(payload: dict[str, Any]) -> dict[str, Any]:
774900
for extension, count in top_extensions
775901
],
776902
}
903+
graph_summary = _graphify_summary_impl(str(root))
904+
if graph_summary.get("has_graph"):
905+
result["graphify"] = graph_summary
906+
return result
777907

778908

779909
from .hybrid_memory import query_hybrid_memory as _query_hybrid_memory_impl # noqa: E402
910+
from .graphify_service import ( # noqa: E402
911+
graphify_query as _graphify_query_impl,
912+
graphify_update as _graphify_update_impl,
913+
graphify_explain as _graphify_explain_impl,
914+
graphify_path as _graphify_path_impl,
915+
graphify_summary as _graphify_summary_impl,
916+
graphify_available as _graphify_available_impl,
917+
)
780918

781919

782920
def query_hybrid_memory(payload: dict[str, Any]) -> dict[str, Any]:
783921
return _query_hybrid_memory_impl(payload)
784922

785923

924+
def _graphify_query(payload: dict[str, Any]) -> dict[str, Any]:
925+
question = str(payload.get("query") or "").strip()
926+
root = str(payload.get("root") or _workspace_root(None))
927+
budget = int(payload.get("budget", 2000))
928+
return _graphify_query_impl(question, root, budget=budget)
929+
930+
931+
def _graphify_update(payload: dict[str, Any]) -> dict[str, Any]:
932+
root = str(payload.get("root") or _workspace_root(None))
933+
return _graphify_update_impl(root)
934+
935+
936+
def _graphify_explain(payload: dict[str, Any]) -> dict[str, Any]:
937+
node = str(payload.get("node") or "").strip()
938+
root = str(payload.get("root") or _workspace_root(None))
939+
return _graphify_explain_impl(node, root)
940+
941+
942+
def _graphify_path(payload: dict[str, Any]) -> dict[str, Any]:
943+
source = str(payload.get("source") or "").strip()
944+
target = str(payload.get("target") or "").strip()
945+
root = str(payload.get("root") or _workspace_root(None))
946+
return _graphify_path_impl(source, target, root)
947+
948+
786949
_SYNC_ACTIONS = frozenset({
787950
"sin_simone_mcp_symbol_search",
788951
"sin_simone_mcp_find_references",
@@ -791,6 +954,10 @@ def query_hybrid_memory(payload: dict[str, Any]) -> dict[str, Any]:
791954
"sin_simone_mcp_project_overview",
792955
"sin_simone_mcp_health",
793956
"sin_simone_mcp_insert_after",
957+
"sin_simone_mcp_graphify_query",
958+
"sin_simone_mcp_graphify_update",
959+
"sin_simone_mcp_graphify_explain",
960+
"sin_simone_mcp_graphify_path",
794961
})
795962

796963

@@ -809,6 +976,10 @@ async def execute_simone_action(payload: dict[str, Any]) -> dict[str, Any]:
809976
"sin_simone_mcp_memory_query",
810977
"sin_simone_mcp_find_references",
811978
"sin_simone_mcp_project_overview",
979+
"sin_simone_mcp_graphify_query",
980+
"sin_simone_mcp_graphify_update",
981+
"sin_simone_mcp_graphify_explain",
982+
"sin_simone_mcp_graphify_path",
812983
],
813984
}
814985
if action in {"simone.mcp.health", "sin.simone.mcp.health", "sin_simone_mcp_health"}:
@@ -840,6 +1011,14 @@ def _execute_sync_action(action: str, payload: dict[str, Any]) -> dict[str, Any]
8401011
return query_hybrid_memory(payload)
8411012
if action == "sin_simone_mcp_insert_after":
8421013
return insert_after_symbol(payload)
1014+
if action == "sin_simone_mcp_graphify_query":
1015+
return _graphify_query(payload)
1016+
if action == "sin_simone_mcp_graphify_update":
1017+
return _graphify_update(payload)
1018+
if action == "sin_simone_mcp_graphify_explain":
1019+
return _graphify_explain(payload)
1020+
if action == "sin_simone_mcp_graphify_path":
1021+
return _graphify_path(payload)
8431022
return {"ok": False, "error": "unknown_action", "action": action}
8441023

8451024

src/simone_mcp/graphify_service.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import logging
5+
import os
6+
import subprocess
7+
import tempfile
8+
from pathlib import Path
9+
from typing import Any
10+
11+
logger = logging.getLogger(__name__)
12+
13+
_GRAPHIFY_BIN: str | None = None
14+
15+
16+
def _find_graphify() -> str | None:
17+
global _GRAPHIFY_BIN
18+
if _GRAPHIFY_BIN is not None:
19+
return _GRAPHIFY_BIN
20+
candidates = [
21+
"/opt/homebrew/bin/graphify",
22+
"/usr/local/bin/graphify",
23+
os.path.expanduser("~/.local/bin/graphify"),
24+
]
25+
for c in candidates:
26+
if os.path.isfile(c) and os.access(c, os.X_OK):
27+
_GRAPHIFY_BIN = c
28+
return c
29+
which = os.popen("which graphify 2>/dev/null").read().strip()
30+
if which and os.path.isfile(which):
31+
_GRAPHIFY_BIN = which
32+
return which
33+
_GRAPHIFY_BIN = ""
34+
return None
35+
36+
37+
def _run_graphify(args: list[str], cwd: str | None = None) -> dict[str, Any]:
38+
gpath = _find_graphify()
39+
if not gpath:
40+
return {"ok": False, "error": "graphify not installed"}
41+
try:
42+
result = subprocess.run(
43+
[gpath] + args,
44+
cwd=cwd,
45+
capture_output=True,
46+
text=True,
47+
timeout=120,
48+
)
49+
output = result.stdout.strip()
50+
stderr = result.stderr.strip()
51+
if result.returncode != 0:
52+
error_msg = stderr or output or f"exit code {result.returncode}"
53+
return {"ok": False, "error": error_msg}
54+
return {"ok": True, "output": output, "stderr": stderr}
55+
except subprocess.TimeoutExpired:
56+
return {"ok": False, "error": "graphify command timed out (120s)"}
57+
except FileNotFoundError:
58+
return {"ok": False, "error": "graphify not found"}
59+
except Exception as e:
60+
return {"ok": False, "error": str(e)}
61+
62+
63+
def _graph_path(root: str) -> str:
64+
return os.path.join(root, "graphify-out", "graph.json")
65+
66+
67+
def _has_graph(root: str) -> bool:
68+
return os.path.isfile(_graph_path(root))
69+
70+
71+
def graphify_update(root: str) -> dict[str, Any]:
72+
"""Run `graphify update <root>` to regenerate the knowledge graph."""
73+
root_path = os.path.abspath(os.path.expanduser(root))
74+
if not os.path.isdir(root_path):
75+
return {"ok": False, "error": f"directory not found: {root_path}"}
76+
return _run_graphify(["update", root_path], cwd=root_path)
77+
78+
79+
def graphify_query(question: str, root: str, budget: int = 2000, dfs: bool = False) -> dict[str, Any]:
80+
"""Run `graphify query <question>` on a repo's graph."""
81+
root_path = os.path.abspath(os.path.expanduser(root))
82+
if not _has_graph(root_path):
83+
return {"ok": False, "error": f"No graph found at {_graph_path(root_path)}. Run graphify_update first."}
84+
graph_arg = _graph_path(root_path)
85+
86+
args = ["query", question, "--graph", graph_arg, "--budget", str(budget)]
87+
if dfs:
88+
args.append("--dfs")
89+
90+
result = _run_graphify(args, cwd=root_path)
91+
return result
92+
93+
94+
def graphify_explain(node: str, root: str) -> dict[str, Any]:
95+
"""Run `graphify explain <node>` to understand a node."""
96+
root_path = os.path.abspath(os.path.expanduser(root))
97+
if not _has_graph(root_path):
98+
return {"ok": False, "error": f"No graph found at {_graph_path(root_path)}"}
99+
graph_arg = _graph_path(root_path)
100+
return _run_graphify(["explain", node, "--graph", graph_arg], cwd=root_path)
101+
102+
103+
def graphify_path(source: str, target: str, root: str) -> dict[str, Any]:
104+
"""Run `graphify path <source> <target>` to find shortest path."""
105+
root_path = os.path.abspath(os.path.expanduser(root))
106+
if not _has_graph(root_path):
107+
return {"ok": False, "error": f"No graph found at {_graph_path(root_path)}"}
108+
graph_arg = _graph_path(root_path)
109+
return _run_graphify(["path", source, target, "--graph", graph_arg], cwd=root_path)
110+
111+
112+
def graphify_install(platform: str = "opencode") -> dict[str, Any]:
113+
"""Run `graphify install --platform <platform>`."""
114+
return _run_graphify(["install", "--platform", platform])
115+
116+
117+
def graphify_summary(root: str) -> dict[str, Any]:
118+
"""Read graph.json and return summary stats (no CLI call)."""
119+
root_path = os.path.abspath(os.path.expanduser(root))
120+
gp = _graph_path(root_path)
121+
if not os.path.isfile(gp):
122+
return {"ok": False, "error": f"No graph found at {gp}", "has_graph": False}
123+
try:
124+
with open(gp) as f:
125+
graph = json.load(f)
126+
nodes = graph.get("nodes", [])
127+
edges = graph.get("edges", [])
128+
communities = graph.get("communities", [])
129+
god_nodes = sorted(
130+
nodes,
131+
key=lambda n: len(n.get("neighbors", [])),
132+
reverse=True,
133+
)[:10]
134+
return {
135+
"ok": True,
136+
"has_graph": True,
137+
"node_count": len(nodes),
138+
"edge_count": len(edges),
139+
"community_count": len(communities),
140+
"top_nodes": [
141+
{"name": n.get("label", n.get("id", "?")), "edges": len(n.get("neighbors", []))}
142+
for n in god_nodes
143+
],
144+
}
145+
except Exception as e:
146+
return {"ok": False, "error": str(e), "has_graph": False}
147+
148+
149+
def graphify_available() -> bool:
150+
return _find_graphify() is not None

0 commit comments

Comments
 (0)