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.
Files changed (72) hide show
  1. package/README.md +29 -7
  2. package/codegraph/__pycache__/affected.cpython-313.pyc +0 -0
  3. package/codegraph/__pycache__/cache.cpython-313.pyc +0 -0
  4. package/codegraph/__pycache__/callflow_html.cpython-313.pyc +0 -0
  5. package/codegraph/__pycache__/export.cpython-313.pyc +0 -0
  6. package/codegraph/__pycache__/report.cpython-313.pyc +0 -0
  7. package/codegraph/__pycache__/server.cpython-313.pyc +0 -0
  8. package/codegraph/__pycache__/tree_html.cpython-313.pyc +0 -0
  9. package/codegraph/affected.py +233 -0
  10. package/codegraph/cache.py +51 -2
  11. package/codegraph/callflow_html.py +273 -0
  12. package/codegraph/export.py +544 -0
  13. package/codegraph/extractors/__pycache__/ast_extractor.cpython-313.pyc +0 -0
  14. package/codegraph/extractors/ast_extractor.py +143 -16
  15. package/codegraph/graph/__pycache__/builder.cpython-313.pyc +0 -0
  16. package/codegraph/graph/__pycache__/clustering.cpython-313.pyc +0 -0
  17. package/codegraph/graph/__pycache__/query.cpython-313.pyc +0 -0
  18. package/codegraph/graph/__pycache__/symbol_resolution.cpython-313.pyc +0 -0
  19. package/codegraph/graph/builder.py +10 -0
  20. package/codegraph/graph/clustering.py +247 -10
  21. package/codegraph/graph/query.py +56 -0
  22. package/codegraph/graph/symbol_resolution.py +112 -0
  23. package/codegraph/report.py +53 -0
  24. package/codegraph/server.py +99 -10
  25. package/codegraph/tree_html.py +241 -0
  26. package/package.json +2 -2
  27. package/pyproject.toml +4 -1
  28. package/src/cli.js +303 -90
  29. package/src/server.js +7 -1
  30. package/src/templates/antigravity/GEMINI.md +96 -0
  31. package/src/templates/antigravity/hooks/context-mcp-post-tool-use.js +62 -0
  32. package/src/templates/antigravity/workflows/context-resume.md +20 -0
  33. package/src/templates/antigravity/workflows/graph-build.md +23 -0
  34. package/src/templates/antigravity/workflows/save-context.md +29 -0
  35. package/src/templates/{CLAUDE.md → claude/CLAUDE.md} +3 -0
  36. package/src/templates/claude/commands/graph-build.md +9 -0
  37. package/src/templates/claude/commands/save-context.md +19 -0
  38. package/src/templates/claude/hooks/context-mcp-post-tool-use.js +59 -0
  39. package/src/templates/claude/hooks/context-mcp-pre-tool-use.js +26 -0
  40. package/src/templates/{skills → claude/skills}/SKILL.md +3 -0
  41. package/src/templates/codex/AGENTS.md +107 -0
  42. package/src/templates/codex/hooks/context-mcp-post-tool-use.js +46 -0
  43. package/src/templates/codex/hooks/context-mcp-pre-tool-use.js +23 -0
  44. package/src/templates/codex/prompts/context-resume.md +15 -0
  45. package/src/templates/codex/prompts/graph-build.md +14 -0
  46. package/src/templates/codex/prompts/save-context.md +24 -0
  47. package/src/templates/cursor/commands/context-resume.md +7 -0
  48. package/src/templates/cursor/commands/graph-build.md +7 -0
  49. package/src/templates/cursor/commands/save-context.md +12 -0
  50. package/src/templates/{cursor-rules.mdc → cursor/cursor-rules.mdc} +13 -3
  51. package/src/templates/cursor/hooks/context-mcp-post-tool-use.js +55 -0
  52. package/src/templates/{GEMINI.md → gemini/GEMINI.md} +3 -1
  53. package/src/templates/gemini/commands/context-resume.toml +15 -0
  54. package/src/templates/gemini/commands/graph-build.toml +14 -0
  55. package/src/templates/gemini/commands/save-context.toml +24 -0
  56. package/src/templates/gemini/hooks/context-mcp-after-tool.js +59 -0
  57. package/src/templates/gemini/hooks/context-mcp-before-tool.js +26 -0
  58. package/src/templates/vscode/commands/context-resume.prompt.md +15 -0
  59. package/src/templates/vscode/commands/graph-build.prompt.md +10 -0
  60. package/src/templates/vscode/commands/save-context.prompt.md +16 -0
  61. package/src/templates/vscode/hooks/context-mcp-post-tool-use.js +58 -0
  62. package/src/templates/windsurf/hooks/context-mcp-post-run-command.js +57 -0
  63. package/src/templates/{windsurf-rules.md → windsurf/windsurf-rules.md} +6 -4
  64. package/src/templates/windsurf/workflows/context-resume.md +11 -0
  65. package/src/templates/windsurf/workflows/graph-build.md +11 -0
  66. package/src/templates/windsurf/workflows/save-context.md +18 -0
  67. package/src/tools/codegraph.js +37 -0
  68. package/uv.lock +1100 -3
  69. package/src/templates/AGENTS.md +0 -90
  70. package/src/templates/commands/graph-build.md +0 -5
  71. package/src/templates/commands/save-context.md +0 -12
  72. /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
@@ -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:
@@ -3,11 +3,12 @@
3
3
  codegraph/server.py — MCP server exposing codebase knowledge graph tools.
4
4
 
5
5
  Tools:
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
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": return await _build(args)
132
- if name == "codegraph_query": return await _query(args)
133
- if name == "codegraph_report": return await _report(args)
134
- if name == "codegraph_nodes": return await _nodes(args)
135
- if name == "codegraph_arch": return await _arch(args)
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.0",
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.0"
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",