context-mcp-server 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -7
- package/codegraph/__pycache__/affected.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/cache.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/callflow_html.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/export.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/report.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/server.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/tree_html.cpython-313.pyc +0 -0
- package/codegraph/affected.py +233 -0
- package/codegraph/cache.py +51 -2
- package/codegraph/callflow_html.py +273 -0
- package/codegraph/export.py +544 -0
- package/codegraph/extractors/__pycache__/ast_extractor.cpython-313.pyc +0 -0
- package/codegraph/extractors/ast_extractor.py +143 -16
- package/codegraph/graph/__pycache__/builder.cpython-313.pyc +0 -0
- package/codegraph/graph/__pycache__/clustering.cpython-313.pyc +0 -0
- package/codegraph/graph/__pycache__/query.cpython-313.pyc +0 -0
- package/codegraph/graph/__pycache__/symbol_resolution.cpython-313.pyc +0 -0
- package/codegraph/graph/builder.py +10 -0
- package/codegraph/graph/clustering.py +247 -10
- package/codegraph/graph/query.py +56 -0
- package/codegraph/graph/symbol_resolution.py +112 -0
- package/codegraph/report.py +53 -0
- package/codegraph/server.py +99 -10
- package/codegraph/tree_html.py +241 -0
- package/package.json +2 -2
- package/pyproject.toml +4 -1
- package/src/cli.js +303 -90
- package/src/server.js +7 -1
- package/src/templates/antigravity/GEMINI.md +96 -0
- package/src/templates/antigravity/hooks/context-mcp-post-tool-use.js +62 -0
- package/src/templates/antigravity/workflows/context-resume.md +20 -0
- package/src/templates/antigravity/workflows/graph-build.md +23 -0
- package/src/templates/antigravity/workflows/save-context.md +29 -0
- package/src/templates/{CLAUDE.md → claude/CLAUDE.md} +3 -0
- package/src/templates/claude/commands/graph-build.md +9 -0
- package/src/templates/claude/commands/save-context.md +19 -0
- package/src/templates/claude/hooks/context-mcp-post-tool-use.js +59 -0
- package/src/templates/claude/hooks/context-mcp-pre-tool-use.js +26 -0
- package/src/templates/{skills → claude/skills}/SKILL.md +3 -0
- package/src/templates/codex/AGENTS.md +107 -0
- package/src/templates/codex/hooks/context-mcp-post-tool-use.js +46 -0
- package/src/templates/codex/hooks/context-mcp-pre-tool-use.js +23 -0
- package/src/templates/codex/prompts/context-resume.md +15 -0
- package/src/templates/codex/prompts/graph-build.md +14 -0
- package/src/templates/codex/prompts/save-context.md +24 -0
- package/src/templates/cursor/commands/context-resume.md +7 -0
- package/src/templates/cursor/commands/graph-build.md +7 -0
- package/src/templates/cursor/commands/save-context.md +12 -0
- package/src/templates/{cursor-rules.mdc → cursor/cursor-rules.mdc} +13 -3
- package/src/templates/cursor/hooks/context-mcp-post-tool-use.js +55 -0
- package/src/templates/{GEMINI.md → gemini/GEMINI.md} +3 -1
- package/src/templates/gemini/commands/context-resume.toml +15 -0
- package/src/templates/gemini/commands/graph-build.toml +14 -0
- package/src/templates/gemini/commands/save-context.toml +24 -0
- package/src/templates/gemini/hooks/context-mcp-after-tool.js +59 -0
- package/src/templates/gemini/hooks/context-mcp-before-tool.js +26 -0
- package/src/templates/vscode/commands/context-resume.prompt.md +15 -0
- package/src/templates/vscode/commands/graph-build.prompt.md +10 -0
- package/src/templates/vscode/commands/save-context.prompt.md +16 -0
- package/src/templates/vscode/hooks/context-mcp-post-tool-use.js +58 -0
- package/src/templates/windsurf/hooks/context-mcp-post-run-command.js +57 -0
- package/src/templates/{windsurf-rules.md → windsurf/windsurf-rules.md} +6 -4
- package/src/templates/windsurf/workflows/context-resume.md +11 -0
- package/src/templates/windsurf/workflows/graph-build.md +11 -0
- package/src/templates/windsurf/workflows/save-context.md +18 -0
- package/src/tools/codegraph.js +37 -0
- package/uv.lock +1100 -3
- package/src/templates/AGENTS.md +0 -90
- package/src/templates/commands/graph-build.md +0 -5
- package/src/templates/commands/save-context.md +0 -12
- /package/src/templates/{commands → claude/commands}/context-resume.md +0 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
graph/symbol_resolution.py — cross-file call edge resolution.
|
|
3
|
+
|
|
4
|
+
Resolves unresolved callee names in node['calls'] to actual node IDs,
|
|
5
|
+
creating 'calls' edges with INFERRED confidence where a unique match exists.
|
|
6
|
+
|
|
7
|
+
Our nodes use 'name'/'file' (graphify uses 'label'/'source_file').
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_name_index(all_nodes: list[dict]) -> dict[str, list[str]]:
|
|
15
|
+
"""Map symbol name → [node_ids] for function/class/struct nodes."""
|
|
16
|
+
index: dict[str, list[str]] = {}
|
|
17
|
+
callable_types = {"function", "method", "class", "struct", "interface", "trait"}
|
|
18
|
+
for node in all_nodes:
|
|
19
|
+
if node.get("type") in callable_types:
|
|
20
|
+
name = node.get("name", "")
|
|
21
|
+
nid = node.get("id", "")
|
|
22
|
+
if name and nid:
|
|
23
|
+
index.setdefault(name, []).append(nid)
|
|
24
|
+
return index
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_module_name_index(all_nodes: list[dict]) -> dict[tuple[str, str], list[str]]:
|
|
28
|
+
"""Map (module_stem, symbol_name) → [node_ids] for scoped resolution."""
|
|
29
|
+
index: dict[tuple[str, str], list[str]] = {}
|
|
30
|
+
callable_types = {"function", "method", "class", "struct", "interface", "trait"}
|
|
31
|
+
for node in all_nodes:
|
|
32
|
+
if node.get("type") in callable_types:
|
|
33
|
+
name = node.get("name", "")
|
|
34
|
+
nid = node.get("id", "")
|
|
35
|
+
file_path = node.get("file", "")
|
|
36
|
+
if name and nid and file_path:
|
|
37
|
+
stem = Path(file_path.replace("\\", "/")).stem
|
|
38
|
+
index.setdefault((stem, name), []).append(nid)
|
|
39
|
+
return index
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_calls(
|
|
43
|
+
all_nodes: list[dict],
|
|
44
|
+
existing_edge_keys: set[tuple[str, str]],
|
|
45
|
+
) -> list[dict]:
|
|
46
|
+
"""Resolve node['calls'] lists to graph edges.
|
|
47
|
+
|
|
48
|
+
For each node that declares `calls: ["FunctionName", ...]`, look up the
|
|
49
|
+
name in the global index. Only emit an edge when the name resolves
|
|
50
|
+
uniquely (avoids false positives for common names like 'get' or 'init').
|
|
51
|
+
|
|
52
|
+
Returns a list of new edge dicts {from, to, relation, confidence}.
|
|
53
|
+
"""
|
|
54
|
+
name_index = build_name_index(all_nodes)
|
|
55
|
+
module_index = build_module_name_index(all_nodes)
|
|
56
|
+
|
|
57
|
+
new_edges: list[dict] = []
|
|
58
|
+
|
|
59
|
+
for node in all_nodes:
|
|
60
|
+
caller_id = node.get("id", "")
|
|
61
|
+
if not caller_id:
|
|
62
|
+
continue
|
|
63
|
+
calls = node.get("calls", [])
|
|
64
|
+
if not calls:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
caller_file = node.get("file", "")
|
|
68
|
+
# Try module-scoped resolution first (same file prefix → higher confidence)
|
|
69
|
+
imports = node.get("imports", [])
|
|
70
|
+
imported_stems = set()
|
|
71
|
+
for imp in imports:
|
|
72
|
+
clean = imp.lstrip(".").replace("\\", "/")
|
|
73
|
+
stem = Path(clean).stem
|
|
74
|
+
if stem:
|
|
75
|
+
imported_stems.add(stem)
|
|
76
|
+
|
|
77
|
+
for callee_name in calls:
|
|
78
|
+
if not isinstance(callee_name, str) or not callee_name:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
target_ids: list[str] = []
|
|
82
|
+
|
|
83
|
+
# 1. Try (imported_module_stem, callee_name) for import-guided resolution
|
|
84
|
+
for stem in imported_stems:
|
|
85
|
+
candidates = module_index.get((stem, callee_name), [])
|
|
86
|
+
target_ids.extend(candidates)
|
|
87
|
+
|
|
88
|
+
# 2. Fall back to global unique name match
|
|
89
|
+
if not target_ids:
|
|
90
|
+
target_ids = name_index.get(callee_name, [])
|
|
91
|
+
|
|
92
|
+
# Only emit when unambiguous
|
|
93
|
+
if len(target_ids) != 1:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
target_id = target_ids[0]
|
|
97
|
+
if target_id == caller_id:
|
|
98
|
+
continue # skip self-calls
|
|
99
|
+
key = (caller_id, target_id)
|
|
100
|
+
if key in existing_edge_keys:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
existing_edge_keys.add(key)
|
|
104
|
+
confidence = "EXTRACTED" if imported_stems else "INFERRED"
|
|
105
|
+
new_edges.append({
|
|
106
|
+
"from": caller_id,
|
|
107
|
+
"to": target_id,
|
|
108
|
+
"relation": "calls",
|
|
109
|
+
"confidence": confidence,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
return new_edges
|
package/codegraph/report.py
CHANGED
|
@@ -84,10 +84,63 @@ def _build_report(g: dict) -> str:
|
|
|
84
84
|
lines += ["## Confidence Breakdown", ""]
|
|
85
85
|
for label, count in sorted(conf_counts.items()):
|
|
86
86
|
lines.append(f"- **{label}**: {count} edges")
|
|
87
|
+
lines.append("")
|
|
88
|
+
|
|
89
|
+
# Knowledge gaps
|
|
90
|
+
lines += _knowledge_gaps_section(nodes, edges, communities, node_map)
|
|
87
91
|
|
|
88
92
|
return "\n".join(lines)
|
|
89
93
|
|
|
90
94
|
|
|
95
|
+
def _knowledge_gaps_section(
|
|
96
|
+
nodes: list, edges: list, communities: list, node_map: dict
|
|
97
|
+
) -> list[str]:
|
|
98
|
+
"""Identify under-connected areas: isolated nodes, thin communities, ambiguous edges."""
|
|
99
|
+
lines = ["## Knowledge Gaps", ""]
|
|
100
|
+
|
|
101
|
+
# Isolated nodes (no edges at all)
|
|
102
|
+
connected_ids: set[str] = set()
|
|
103
|
+
for e in edges:
|
|
104
|
+
connected_ids.add(e.get("from", ""))
|
|
105
|
+
connected_ids.add(e.get("to", ""))
|
|
106
|
+
isolated = [n for n in nodes if n["id"] not in connected_ids]
|
|
107
|
+
if isolated:
|
|
108
|
+
lines.append(f"**Isolated nodes** ({len(isolated)} with no edges):")
|
|
109
|
+
for n in isolated[:8]:
|
|
110
|
+
lines.append(f" - `{n.get('name', n['id'])}` in `{n.get('file', '?')}`")
|
|
111
|
+
if len(isolated) > 8:
|
|
112
|
+
lines.append(f" - …and {len(isolated) - 8} more")
|
|
113
|
+
else:
|
|
114
|
+
lines.append("_No isolated nodes._")
|
|
115
|
+
lines.append("")
|
|
116
|
+
|
|
117
|
+
# Thin communities (single-node clusters)
|
|
118
|
+
thin = [c for c in communities if len(c.get("members", [])) == 1]
|
|
119
|
+
if thin:
|
|
120
|
+
lines.append(f"**Thin communities** ({len(thin)} single-node clusters):")
|
|
121
|
+
for c in thin[:5]:
|
|
122
|
+
nid = c["members"][0]
|
|
123
|
+
n = node_map.get(nid, {})
|
|
124
|
+
lines.append(f" - `{n.get('name', nid)}` in `{n.get('file', '?')}`")
|
|
125
|
+
if len(thin) > 5:
|
|
126
|
+
lines.append(f" - …and {len(thin) - 5} more")
|
|
127
|
+
else:
|
|
128
|
+
lines.append("_No thin communities._")
|
|
129
|
+
lines.append("")
|
|
130
|
+
|
|
131
|
+
# High-ambiguity edges
|
|
132
|
+
ambiguous = [e for e in edges if e.get("confidence") == "AMBIGUOUS"]
|
|
133
|
+
if ambiguous and edges:
|
|
134
|
+
pct = round(100 * len(ambiguous) / len(edges), 1)
|
|
135
|
+
lines.append(f"**Ambiguous edges**: {len(ambiguous)} of {len(edges)} ({pct}%) "
|
|
136
|
+
"have low-confidence resolution — consider adding type annotations.")
|
|
137
|
+
else:
|
|
138
|
+
lines.append("_No ambiguous edges._")
|
|
139
|
+
lines.append("")
|
|
140
|
+
|
|
141
|
+
return lines
|
|
142
|
+
|
|
143
|
+
|
|
91
144
|
def _cross_module_edges(edges: list, node_map: dict) -> list[tuple]:
|
|
92
145
|
results = []
|
|
93
146
|
for e in edges:
|
package/codegraph/server.py
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
codegraph/server.py — MCP server exposing codebase knowledge graph tools.
|
|
4
4
|
|
|
5
5
|
Tools:
|
|
6
|
-
codegraph_build
|
|
7
|
-
codegraph_query
|
|
8
|
-
codegraph_arch
|
|
9
|
-
codegraph_report
|
|
10
|
-
codegraph_nodes
|
|
6
|
+
codegraph_build — scan project, extract AST nodes, build graph (local only, no API)
|
|
7
|
+
codegraph_query — structural question OR single-node lookup (or both)
|
|
8
|
+
codegraph_arch — module map: every file with its exports and imports
|
|
9
|
+
codegraph_report — return full CODEGRAPH_REPORT.md
|
|
10
|
+
codegraph_nodes — list nodes of a given type
|
|
11
|
+
codegraph_affected — BFS: what breaks if I change node X?
|
|
11
12
|
"""
|
|
12
13
|
|
|
13
14
|
import asyncio
|
|
@@ -28,6 +29,10 @@ from .graph.builder import build, to_json_dict, save_graph, load_graph
|
|
|
28
29
|
from .graph.query import answer as graph_answer, module_map
|
|
29
30
|
from .graph.clustering import detect_communities
|
|
30
31
|
from .report import generate as generate_report
|
|
32
|
+
from .affected import run_affected
|
|
33
|
+
from .export import to_html as export_html, to_graphml, to_obsidian, generate_all as export_all
|
|
34
|
+
from .tree_html import to_html as tree_html
|
|
35
|
+
from .callflow_html import to_html as callflow_html
|
|
31
36
|
|
|
32
37
|
app = Server("codegraph")
|
|
33
38
|
|
|
@@ -94,6 +99,40 @@ TOOLS = [
|
|
|
94
99
|
"required": ["path", "type"],
|
|
95
100
|
},
|
|
96
101
|
),
|
|
102
|
+
Tool(
|
|
103
|
+
name="codegraph_html",
|
|
104
|
+
description=(
|
|
105
|
+
"Generate interactive vis.js HTML graph visualization. "
|
|
106
|
+
"Dark theme, search box, community toggle, click-to-inspect node panel. "
|
|
107
|
+
"Outputs codegraph-cache/graph.html. Also generates graph.graphml and obsidian/ vault."
|
|
108
|
+
),
|
|
109
|
+
inputSchema={
|
|
110
|
+
"type": "object",
|
|
111
|
+
"properties": {
|
|
112
|
+
"path": {"type": "string", "description": "Project root"},
|
|
113
|
+
"formats": {"type": "array", "items": {"type": "string"}, "description": "Formats to generate: html, graphml, obsidian, tree, callflow (default: all)"},
|
|
114
|
+
},
|
|
115
|
+
"required": ["path"],
|
|
116
|
+
},
|
|
117
|
+
),
|
|
118
|
+
Tool(
|
|
119
|
+
name="codegraph_affected",
|
|
120
|
+
description=(
|
|
121
|
+
"BFS traversal: given a node name, find every node that would be affected "
|
|
122
|
+
"if you change it — callers, importers, inheritors, etc. "
|
|
123
|
+
"Use before refactoring to understand blast radius. "
|
|
124
|
+
"Returns affected nodes with file paths, relation types, and traversal depth."
|
|
125
|
+
),
|
|
126
|
+
inputSchema={
|
|
127
|
+
"type": "object",
|
|
128
|
+
"properties": {
|
|
129
|
+
"path": {"type": "string", "description": "Project root"},
|
|
130
|
+
"node": {"type": "string", "description": "Node name, ID, or file path to start from"},
|
|
131
|
+
"depth": {"type": "integer", "description": "BFS depth (default 2, max 5)"},
|
|
132
|
+
},
|
|
133
|
+
"required": ["path", "node"],
|
|
134
|
+
},
|
|
135
|
+
),
|
|
97
136
|
Tool(
|
|
98
137
|
name="codegraph_arch",
|
|
99
138
|
description=(
|
|
@@ -128,11 +167,13 @@ async def call_tool(name: str, arguments: dict):
|
|
|
128
167
|
|
|
129
168
|
|
|
130
169
|
async def _dispatch(name: str, args: dict):
|
|
131
|
-
if name == "codegraph_build":
|
|
132
|
-
if name == "codegraph_query":
|
|
133
|
-
if name == "codegraph_report":
|
|
134
|
-
if name == "codegraph_nodes":
|
|
135
|
-
if name == "codegraph_arch":
|
|
170
|
+
if name == "codegraph_build": return await _build(args)
|
|
171
|
+
if name == "codegraph_query": return await _query(args)
|
|
172
|
+
if name == "codegraph_report": return await _report(args)
|
|
173
|
+
if name == "codegraph_nodes": return await _nodes(args)
|
|
174
|
+
if name == "codegraph_arch": return await _arch(args)
|
|
175
|
+
if name == "codegraph_affected": return await _affected(args)
|
|
176
|
+
if name == "codegraph_html": return await _export_viz(args)
|
|
136
177
|
raise ValueError(f"Unknown tool: {name}")
|
|
137
178
|
|
|
138
179
|
|
|
@@ -190,6 +231,10 @@ async def _build(args: dict) -> dict:
|
|
|
190
231
|
save_graph(root, graph_dict)
|
|
191
232
|
generate_report(graph_dict, root)
|
|
192
233
|
save_cache(root, cache)
|
|
234
|
+
try:
|
|
235
|
+
export_all(graph_dict, root)
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
193
238
|
|
|
194
239
|
elapsed_ms = int((time.time() - t0) * 1000)
|
|
195
240
|
result = {
|
|
@@ -295,6 +340,50 @@ async def _arch(args: dict) -> dict:
|
|
|
295
340
|
return module_map(graph_dict, limit=limit)
|
|
296
341
|
|
|
297
342
|
|
|
343
|
+
async def _affected(args: dict) -> dict:
|
|
344
|
+
graph_dict = load_graph(args["path"])
|
|
345
|
+
if not graph_dict:
|
|
346
|
+
raise ValueError("No graph found. Run codegraph_build first.")
|
|
347
|
+
depth = min(int(args.get("depth", 2)), 5)
|
|
348
|
+
return run_affected(graph_dict, args["node"], depth=depth)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
async def _export_viz(args: dict) -> dict:
|
|
352
|
+
graph_dict = load_graph(args["path"])
|
|
353
|
+
if not graph_dict:
|
|
354
|
+
raise ValueError("No graph found. Run codegraph_build first.")
|
|
355
|
+
cache_dir = str(Path(args["path"]) / "codegraph-cache")
|
|
356
|
+
formats = args.get("formats") or ["html", "graphml", "obsidian", "tree", "callflow"]
|
|
357
|
+
results: dict[str, str] = {}
|
|
358
|
+
if "html" in formats:
|
|
359
|
+
try:
|
|
360
|
+
results["html"] = export_html(graph_dict, str(Path(cache_dir) / "graph.html"))
|
|
361
|
+
except Exception as e:
|
|
362
|
+
results["html_error"] = str(e)
|
|
363
|
+
if "graphml" in formats:
|
|
364
|
+
try:
|
|
365
|
+
results["graphml"] = to_graphml(graph_dict, str(Path(cache_dir) / "graph.graphml"))
|
|
366
|
+
except Exception as e:
|
|
367
|
+
results["graphml_error"] = str(e)
|
|
368
|
+
if "obsidian" in formats:
|
|
369
|
+
try:
|
|
370
|
+
results["obsidian"] = to_obsidian(graph_dict, str(Path(cache_dir) / "obsidian"))
|
|
371
|
+
except Exception as e:
|
|
372
|
+
results["obsidian_error"] = str(e)
|
|
373
|
+
if "tree" in formats:
|
|
374
|
+
try:
|
|
375
|
+
results["tree"] = tree_html(graph_dict, str(Path(cache_dir) / "tree.html"))
|
|
376
|
+
except Exception as e:
|
|
377
|
+
results["tree_error"] = str(e)
|
|
378
|
+
if "callflow" in formats:
|
|
379
|
+
try:
|
|
380
|
+
results["callflow"] = callflow_html(graph_dict, str(Path(cache_dir) / "callflow.html"))
|
|
381
|
+
except Exception as e:
|
|
382
|
+
results["callflow_error"] = str(e)
|
|
383
|
+
results["summary"] = f"Generated: {', '.join(k for k in results if not k.endswith('_error'))}"
|
|
384
|
+
return results
|
|
385
|
+
|
|
386
|
+
|
|
298
387
|
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
299
388
|
|
|
300
389
|
async def _async_main():
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tree_html.py — D3 v7 collapsible file-tree HTML from graph.json.
|
|
3
|
+
|
|
4
|
+
Adapted from graphify's tree_html.py.
|
|
5
|
+
Field name differences from graphify: we use 'file' not 'source_file', 'name' not 'label'.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import html as _html
|
|
10
|
+
import json
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
DEFAULT_MAX_CHILDREN = 200
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── Tree builder ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
def _common_root(paths: list[str]) -> str:
|
|
20
|
+
if not paths:
|
|
21
|
+
return ""
|
|
22
|
+
parts = [Path(p.replace("\\", "/")).parts for p in paths if p]
|
|
23
|
+
if not parts:
|
|
24
|
+
return ""
|
|
25
|
+
common = list(parts[0])
|
|
26
|
+
for p in parts[1:]:
|
|
27
|
+
i = 0
|
|
28
|
+
while i < len(common) and i < len(p) and common[i] == p[i]:
|
|
29
|
+
i += 1
|
|
30
|
+
common = common[:i]
|
|
31
|
+
return str(Path(*common)) if common else ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_tree(
|
|
35
|
+
graph_dict: dict,
|
|
36
|
+
*,
|
|
37
|
+
root: str | None = None,
|
|
38
|
+
max_children: int = DEFAULT_MAX_CHILDREN,
|
|
39
|
+
project_label: str | None = None,
|
|
40
|
+
) -> dict:
|
|
41
|
+
"""Build {name, total_count, children} hierarchy from graph nodes."""
|
|
42
|
+
nodes = graph_dict.get("nodes", [])
|
|
43
|
+
file_nodes = [n for n in nodes if n.get("file")]
|
|
44
|
+
if not file_nodes:
|
|
45
|
+
return {"name": "(empty graph)", "total_count": 0, "children": []}
|
|
46
|
+
|
|
47
|
+
if root is None:
|
|
48
|
+
root = _common_root([n["file"].replace("\\", "/") for n in file_nodes])
|
|
49
|
+
root_path = Path(root.replace("\\", "/")) if root else Path(".")
|
|
50
|
+
|
|
51
|
+
by_file: dict[str, list[dict]] = defaultdict(list)
|
|
52
|
+
for n in file_nodes:
|
|
53
|
+
by_file[n["file"].replace("\\", "/")].append(n)
|
|
54
|
+
|
|
55
|
+
dir_index: dict[str, dict] = {}
|
|
56
|
+
label_root = project_label or root_path.name or root or "/"
|
|
57
|
+
root_node: dict = {"name": label_root, "total_count": 0, "children": []}
|
|
58
|
+
dir_index[str(root_path)] = root_node
|
|
59
|
+
|
|
60
|
+
def _ensure_dir(abs_path: Path) -> dict:
|
|
61
|
+
key = str(abs_path)
|
|
62
|
+
if key in dir_index:
|
|
63
|
+
return dir_index[key]
|
|
64
|
+
if abs_path == abs_path.parent:
|
|
65
|
+
return root_node
|
|
66
|
+
parent = _ensure_dir(abs_path.parent) if abs_path.parent != abs_path else root_node
|
|
67
|
+
node = {"name": abs_path.name, "total_count": 0, "children": []}
|
|
68
|
+
dir_index[key] = node
|
|
69
|
+
parent["children"].append(node)
|
|
70
|
+
return node
|
|
71
|
+
|
|
72
|
+
for src_file, syms in sorted(by_file.items()):
|
|
73
|
+
src_path = Path(src_file)
|
|
74
|
+
try:
|
|
75
|
+
rel = src_path.relative_to(root_path)
|
|
76
|
+
parent_path = (root_path / rel).parent
|
|
77
|
+
except ValueError:
|
|
78
|
+
parent_path = root_path
|
|
79
|
+
parent_dir = _ensure_dir(parent_path)
|
|
80
|
+
|
|
81
|
+
sym_children = []
|
|
82
|
+
for n in syms:
|
|
83
|
+
sym_name = n.get("name", n.get("id", "?"))
|
|
84
|
+
if sym_name == src_path.name:
|
|
85
|
+
continue
|
|
86
|
+
sym_children.append({"name": sym_name, "total_count": 1, "children": []})
|
|
87
|
+
sym_children.sort(key=lambda c: (c["name"].startswith("_"), c["name"].lower()))
|
|
88
|
+
if len(sym_children) > max_children:
|
|
89
|
+
extra = len(sym_children) - max_children
|
|
90
|
+
sym_children = sym_children[:max_children] + [{"name": f"(+{extra} more)", "total_count": extra, "children": []}]
|
|
91
|
+
file_node = {"name": src_path.name, "total_count": len(sym_children) or 1, "children": sym_children}
|
|
92
|
+
parent_dir["children"].append(file_node)
|
|
93
|
+
|
|
94
|
+
def _finalise(d: dict) -> int:
|
|
95
|
+
kids = d.get("children") or []
|
|
96
|
+
kids.sort(key=lambda c: (0 if (c.get("children") and len(c["children"]) > 0) else 1, c["name"].lower()))
|
|
97
|
+
if not kids:
|
|
98
|
+
return d.get("total_count") or 1
|
|
99
|
+
n = sum(_finalise(c) for c in kids)
|
|
100
|
+
d["total_count"] = n or 1
|
|
101
|
+
return d["total_count"]
|
|
102
|
+
|
|
103
|
+
_finalise(root_node)
|
|
104
|
+
return root_node
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── HTML emitter ──────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
_HTML_TEMPLATE = r"""<!DOCTYPE html>
|
|
110
|
+
<html lang="en">
|
|
111
|
+
<head>
|
|
112
|
+
<meta charset="UTF-8">
|
|
113
|
+
<title>{title}</title>
|
|
114
|
+
<style>
|
|
115
|
+
body {{ font-family: 'Segoe UI', sans-serif; margin: 0; padding: 0; background: #f9f9f9; color: #333; }}
|
|
116
|
+
h1 {{ margin: 20px 0 0 24px; font-size: 2.2rem; font-weight: bold; color: #1e3a56; }}
|
|
117
|
+
.controls {{ margin: 20px 0 15px 24px; }}
|
|
118
|
+
button {{ margin-right: 10px; padding: 8px 18px; background: #007bff; color: #fff; border: none; border-radius: 5px; font-size: 0.95rem; cursor: pointer; }}
|
|
119
|
+
button:hover {{ background: #0056b3; }}
|
|
120
|
+
#tree-container {{ width: calc(100vw - 48px); height: 85vh; overflow: auto; border-radius: 8px; background: #fff; margin-left: 24px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); border: 1px solid #ddd; }}
|
|
121
|
+
svg {{ background: #fff; border-radius: 8px; display: block; }}
|
|
122
|
+
.node circle {{ stroke-width: 2.5px; }}
|
|
123
|
+
.node text {{ font: 13px 'Segoe UI', sans-serif; paint-order: stroke fill; stroke: #fff; stroke-width: 3px; stroke-linejoin: round; stroke-opacity: 0.85; }}
|
|
124
|
+
.link {{ fill: none; stroke-opacity: 0.7; stroke-width: 2px; }}
|
|
125
|
+
</style>
|
|
126
|
+
</head>
|
|
127
|
+
<body>
|
|
128
|
+
<h1>{header}</h1>
|
|
129
|
+
<div class="controls">
|
|
130
|
+
<button onclick="expandAll()">Expand All</button>
|
|
131
|
+
<button onclick="collapseAll()">Collapse All</button>
|
|
132
|
+
<button onclick="resetView()">Reset View</button>
|
|
133
|
+
</div>
|
|
134
|
+
<div id="tree-container">
|
|
135
|
+
<svg id="tree-svg" width="{svg_width}" height="{svg_height}"></svg>
|
|
136
|
+
</div>
|
|
137
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
138
|
+
<script>
|
|
139
|
+
const initialJsonData = {data_json};
|
|
140
|
+
function transformData(d) {{
|
|
141
|
+
function p(node, parentL1) {{
|
|
142
|
+
let dn = node.name;
|
|
143
|
+
if (node.total_count !== undefined && !/\(Total Count: \d+\)$/.test(dn))
|
|
144
|
+
dn += ` (Total Count: ${{node.total_count}})`;
|
|
145
|
+
const r = {{ name: dn, originalStageName: parentL1 === "Root" ? node.name : parentL1 }};
|
|
146
|
+
if (node.children && node.children.length > 0)
|
|
147
|
+
r.children = node.children.map(c => p(c, parentL1 === "Root" ? node.name : parentL1));
|
|
148
|
+
return r;
|
|
149
|
+
}}
|
|
150
|
+
let rn = d.name;
|
|
151
|
+
if (d.total_count !== undefined && !/\(Total Count: \d+\)$/.test(rn)) rn += ` (Total Count: ${{d.total_count}})`;
|
|
152
|
+
return {{ name: rn, originalStageName: "Root", children: (d.children || []).map(c => p(c, "Root")) }};
|
|
153
|
+
}}
|
|
154
|
+
const treeData = transformData(initialJsonData);
|
|
155
|
+
const PALETTE = [
|
|
156
|
+
["#3498DB","#2980B9","#AED6F1"],["#2ECC71","#27AE60","#A9DFBF"],
|
|
157
|
+
["#E74C3C","#C0392B","#F5B7B1"],["#9B59B6","#8E44AD","#D7BDE2"],
|
|
158
|
+
["#F39C12","#D68910","#FAD7A0"],["#1ABC9C","#117864","#A2D9CE"],
|
|
159
|
+
["#34495E","#1B2631","#ABB2B9"],["#E67E22","#BA4A00","#F5CBA7"],
|
|
160
|
+
];
|
|
161
|
+
const phaseColors = {{ "Root": {{ fill:"#4A4A4A",stroke:"#333333",collapsedFill:"#6C757D" }}, "Default": {{ fill:"#BDC3C7",stroke:"#95A5A6",collapsedFill:"#ECF0F1" }} }};
|
|
162
|
+
(initialJsonData.children||[]).forEach((c,i) => {{ const pal=PALETTE[i%PALETTE.length]; phaseColors[c.name]={{ fill:pal[0],stroke:pal[1],collapsedFill:pal[2] }}; }});
|
|
163
|
+
const levelPalettes = {{
|
|
164
|
+
0:{{fill:"#4A4A4A",stroke:"#333333",collapsedFill:"#6C757D"}},
|
|
165
|
+
2:{{fill:"#6ab04c",stroke:"#508a38",collapsedFill:"#a3d391"}},
|
|
166
|
+
3:{{fill:"#f0932b",stroke:"#d0730f",collapsedFill:"#f6c07e"}},
|
|
167
|
+
4:{{fill:"#be2edd",stroke:"#a01cb3",collapsedFill:"#e08bf2"}},
|
|
168
|
+
default:{{fill:"#747d8c",stroke:"#57606f",collapsedFill:"#a4b0be"}}
|
|
169
|
+
}};
|
|
170
|
+
const svg = d3.select("#tree-svg");
|
|
171
|
+
const margin = {{top:40,right:120,bottom:80,left:450}};
|
|
172
|
+
const duration = 500;
|
|
173
|
+
let nc = 0;
|
|
174
|
+
const g = svg.append("g").attr("transform",`translate(${{margin.left}},${{margin.top}})`);
|
|
175
|
+
const treemap = d3.tree().nodeSize([40,0]);
|
|
176
|
+
let root = d3.hierarchy(treeData, d=>d.children);
|
|
177
|
+
root.x0=0; root.y0=0;
|
|
178
|
+
if (root.children) root.children.forEach(collapse);
|
|
179
|
+
update(root);
|
|
180
|
+
function collapse(d) {{ if(d.children){{ d._children=d.children; d._children.forEach(collapse); d.children=null; }} }}
|
|
181
|
+
function expand(d) {{ if(d._children){{ d.children=d._children; d._children=null; }} if(d.children) d.children.forEach(expand); }}
|
|
182
|
+
window.expandAll=()=>{{ expand(root); update(root); }};
|
|
183
|
+
window.collapseAll=()=>{{ if(root.children) root.children.forEach(collapse); update(root); }};
|
|
184
|
+
window.resetView=()=>{{ if(root.children) root.children.forEach(c=>{{ if(c.children||c._children) collapse(c); }}); update(root); }};
|
|
185
|
+
function pal(d) {{
|
|
186
|
+
if (d.depth===0) return levelPalettes[0];
|
|
187
|
+
if (d.depth===1) return phaseColors[d.data.originalStageName]||phaseColors.Default;
|
|
188
|
+
return levelPalettes[d.depth]||levelPalettes.default;
|
|
189
|
+
}}
|
|
190
|
+
function update(src) {{
|
|
191
|
+
const td = treemap(root);
|
|
192
|
+
const nodes = td.descendants(), links = td.descendants().slice(1);
|
|
193
|
+
const minX = d3.min(nodes,d=>d.x)||0, maxX = d3.max(nodes,d=>d.x)||0;
|
|
194
|
+
svg.transition().duration(duration/2).attr("height",Math.max(+svg.attr("height"),maxX-minX+margin.top+margin.bottom+100));
|
|
195
|
+
g.transition().duration(duration/2).attr("transform",`translate(${{margin.left}},${{margin.top-minX+40}})`);
|
|
196
|
+
nodes.forEach(d=>{{ d.y=d.depth*400; }});
|
|
197
|
+
const node = g.selectAll('g.node').data(nodes, d=>d.id||(d.id=++nc));
|
|
198
|
+
const ne = node.enter().append('g')
|
|
199
|
+
.attr('class','node')
|
|
200
|
+
.attr('transform',`translate(${{src.y0}},${{src.x0}})`)
|
|
201
|
+
.style('cursor',d=>(d.children||d._children)?'pointer':'default')
|
|
202
|
+
.on('click',(e,d)=>{{ if(d.children){{ d._children=d.children; d.children=null; }} else if(d._children){{ d.children=d._children; d._children=null; }} update(d); }});
|
|
203
|
+
ne.append('circle').attr('r',1e-6);
|
|
204
|
+
ne.append('text').attr('dy','.35em').attr('x',d=>d.children||d._children?-14:14).attr('text-anchor',d=>d.children||d._children?'end':'start').style("fill-opacity",1e-6);
|
|
205
|
+
const nu = ne.merge(node);
|
|
206
|
+
nu.transition().duration(duration).attr('transform',d=>`translate(${{d.y}},${{d.x}})`);
|
|
207
|
+
nu.select('circle').attr('r',8.5)
|
|
208
|
+
.style('fill',d=>d._children?pal(d).collapsedFill:d.children?pal(d).fill:"#fff")
|
|
209
|
+
.style('stroke',d=>pal(d).stroke);
|
|
210
|
+
nu.select('text').style("fill-opacity",1).text(d=>d.data.name.replace(/\s*\(Total Count: \d+\)$/,''));
|
|
211
|
+
node.exit().transition().duration(duration).attr('transform',`translate(${{src.y}},${{src.x}})`).remove();
|
|
212
|
+
const link = g.selectAll('path.link').data(links,d=>d.id);
|
|
213
|
+
link.enter().insert('path',"g").attr('class','link').attr('d',d=>{{ const o={{x:src.x0,y:src.y0}}; return `M${{o.y}} ${{o.x}} C${{(o.y+o.y)/2}} ${{o.x}},${{(o.y+o.y)/2}} ${{o.x}},${{o.y}} ${{o.x}}`; }})
|
|
214
|
+
.merge(link).transition().duration(duration).attr('d',d=>`M${{d.y}} ${{d.x}} C${{(d.y+d.parent.y)/2}} ${{d.x}},${{(d.y+d.parent.y)/2}} ${{d.parent.x}},${{d.parent.y}} ${{d.parent.x}}`)
|
|
215
|
+
.style('stroke',d=>pal(d.parent).stroke);
|
|
216
|
+
link.exit().transition().duration(duration).attr('d',d=>{{ const o={{x:src.x,y:src.y}}; return `M${{o.y}} ${{o.x}} C${{o.y}} ${{o.x}},${{o.y}} ${{o.x}},${{o.y}} ${{o.x}}`; }}).remove();
|
|
217
|
+
nodes.forEach(d=>{{ d.x0=d.x; d.y0=d.y; }});
|
|
218
|
+
}}
|
|
219
|
+
</script>
|
|
220
|
+
</body>
|
|
221
|
+
</html>"""
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def to_html(graph_dict: dict, output_path: str, *, project_label: str | None = None) -> str:
|
|
225
|
+
"""Generate D3 collapsible tree HTML. Returns path written."""
|
|
226
|
+
tree = build_tree(graph_dict, project_label=project_label)
|
|
227
|
+
title = _html.escape(f"{tree['name']} — CodeGraph file tree")
|
|
228
|
+
header = _html.escape(f"{tree['name']} — File Tree")
|
|
229
|
+
data_json = json.dumps(tree, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
|
|
230
|
+
|
|
231
|
+
html_content = _HTML_TEMPLATE.format(
|
|
232
|
+
title=title,
|
|
233
|
+
header=header,
|
|
234
|
+
svg_width=6000,
|
|
235
|
+
svg_height=8000,
|
|
236
|
+
data_json=data_json,
|
|
237
|
+
)
|
|
238
|
+
out = Path(output_path)
|
|
239
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
out.write_text(html_content, encoding="utf-8")
|
|
241
|
+
return str(out)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-mcp-server",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "Persistent AI memory + codebase knowledge graph MCP server. Works across Claude Code, Cursor, Gemini CLI, Codex, Windsurf, VS Code Copilot, Claude.ai, and ChatGPT.",
|
|
3
|
+
"version": "1.1.2",
|
|
4
|
+
"description": "Persistent AI memory + codebase knowledge graph MCP server. Works across Claude Code, Cursor, Gemini CLI, Codex, Windsurf, VS Code Copilot, Antigravity IDE, Claude.ai, and ChatGPT.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"context-mcp": "./src/index.js",
|
package/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "codegraph-mcp"
|
|
7
|
-
version = "1.1.
|
|
7
|
+
version = "1.1.2"
|
|
8
8
|
description = "Codebase knowledge graph MCP server — AST extraction, graph queries, community detection"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -18,6 +18,9 @@ dependencies = [
|
|
|
18
18
|
]
|
|
19
19
|
|
|
20
20
|
[project.optional-dependencies]
|
|
21
|
+
leiden = [
|
|
22
|
+
"graspologic>=3.3",
|
|
23
|
+
]
|
|
21
24
|
treesitter = [
|
|
22
25
|
"tree-sitter>=0.23.0",
|
|
23
26
|
"tree-sitter-python",
|