codegraph-nav 0.1.0__py3-none-any.whl

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 (41) hide show
  1. codegraph_nav/__init__.py +194 -0
  2. codegraph_nav/ast_grep_analyzer.py +448 -0
  3. codegraph_nav/cli.py +223 -0
  4. codegraph_nav/code_navigator.py +1328 -0
  5. codegraph_nav/code_search.py +1009 -0
  6. codegraph_nav/colors.py +209 -0
  7. codegraph_nav/completions.py +354 -0
  8. codegraph_nav/dart_analyzer.py +301 -0
  9. codegraph_nav/dependency_graph.py +814 -0
  10. codegraph_nav/domain/__init__.py +20 -0
  11. codegraph_nav/domain/routes.py +337 -0
  12. codegraph_nav/domain/schemas.py +229 -0
  13. codegraph_nav/domain/tags.py +87 -0
  14. codegraph_nav/exporters.py +563 -0
  15. codegraph_nav/go_analyzer.py +273 -0
  16. codegraph_nav/graph/__init__.py +72 -0
  17. codegraph_nav/graph/builder.py +409 -0
  18. codegraph_nav/graph/communities.py +402 -0
  19. codegraph_nav/graph/flows.py +311 -0
  20. codegraph_nav/graph/query.py +380 -0
  21. codegraph_nav/graph/schema.py +266 -0
  22. codegraph_nav/graph/search.py +257 -0
  23. codegraph_nav/graph/store.py +517 -0
  24. codegraph_nav/hints.py +195 -0
  25. codegraph_nav/import_resolver.py +891 -0
  26. codegraph_nav/js_ts_analyzer.py +564 -0
  27. codegraph_nav/line_reader.py +664 -0
  28. codegraph_nav/mcp/__init__.py +39 -0
  29. codegraph_nav/mcp/__main__.py +5 -0
  30. codegraph_nav/mcp/server.py +2228 -0
  31. codegraph_nav/py.typed +2 -0
  32. codegraph_nav/ruby_analyzer.py +259 -0
  33. codegraph_nav/rust_analyzer.py +379 -0
  34. codegraph_nav/token_efficient_renderer.py +743 -0
  35. codegraph_nav/watcher.py +382 -0
  36. codegraph_nav-0.1.0.dist-info/METADATA +487 -0
  37. codegraph_nav-0.1.0.dist-info/RECORD +41 -0
  38. codegraph_nav-0.1.0.dist-info/WHEEL +5 -0
  39. codegraph_nav-0.1.0.dist-info/entry_points.txt +4 -0
  40. codegraph_nav-0.1.0.dist-info/licenses/LICENSE +21 -0
  41. codegraph_nav-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2228 @@
1
+ #!/usr/bin/env python3
2
+ """Codegraph-nav MCP Server - Token-efficient code navigation for AI assistants.
3
+
4
+ This server implements the Model Context Protocol (MCP) using FastMCP to expose
5
+ codegraph-nav's code navigation capabilities to Claude Desktop, Claude Code, and
6
+ other MCP-compatible AI assistants.
7
+
8
+ Usage:
9
+ python -m codegraph_nav.mcp
10
+ codegraph-nav-mcp
11
+ """
12
+
13
+ import json
14
+ import logging
15
+ import os
16
+ import re
17
+ import subprocess
18
+ from collections import Counter
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, Optional, cast
21
+
22
+ from mcp.server.fastmcp import FastMCP
23
+
24
+ from ..code_navigator import CodeNavigator
25
+ from ..code_search import CodeSearcher
26
+ from ..line_reader import LineReader
27
+
28
+ if TYPE_CHECKING:
29
+ from ..graph import GraphStore
30
+ from ..hints import SessionState
31
+
32
+ # Optional imports
33
+ try:
34
+ from ..token_efficient_renderer import TokenEfficientRenderer
35
+
36
+ HAS_RENDERER = True
37
+ except ImportError:
38
+ HAS_RENDERER = False
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ # ==============================================================================
43
+ # CONSTANTS
44
+ # ==============================================================================
45
+
46
+ DETAIL_LEVELS = ("minimal", "standard", "verbose")
47
+
48
+ # Security limits
49
+ MAX_LIMIT = 200
50
+ MAX_DEPTH = 10
51
+
52
+ # Regex patterns for identifying test files
53
+ TEST_FILE_PATTERNS = [
54
+ re.compile(r"test_[^/]*\.py$"),
55
+ re.compile(r"[^/]*_test\.py$"),
56
+ re.compile(r"[^/]*\.test\.[jt]sx?$"),
57
+ re.compile(r"[^/]*\.spec\.[jt]sx?$"),
58
+ re.compile(r"(^|/)tests/"),
59
+ re.compile(r"(^|/)__tests__/"),
60
+ re.compile(r"(^|/)test/"),
61
+ ]
62
+
63
+
64
+ def _validate_detail_level(level: str) -> str:
65
+ """Validate and normalize detail_level parameter."""
66
+ level = level.lower().strip()
67
+ if level not in DETAIL_LEVELS:
68
+ return "minimal"
69
+ return level
70
+
71
+
72
+ # ==============================================================================
73
+ # SYSTEM PROMPT - Instructions for AI agents
74
+ # ==============================================================================
75
+
76
+ SYSTEM_PROMPT = """# Codegraph-Nav - Graph-Intelligent, Token-Efficient Code Navigation
77
+
78
+ You have access to Codegraph-Nav, an MCP server for exploring codebases efficiently while minimizing token usage.
79
+
80
+ ## Quick Start
81
+
82
+ 1. **Orient first** (`codegraph_get_minimal_context`): Get a ~100 token project overview. Always start here.
83
+ 2. **Scan if needed** (`codegraph_scan`): Generate the code index (creates `.codegraph.json`).
84
+ 3. **Search by symbol** (`codegraph_search`): Find functions, classes, methods by name. Returns file:line locations.
85
+ 4. **Read surgically** (`codegraph_read`): Load only the specific lines you need, never entire files.
86
+
87
+ ## Detail Levels
88
+
89
+ All tools accept `detail_level` parameter: `minimal` (default), `standard`, or `verbose`.
90
+ Start with `minimal`. Only escalate if you need more context.
91
+
92
+ ## Available Tools
93
+
94
+ | Tool | Purpose | When to Use |
95
+ |------|---------|-------------|
96
+ | `codegraph_get_minimal_context` | ~100 token orientation | **Always use first** |
97
+ | `codegraph_scan` | Index codebase | First time on a new project |
98
+ | `codegraph_search` | Find symbols | Looking for specific function/class |
99
+ | `codegraph_read` | Read lines | After finding symbol location |
100
+ | `codegraph_stats` | Codebase overview | Understanding project size |
101
+ | `codegraph_get_hubs` | Find central files | Architecture analysis |
102
+ | `codegraph_get_structure` | File outline | Before reading a file |
103
+ | `codegraph_get_dependencies` | Import graph | Understanding coupling |
104
+ | `codegraph_test_gaps` | Find untested symbols | Before/after changes |
105
+ | `codegraph_graph_build` | Build graph DB | Before using graph tools |
106
+ | `codegraph_blast_radius` | Impact analysis | "What breaks if I change this?" |
107
+ | `codegraph_list_flows` | Execution flows | Understanding call chains |
108
+ | `codegraph_detect_changes` | Risk-scored diff | Code review / PR analysis |
109
+ | `codegraph_search_graph` | FTS5 hybrid search | Search by concept, not just name |
110
+ | `codegraph_list_communities` | Code communities | Architecture grouping |
111
+ | `codegraph_get_community` | Community details | Explore a specific community |
112
+ | `codegraph_get_architecture_overview` | Architecture summary | High-level structure |
113
+ | `codegraph_list_routes` | HTTP routes (15+ frameworks) | API/route exploration |
114
+ | `codegraph_list_schemas` | ORM schemas (8+ ORMs) | Data model exploration |
115
+
116
+ ## Example Session
117
+
118
+ ```
119
+ User: "Fix the payment bug"
120
+
121
+ 1. codegraph_get_minimal_context(path="/project", task="fix payment bug")
122
+ → project: 142 files · 1847 symbols · py,js
123
+ → hubs: config.py(8←), models.py(5←)
124
+ → suggest: codegraph_search → codegraph_read → codegraph_get_dependencies
125
+
126
+ 2. codegraph_search(query="payment")
127
+ → payments.py:L45-89 [fn] process_payment
128
+
129
+ 3. codegraph_read(file_path="payments.py", start_line=45, end_line=89)
130
+ → Read only those 44 lines (~500 tokens vs ~15,000 for whole file)
131
+
132
+ 4. codegraph_test_gaps(path="/project", changed_only=True)
133
+ → 2 untested symbols | gaps: process_payment, validate_card
134
+ ```
135
+
136
+ ## Workflow Templates
137
+
138
+ Use the built-in prompts for common tasks:
139
+ - `investigate_bug` - Bug investigation workflow
140
+ - `add_feature` - Feature implementation workflow
141
+ - `review_changes` - Code review workflow
142
+ - `understand_architecture` - Architecture analysis workflow
143
+ - `onboard_project` - Project onboarding workflow
144
+
145
+ Each enforces: start with minimal_context, use minimal detail_level, escalate only when needed.
146
+ """
147
+
148
+ # ==============================================================================
149
+ # SERVER INITIALIZATION
150
+ # ==============================================================================
151
+
152
+ # Create FastMCP server with instructions
153
+ mcp = FastMCP(
154
+ "codegraph-nav",
155
+ instructions=SYSTEM_PROMPT,
156
+ )
157
+
158
+ # Global handler instance (initialized per-session)
159
+ _handler: Optional["CodegraphToolHandler"] = None
160
+
161
+
162
+ def get_handler() -> "CodegraphToolHandler":
163
+ """Get or create the tool handler."""
164
+ global _handler
165
+ if _handler is None:
166
+ _handler = CodegraphToolHandler()
167
+ return _handler
168
+
169
+
170
+ # ==============================================================================
171
+ # TOOL HANDLER CLASS
172
+ # ==============================================================================
173
+
174
+
175
+ class CodegraphToolHandler:
176
+ """Handles execution of Codegraph-nav MCP tools."""
177
+
178
+ def __init__(self, workspace_root: str | None = None):
179
+ self.workspace_root = workspace_root or os.getcwd()
180
+ self._code_map_cache: dict[str, dict] = {}
181
+ self._navigator_cache: dict[str, CodeNavigator] = {}
182
+ # Session hints. SessionState is imported lazily below (and under
183
+ # TYPE_CHECKING), so the annotation must stay a string forward-ref.
184
+ self._session: "SessionState | None" # noqa: UP037 (lazy import below)
185
+ try:
186
+ from ..hints import SessionState
187
+
188
+ self._session = SessionState()
189
+ except ImportError:
190
+ self._session = None
191
+
192
+ def _get_map_path(self, path: str) -> Path:
193
+ """Get the .codegraph.json path for a directory."""
194
+ return Path(path) / ".codegraph.json"
195
+
196
+ def _check_map_exists(self, path: str) -> tuple[bool, str]:
197
+ """Check if code map is available (in cache or on disk)."""
198
+ abs_path = os.path.abspath(path)
199
+ if abs_path in self._code_map_cache:
200
+ return True, ""
201
+ map_path = self._get_map_path(path)
202
+ if not map_path.exists():
203
+ return False, (
204
+ f"No .codegraph.json found in {path}. "
205
+ "Run `codegraph_scan` first to index the codebase."
206
+ )
207
+ return True, ""
208
+
209
+ def _get_navigator(self, path: str) -> CodeNavigator:
210
+ """Get or create a CodeNavigator for the given path."""
211
+ abs_path = os.path.abspath(path)
212
+ if abs_path not in self._navigator_cache:
213
+ self._navigator_cache[abs_path] = CodeNavigator(abs_path)
214
+ return self._navigator_cache[abs_path]
215
+
216
+ def _get_code_map(self, path: str, force_rescan: bool = False) -> dict:
217
+ """Get or load a code map for the given path."""
218
+ abs_path = os.path.abspath(path)
219
+
220
+ # Check cache
221
+ if not force_rescan and abs_path in self._code_map_cache:
222
+ return self._code_map_cache[abs_path]
223
+
224
+ # Load from file
225
+ map_path = self._get_map_path(abs_path)
226
+ if map_path.exists():
227
+ with open(map_path, encoding="utf-8") as f:
228
+ code_map: dict = json.load(f)
229
+ self._code_map_cache[abs_path] = code_map
230
+ return code_map
231
+
232
+ return {}
233
+
234
+ # ------------------------------------------------------------------
235
+ # Hub computation (shared by scan, get_hubs, minimal_context)
236
+ # ------------------------------------------------------------------
237
+
238
+ def _compute_hubs(self, code_map: dict, top_n: int = 10, min_imports: int = 3) -> list[dict]:
239
+ """Compute hub files from a code map.
240
+
241
+ Returns list of {"file": str, "imports": int, "symbols": list[str]}.
242
+ """
243
+ import_counts: dict[str, int] = {}
244
+ file_symbols: dict[str, list[str]] = {}
245
+
246
+ for fpath, file_info in code_map.get("files", {}).items():
247
+ file_symbols[fpath] = [s["name"] for s in file_info.get("symbols", [])]
248
+ for imp in file_info.get("imports", []):
249
+ import_counts[imp] = import_counts.get(imp, 0) + 1
250
+
251
+ hubs = []
252
+ for file_path, count in import_counts.items():
253
+ if count >= min_imports:
254
+ hubs.append(
255
+ {
256
+ "file": file_path,
257
+ "imports": count,
258
+ "symbols": file_symbols.get(file_path, []),
259
+ }
260
+ )
261
+
262
+ hubs.sort(key=lambda x: cast(int, x["imports"]), reverse=True)
263
+ return hubs[:top_n]
264
+
265
+ # ------------------------------------------------------------------
266
+ # Search result formatters
267
+ # ------------------------------------------------------------------
268
+
269
+ def _format_search_results_compact(self, results: list, limit: int) -> str:
270
+ """Format search results in compact single-line format (minimal)."""
271
+ if not results:
272
+ return "No matching symbols found."
273
+
274
+ lines = [f"Found {len(results)} matches:"]
275
+
276
+ for r in results[:limit]:
277
+ end_line = r.lines[1] if len(r.lines) > 1 else r.lines[0]
278
+ type_abbr = {"function": "fn", "class": "cls", "method": "mth"}.get(r.type, r.type[:3])
279
+ lines.append(f"{r.file}:L{r.lines[0]}-{end_line} [{type_abbr}] {r.name}")
280
+
281
+ if len(results) > limit:
282
+ lines.append(f"... +{len(results) - limit} more")
283
+
284
+ return "\n".join(lines)
285
+
286
+ def _format_search_results_standard(self, results: list, limit: int) -> str:
287
+ """Format search results with signatures (standard)."""
288
+ if not results:
289
+ return "No matching symbols found."
290
+
291
+ lines = [f"Found {len(results)} matches:"]
292
+
293
+ for r in results[:limit]:
294
+ end_line = r.lines[1] if len(r.lines) > 1 else r.lines[0]
295
+ type_abbr = {"function": "fn", "class": "cls", "method": "mth"}.get(r.type, r.type[:3])
296
+ sig = f" :: {r.signature}" if r.signature else ""
297
+ parent = f" ({r.parent})" if r.parent else ""
298
+ lines.append(f"{r.file}:L{r.lines[0]}-{end_line} [{type_abbr}] {r.name}{parent}{sig}")
299
+
300
+ if len(results) > limit:
301
+ lines.append(f"... +{len(results) - limit} more")
302
+
303
+ return "\n".join(lines)
304
+
305
+ def _format_search_results_verbose(self, results: list, limit: int) -> str:
306
+ """Format search results with signatures, docstrings, deps (verbose)."""
307
+ if not results:
308
+ return "No matching symbols found."
309
+
310
+ lines = [f"Found {len(results)} matches:"]
311
+
312
+ for r in results[:limit]:
313
+ end_line = r.lines[1] if len(r.lines) > 1 else r.lines[0]
314
+ type_abbr = {"function": "fn", "class": "cls", "method": "mth"}.get(r.type, r.type[:3])
315
+ parent = f" ({r.parent})" if r.parent else ""
316
+ lines.append(f"{r.file}:L{r.lines[0]}-{end_line} [{type_abbr}] {r.name}{parent}")
317
+ if r.signature:
318
+ lines.append(f" sig: {r.signature}")
319
+ if r.docstring:
320
+ doc = r.docstring[:80] + "..." if len(r.docstring) > 80 else r.docstring
321
+ lines.append(f" doc: {doc}")
322
+
323
+ if len(results) > limit:
324
+ lines.append(f"... +{len(results) - limit} more")
325
+
326
+ return "\n".join(lines)
327
+
328
+ # ------------------------------------------------------------------
329
+ # Hub formatters
330
+ # ------------------------------------------------------------------
331
+
332
+ def _format_hubs_compact(self, hubs: list) -> str:
333
+ """Format hub files in compact list format (minimal)."""
334
+ if not hubs:
335
+ return "No hub files found."
336
+
337
+ lines = ["Architectural hubs (most imported):"]
338
+ for i, hub in enumerate(hubs, 1):
339
+ symbols_preview = ", ".join(hub.get("symbols", [])[:3])
340
+ if len(hub.get("symbols", [])) > 3:
341
+ symbols_preview += "..."
342
+ lines.append(f"{i}. {hub['file']} ({hub['imports']}← imports) [{symbols_preview}]")
343
+
344
+ return "\n".join(lines)
345
+
346
+ def _format_hubs_standard(self, hubs: list) -> str:
347
+ """Format hub files with full symbol lists (standard)."""
348
+ if not hubs:
349
+ return "No hub files found."
350
+
351
+ lines = ["Architectural hubs (most imported):"]
352
+ for i, hub in enumerate(hubs, 1):
353
+ symbols = ", ".join(hub.get("symbols", []))
354
+ lines.append(f"{i}. {hub['file']} ({hub['imports']}← imports)")
355
+ if symbols:
356
+ lines.append(f" symbols: {symbols}")
357
+
358
+ return "\n".join(lines)
359
+
360
+ # ------------------------------------------------------------------
361
+ # Stats formatters
362
+ # ------------------------------------------------------------------
363
+
364
+ def _format_stats_compact(self, stats: dict) -> str:
365
+ """Format stats as compact key-value pairs (minimal)."""
366
+ lines = [
367
+ f"root: {stats.get('root', 'unknown')}",
368
+ f"files: {stats.get('files', 0)}",
369
+ f"symbols: {stats.get('total_symbols', 0)}",
370
+ ]
371
+
372
+ by_type = stats.get("by_type", {})
373
+ if by_type:
374
+ type_parts = [f"{k}:{v}" for k, v in sorted(by_type.items())]
375
+ lines.append(f"by_type: {', '.join(type_parts)}")
376
+
377
+ if stats.get("generated_at"):
378
+ lines.append(f"generated: {stats['generated_at']}")
379
+
380
+ return "\n".join(lines)
381
+
382
+ def _format_stats_standard(self, stats: dict, code_map: dict) -> str:
383
+ """Format stats with language breakdown and hub count (standard)."""
384
+ lines = [self._format_stats_compact(stats)]
385
+
386
+ # Language breakdown
387
+ ext_counts: Counter = Counter()
388
+ for fpath in code_map.get("files", {}):
389
+ ext = Path(fpath).suffix
390
+ if ext:
391
+ ext_counts[ext] += 1
392
+ if ext_counts:
393
+ lang_parts = [f"{ext}:{n}" for ext, n in ext_counts.most_common(10)]
394
+ lines.append(f"languages: {', '.join(lang_parts)}")
395
+
396
+ # Hub count
397
+ hubs = self._compute_hubs(code_map, top_n=100, min_imports=3)
398
+ lines.append(f"hubs: {len(hubs)} files with 3+ importers")
399
+
400
+ return "\n".join(lines)
401
+
402
+ def _format_stats_verbose(self, stats: dict, code_map: dict) -> str:
403
+ """Format stats with per-file details (verbose)."""
404
+ lines = [self._format_stats_standard(stats, code_map)]
405
+
406
+ # Top files by symbol count
407
+ file_sym_counts = []
408
+ for fpath, info in code_map.get("files", {}).items():
409
+ sym_count = len(info.get("symbols", []))
410
+ if sym_count > 0:
411
+ file_sym_counts.append((fpath, sym_count))
412
+ file_sym_counts.sort(key=lambda x: x[1], reverse=True)
413
+
414
+ if file_sym_counts:
415
+ lines.append("top files by symbols:")
416
+ for fpath, count in file_sym_counts[:20]:
417
+ lines.append(f" {fpath}: {count} symbols")
418
+
419
+ return "\n".join(lines)
420
+
421
+ # ------------------------------------------------------------------
422
+ # Symbol context lookup (for codegraph_read standard mode)
423
+ # ------------------------------------------------------------------
424
+
425
+ def _find_containing_symbol(
426
+ self, code_map: dict, rel_file_path: str, line_number: int
427
+ ) -> dict | None:
428
+ """Find the symbol containing a given line number."""
429
+ file_info = code_map.get("files", {}).get(rel_file_path)
430
+ if not file_info:
431
+ # Try partial match
432
+ for fpath, info in code_map.get("files", {}).items():
433
+ if rel_file_path in fpath or fpath in rel_file_path:
434
+ file_info = info
435
+ break
436
+ if not file_info:
437
+ return None
438
+
439
+ best = None
440
+ for sym in file_info.get("symbols", []):
441
+ lines = sym.get("lines", [])
442
+ if len(lines) >= 2 and lines[0] <= line_number <= lines[1]:
443
+ # Prefer innermost (smallest range)
444
+ if best is None or (lines[1] - lines[0]) < (best["lines"][1] - best["lines"][0]):
445
+ best = sym
446
+ if best:
447
+ return {
448
+ "name": best.get("name"),
449
+ "type": best.get("type"),
450
+ "lines": best.get("lines"),
451
+ "parent": best.get("parent"),
452
+ }
453
+ return None
454
+
455
+ # ------------------------------------------------------------------
456
+ # Test gap analysis
457
+ # ------------------------------------------------------------------
458
+
459
+ def _is_test_file(self, file_path: str) -> bool:
460
+ """Check if a file path matches test file patterns."""
461
+ for pattern in TEST_FILE_PATTERNS:
462
+ if pattern.search(file_path):
463
+ return True
464
+ return False
465
+
466
+ def _classify_test_files(self, code_map: dict) -> tuple[set, set]:
467
+ """Classify files into test and production sets."""
468
+ test_files = set()
469
+ prod_files = set()
470
+ for fpath in code_map.get("files", {}):
471
+ if self._is_test_file(fpath):
472
+ test_files.add(fpath)
473
+ else:
474
+ prod_files.add(fpath)
475
+ return test_files, prod_files
476
+
477
+ def _find_untested_symbols(
478
+ self, code_map: dict, changed_files: set | None = None
479
+ ) -> list[dict]:
480
+ """Find production symbols that lack test coverage.
481
+
482
+ Returns list of {"file": str, "name": str, "type": str, "confidence": str}.
483
+ """
484
+ test_files, prod_files = self._classify_test_files(code_map)
485
+
486
+ # Build set of tested symbol names from test files
487
+ tested_names: set[str] = set()
488
+ for fpath in test_files:
489
+ for sym in code_map.get("files", {}).get(fpath, {}).get("symbols", []):
490
+ name = sym.get("name", "")
491
+ # Strip test_ prefix to get production name
492
+ if name.startswith("test_"):
493
+ tested_names.add(name[5:])
494
+ # Strip Test suffix for class-based tests
495
+ elif name.endswith("Test"):
496
+ tested_names.add(name[:-4].lower())
497
+
498
+ # Build set of files that have corresponding test files
499
+ tested_file_stems: set[str] = set()
500
+ for fpath in test_files:
501
+ stem = Path(fpath).stem
502
+ # test_foo -> foo, foo_test -> foo, foo.test -> foo, foo.spec -> foo
503
+ for prefix in ("test_",):
504
+ if stem.startswith(prefix):
505
+ tested_file_stems.add(stem[len(prefix) :])
506
+ for suffix in ("_test", ".test", ".spec"):
507
+ if stem.endswith(suffix):
508
+ tested_file_stems.add(stem[: -len(suffix)])
509
+
510
+ untested = []
511
+ for fpath in prod_files:
512
+ if changed_files is not None and fpath not in changed_files:
513
+ continue
514
+
515
+ file_stem = Path(fpath).stem
516
+ has_test_file = file_stem in tested_file_stems
517
+
518
+ for sym in code_map.get("files", {}).get(fpath, {}).get("symbols", []):
519
+ sym_type = sym.get("type", "")
520
+ sym_name = sym.get("name", "")
521
+
522
+ # Only check functions and methods
523
+ if sym_type not in ("function", "method"):
524
+ continue
525
+ # Skip private/dunder
526
+ if sym_name.startswith("_"):
527
+ continue
528
+
529
+ if sym_name.lower() not in tested_names:
530
+ confidence = "PARTIAL" if has_test_file else "NO_TEST_FILE"
531
+ untested.append(
532
+ {
533
+ "file": fpath,
534
+ "name": sym_name,
535
+ "type": sym_type,
536
+ "confidence": confidence,
537
+ }
538
+ )
539
+
540
+ return untested
541
+
542
+
543
+ # ==============================================================================
544
+ # MCP TOOLS
545
+ # ==============================================================================
546
+
547
+
548
+ @mcp.tool()
549
+ def codegraph_scan(
550
+ path: str,
551
+ ignore_patterns: list[str] | None = None,
552
+ git_only: bool = False,
553
+ use_gitignore: bool = False,
554
+ max_depth: int = 0,
555
+ detail_level: str = "minimal",
556
+ ) -> str:
557
+ """Scan a codebase and generate a structural map with all symbols.
558
+
559
+ Use this tool first when starting work on any codebase. It creates a
560
+ .codegraph.json index file containing all functions, classes, and methods
561
+ with their exact line numbers.
562
+
563
+ Args:
564
+ path: Root directory to scan (absolute or relative path)
565
+ ignore_patterns: Glob patterns to ignore (e.g., ['*.test.py', 'vendor/'])
566
+ git_only: Only scan files tracked by git
567
+ use_gitignore: Also ignore patterns from .gitignore
568
+ max_depth: Maximum directory depth to display (0=unlimited)
569
+ detail_level: Output detail: 'minimal' (default), 'standard', or 'verbose'
570
+
571
+ Returns:
572
+ Token-efficient summary showing file tree with symbol metadata
573
+ """
574
+ handler = get_handler()
575
+ detail_level = _validate_detail_level(detail_level)
576
+
577
+ # Clamp max_depth to safe bounds
578
+ if max_depth <= 0:
579
+ max_depth = MAX_DEPTH
580
+ max_depth = max(1, min(max_depth, MAX_DEPTH))
581
+
582
+ try:
583
+ abs_path = os.path.abspath(path)
584
+ navigator = CodeNavigator(
585
+ abs_path,
586
+ ignore_patterns=ignore_patterns or [],
587
+ git_only=git_only,
588
+ use_gitignore=use_gitignore,
589
+ )
590
+ code_map = navigator.scan()
591
+ handler._code_map_cache[abs_path] = code_map
592
+
593
+ # Persist to disk so other tools can find it
594
+ map_path = handler._get_map_path(abs_path)
595
+ with open(map_path, "w", encoding="utf-8") as f:
596
+ json.dump(code_map, f, indent=2)
597
+
598
+ # Use token-efficient rendering if available
599
+ if HAS_RENDERER:
600
+ renderer = TokenEfficientRenderer(code_map, root_path=abs_path)
601
+ result = renderer.render_skeleton_tree(
602
+ max_depth=max_depth,
603
+ show_meta=True,
604
+ show_summary=True,
605
+ )
606
+ else:
607
+ files = code_map.get("files", {})
608
+ total_symbols = sum(len(f.get("symbols", [])) for f in files.values())
609
+ result = (
610
+ f"Scanned {len(files)} files, found {total_symbols} symbols. "
611
+ "Map saved to .codegraph.json"
612
+ )
613
+
614
+ if detail_level == "minimal":
615
+ return result
616
+
617
+ # Standard: add top hubs
618
+ hubs = handler._compute_hubs(code_map, top_n=5, min_imports=2)
619
+ if hubs:
620
+ result += "\n\n--- Top Hubs ---\n"
621
+ result += handler._format_hubs_compact(hubs)
622
+
623
+ if detail_level == "verbose":
624
+ # Add stats
625
+ map_file = handler._get_map_path(abs_path)
626
+ searcher = CodeSearcher(str(map_file))
627
+ stats = searcher.get_stats()
628
+ result += "\n\n--- Stats ---\n"
629
+ result += handler._format_stats_standard(stats, code_map)
630
+
631
+ return result
632
+
633
+ except Exception as e:
634
+ logger.exception(f"Error scanning {path}")
635
+ return f"Error: {e}"
636
+
637
+
638
+ @mcp.tool()
639
+ def codegraph_search(
640
+ query: str,
641
+ symbol_type: str = "any",
642
+ file_pattern: str | None = None,
643
+ limit: int = 20,
644
+ path: str | None = None,
645
+ detail_level: str = "minimal",
646
+ ) -> str:
647
+ """Search for symbols (functions, classes, methods) by name or pattern.
648
+
649
+ Use this after scanning to find where specific code is defined.
650
+ Returns compact file:line locations for efficient reading.
651
+
652
+ Args:
653
+ query: Search query (name, pattern, or regex)
654
+ symbol_type: Filter by type: 'function', 'class', 'method', 'variable', or 'any'
655
+ file_pattern: Filter by file glob pattern (e.g., '*.py', 'src/**/*.ts')
656
+ limit: Maximum results to return
657
+ path: Root directory (uses current dir if not specified)
658
+ detail_level: Output detail: 'minimal' (default), 'standard', or 'verbose'
659
+
660
+ Returns:
661
+ Compact list: file:L{start}-{end} [type] name
662
+ """
663
+ handler = get_handler()
664
+ detail_level = _validate_detail_level(detail_level)
665
+
666
+ # Clamp limit to safe bounds
667
+ limit = max(1, min(limit, MAX_LIMIT))
668
+
669
+ search_path = os.path.abspath(path or handler.workspace_root)
670
+
671
+ # Check if map exists
672
+ exists, error_msg = handler._check_map_exists(search_path)
673
+ if not exists:
674
+ return error_msg
675
+
676
+ try:
677
+ map_path = handler._get_map_path(search_path)
678
+ searcher = CodeSearcher(str(map_path))
679
+
680
+ # Search based on type
681
+ if symbol_type == "any":
682
+ results = searcher.search_symbol(query, limit=limit)
683
+ else:
684
+ results = searcher.search_symbol(query, symbol_type=symbol_type, limit=limit)
685
+
686
+ # Filter by file pattern if specified
687
+ if file_pattern:
688
+ import fnmatch
689
+
690
+ results = [r for r in results if fnmatch.fnmatch(r.file, file_pattern)]
691
+
692
+ if detail_level == "verbose":
693
+ return handler._format_search_results_verbose(results, limit)
694
+ elif detail_level == "standard":
695
+ return handler._format_search_results_standard(results, limit)
696
+ else:
697
+ return handler._format_search_results_compact(results, limit)
698
+
699
+ except Exception as e:
700
+ logger.exception(f"Error searching for {query}")
701
+ return f"Error: {e}"
702
+
703
+
704
+ @mcp.tool()
705
+ def codegraph_read(
706
+ file_path: str,
707
+ start_line: int,
708
+ end_line: int,
709
+ context: int = 0,
710
+ detail_level: str = "minimal",
711
+ ) -> dict:
712
+ """Read specific lines from a file with optional context.
713
+
714
+ Use this after finding a symbol's location to read its implementation.
715
+ Much more token-efficient than reading entire files.
716
+
717
+ Args:
718
+ file_path: Path to the file to read
719
+ start_line: First line to read (1-indexed)
720
+ end_line: Last line to read (inclusive)
721
+ context: Additional lines before/after the range
722
+ detail_level: Output detail: 'minimal' (default), 'standard' adds symbol context
723
+
724
+ Returns:
725
+ Dict with file, requested/actual ranges, total_lines, and lines
726
+ (each line has num, content, in_range fields)
727
+ """
728
+ handler = get_handler()
729
+ detail_level = _validate_detail_level(detail_level)
730
+
731
+ try:
732
+ reader = LineReader(root_path=handler.workspace_root)
733
+ content = reader.read_lines(file_path, start_line, end_line, context=context)
734
+
735
+ if detail_level in ("standard", "verbose"):
736
+ # Try to find the containing symbol
737
+ # Search up for .codegraph.json
738
+ search_dir = os.path.dirname(os.path.abspath(file_path)) or handler.workspace_root
739
+ map_dir = search_dir
740
+ while map_dir and map_dir != "/":
741
+ if handler._get_map_path(map_dir).exists():
742
+ break
743
+ map_dir = os.path.dirname(map_dir)
744
+
745
+ if map_dir and map_dir != "/":
746
+ code_map = handler._get_code_map(map_dir)
747
+ abs_file = os.path.abspath(file_path)
748
+ rel_path = os.path.relpath(abs_file, map_dir)
749
+ sym = handler._find_containing_symbol(code_map, rel_path, start_line)
750
+ if sym:
751
+ content["symbol_context"] = sym
752
+
753
+ return content
754
+
755
+ except Exception as e:
756
+ logger.exception(f"Error reading {file_path}")
757
+ return {"error": str(e)}
758
+
759
+
760
+ @mcp.tool()
761
+ def codegraph_stats(
762
+ path: str | None = None,
763
+ detail_level: str = "minimal",
764
+ ) -> str:
765
+ """Get statistics about the indexed codebase.
766
+
767
+ Shows file count, symbol count, and breakdown by type.
768
+ Useful for understanding project size before diving in.
769
+
770
+ Args:
771
+ path: Root directory (uses current dir if not specified)
772
+ detail_level: Output detail: 'minimal' (default), 'standard', or 'verbose'
773
+
774
+ Returns:
775
+ Compact stats: files, symbols, breakdown by type
776
+ """
777
+ handler = get_handler()
778
+ detail_level = _validate_detail_level(detail_level)
779
+ stats_path = os.path.abspath(path or handler.workspace_root)
780
+
781
+ # Check if map exists
782
+ exists, error_msg = handler._check_map_exists(stats_path)
783
+ if not exists:
784
+ return error_msg
785
+
786
+ try:
787
+ map_path = handler._get_map_path(stats_path)
788
+ searcher = CodeSearcher(str(map_path))
789
+ stats = searcher.get_stats()
790
+
791
+ if detail_level == "verbose":
792
+ code_map = handler._get_code_map(stats_path)
793
+ return handler._format_stats_verbose(stats, code_map)
794
+ elif detail_level == "standard":
795
+ code_map = handler._get_code_map(stats_path)
796
+ return handler._format_stats_standard(stats, code_map)
797
+ else:
798
+ return handler._format_stats_compact(stats)
799
+
800
+ except Exception as e:
801
+ logger.exception(f"Error getting stats for {stats_path}")
802
+ return f"Error: {e}"
803
+
804
+
805
+ @mcp.tool()
806
+ def codegraph_get_hubs(
807
+ path: str,
808
+ top_n: int = 10,
809
+ min_imports: int = 3,
810
+ detail_level: str = "minimal",
811
+ ) -> str:
812
+ """Identify architectural hub files - the most central files in the codebase.
813
+
814
+ Hub files are heavily imported by other files, making them critical
815
+ for understanding the architecture. Review these first.
816
+
817
+ Args:
818
+ path: Root directory to analyze
819
+ top_n: Number of top hubs to return
820
+ min_imports: Minimum import count to be considered a hub
821
+ detail_level: Output detail: 'minimal' (default), 'standard', or 'verbose'
822
+
823
+ Returns:
824
+ Ranked list of hub files with import counts
825
+ """
826
+ handler = get_handler()
827
+ detail_level = _validate_detail_level(detail_level)
828
+ abs_path = os.path.abspath(path)
829
+
830
+ # Check if map exists
831
+ exists, error_msg = handler._check_map_exists(abs_path)
832
+ if not exists:
833
+ return error_msg
834
+
835
+ try:
836
+ code_map = handler._get_code_map(abs_path)
837
+ hubs = handler._compute_hubs(code_map, top_n=top_n, min_imports=min_imports)
838
+
839
+ if detail_level == "verbose":
840
+ # Standard + dependency info per hub
841
+ result = handler._format_hubs_standard(hubs)
842
+ if result != "No hub files found.":
843
+ result += "\n\n--- Hub Dependencies ---"
844
+ for hub in hubs[:5]:
845
+ file_info = code_map.get("files", {}).get(hub["file"], {})
846
+ imports = file_info.get("imports", [])
847
+ if imports:
848
+ result += f"\n{hub['file']} imports: {', '.join(imports[:10])}"
849
+ if len(imports) > 10:
850
+ result += f" +{len(imports)-10} more"
851
+ return result
852
+ elif detail_level == "standard":
853
+ return handler._format_hubs_standard(hubs)
854
+ else:
855
+ return handler._format_hubs_compact(hubs)
856
+
857
+ except Exception as e:
858
+ logger.exception(f"Error getting hubs for {path}")
859
+ return f"Error: {e}"
860
+
861
+
862
+ @mcp.tool()
863
+ def codegraph_get_dependencies(
864
+ path: str,
865
+ file: str | None = None,
866
+ direction: str = "both",
867
+ depth: int = 1,
868
+ detail_level: str = "minimal",
869
+ ) -> str:
870
+ """Get the dependency graph for a file or the entire project.
871
+
872
+ Shows what a file imports and what imports it.
873
+ Useful for understanding coupling between modules.
874
+
875
+ Args:
876
+ path: Root directory or specific file to analyze
877
+ file: Specific file to get dependencies for (optional)
878
+ direction: 'imports', 'imported_by', or 'both'
879
+ depth: How many levels deep to traverse
880
+ detail_level: Output detail: 'minimal' (default), 'standard', or 'verbose'
881
+
882
+ Returns:
883
+ Import/export relationships in compact format
884
+ """
885
+ handler = get_handler()
886
+ detail_level = _validate_detail_level(detail_level)
887
+ abs_path = os.path.abspath(path)
888
+
889
+ # Check if map exists
890
+ exists, error_msg = handler._check_map_exists(abs_path)
891
+ if not exists:
892
+ return error_msg
893
+
894
+ try:
895
+ code_map = handler._get_code_map(abs_path)
896
+
897
+ if file:
898
+ # Find the file
899
+ file_info = None
900
+ matched_path = None
901
+ for fpath, info in code_map.get("files", {}).items():
902
+ if fpath == file or file in fpath:
903
+ file_info = info
904
+ matched_path = fpath
905
+ break
906
+
907
+ if not file_info:
908
+ return f"File not found: {file}"
909
+
910
+ lines = [f"Dependencies for {matched_path}:"]
911
+
912
+ if direction in ("imports", "both"):
913
+ imports = file_info.get("imports", [])
914
+ lines.append(f"imports ({len(imports)}): {', '.join(imports[:10])}")
915
+ if len(imports) > 10:
916
+ lines[-1] += f" +{len(imports)-10} more"
917
+
918
+ if direction in ("imported_by", "both"):
919
+ imported_by = []
920
+ for fpath, info in code_map.get("files", {}).items():
921
+ if matched_path in info.get("imports", []):
922
+ imported_by.append(fpath)
923
+ lines.append(f"imported_by ({len(imported_by)}): {', '.join(imported_by[:10])}")
924
+ if len(imported_by) > 10:
925
+ lines[-1] += f" +{len(imported_by)-10} more"
926
+
927
+ if detail_level in ("standard", "verbose"):
928
+ # Add bidirectional summary
929
+ imports = file_info.get("imports", [])
930
+ imported_by_count = sum(
931
+ 1
932
+ for info in code_map.get("files", {}).values()
933
+ if matched_path in info.get("imports", [])
934
+ )
935
+ lines.append(f"summary: {len(imports)} imports, {imported_by_count} importers")
936
+
937
+ if detail_level == "verbose" and depth > 1:
938
+ # Add transitive dependencies
939
+ lines.append(f"\ntransitive (depth={depth}):")
940
+ visited = {matched_path}
941
+ frontier = file_info.get("imports", [])
942
+ for d in range(1, depth):
943
+ next_frontier = []
944
+ for dep in frontier:
945
+ if dep not in visited:
946
+ visited.add(dep)
947
+ dep_info = code_map.get("files", {}).get(dep, {})
948
+ sub_imports = dep_info.get("imports", [])
949
+ lines.append(f" {' ' * d}{dep} → [{', '.join(sub_imports[:5])}]")
950
+ next_frontier.extend(sub_imports)
951
+ frontier = next_frontier
952
+ if not frontier:
953
+ break
954
+
955
+ return "\n".join(lines)
956
+ else:
957
+ # Project-wide summary
958
+ files = code_map.get("files", {})
959
+
960
+ if detail_level in ("standard", "verbose"):
961
+ # Bidirectional counts
962
+ import_counts: dict[str, int] = {}
963
+ for fpath, info in files.items():
964
+ for imp in info.get("imports", []):
965
+ import_counts[imp] = import_counts.get(imp, 0) + 1
966
+
967
+ conn_full = []
968
+ for fpath, info in files.items():
969
+ out_deg = len(info.get("imports", []))
970
+ in_deg = import_counts.get(fpath, 0)
971
+ conn_full.append((fpath, out_deg, in_deg))
972
+ conn_full.sort(key=lambda x: x[1] + x[2], reverse=True)
973
+
974
+ lines = [f"Project dependencies ({len(files)} files):", "Most connected:"]
975
+ for fpath, out_d, in_d in conn_full[:15]:
976
+ lines.append(f" {fpath}: {out_d} imports, {in_d} importers")
977
+ return "\n".join(lines)
978
+ else:
979
+ conn_simple = [
980
+ (fpath, len(info.get("imports", []))) for fpath, info in files.items()
981
+ ]
982
+ conn_simple.sort(key=lambda x: x[1], reverse=True)
983
+
984
+ lines = [f"Project dependencies ({len(files)} files):", "Most connected:"]
985
+ for fpath, count in conn_simple[:10]:
986
+ lines.append(f" {fpath}: {count} imports")
987
+
988
+ return "\n".join(lines)
989
+
990
+ except Exception as e:
991
+ logger.exception(f"Error getting dependencies for {path}")
992
+ return f"Error: {e}"
993
+
994
+
995
+ @mcp.tool()
996
+ def codegraph_get_structure(
997
+ file_path: str,
998
+ include_private: bool = False,
999
+ detail_level: str = "minimal",
1000
+ ) -> str:
1001
+ """Get the structure of a specific file showing all its symbols.
1002
+
1003
+ Use this to see what's in a file before reading it.
1004
+ Helps decide which parts to read in detail.
1005
+
1006
+ Args:
1007
+ file_path: Path to the file to analyze
1008
+ include_private: Include private symbols (starting with _)
1009
+ detail_level: Output detail: 'minimal' (default), 'standard' adds signatures, 'verbose' adds docstrings
1010
+
1011
+ Returns:
1012
+ Hierarchical list of symbols with types and line numbers
1013
+ """
1014
+ handler = get_handler()
1015
+ detail_level = _validate_detail_level(detail_level)
1016
+
1017
+ # Determine the project root from file path
1018
+ file_dir = os.path.dirname(os.path.abspath(file_path)) or handler.workspace_root
1019
+
1020
+ # Check if map exists (try parent directories)
1021
+ search_path = file_dir
1022
+ map_found = False
1023
+ while search_path and search_path != "/":
1024
+ if handler._get_map_path(search_path).exists():
1025
+ map_found = True
1026
+ break
1027
+ search_path = os.path.dirname(search_path)
1028
+
1029
+ if not map_found:
1030
+ return "No .codegraph.json found. Run `codegraph_scan` first to index the codebase."
1031
+
1032
+ try:
1033
+ code_map = handler._get_code_map(search_path)
1034
+ abs_file_path = os.path.abspath(file_path)
1035
+ rel_path = os.path.relpath(abs_file_path, search_path)
1036
+
1037
+ # Find the file in the code map
1038
+ file_info = None
1039
+ for fpath, info in code_map.get("files", {}).items():
1040
+ if fpath == rel_path or fpath == abs_file_path or rel_path in fpath:
1041
+ file_info = info
1042
+ break
1043
+
1044
+ if not file_info:
1045
+ return f"File not found in code map: {file_path}"
1046
+
1047
+ symbols = file_info.get("symbols", [])
1048
+ if not include_private:
1049
+ symbols = [s for s in symbols if not s["name"].startswith("_")]
1050
+
1051
+ # Group and format
1052
+ classes = [s for s in symbols if s.get("type") == "class"]
1053
+ functions = [s for s in symbols if s.get("type") == "function"]
1054
+ methods = [s for s in symbols if s.get("type") == "method"]
1055
+
1056
+ lines = [f"Structure of {rel_path}:"]
1057
+
1058
+ def _format_sym(s: dict) -> str:
1059
+ end = s.get("end_line", s["lines"][1] if len(s.get("lines", [])) > 1 else "?")
1060
+ base = f" {s['name']} L{s['lines'][0]}-{end}"
1061
+ if detail_level in ("standard", "verbose") and s.get("signature"):
1062
+ base += f" :: {s['signature']}"
1063
+ return base
1064
+
1065
+ def _format_doc(s: dict) -> str | None:
1066
+ if detail_level == "verbose" and s.get("docstring"):
1067
+ doc = (
1068
+ s["docstring"][:80] + "..."
1069
+ if len(s.get("docstring", "")) > 80
1070
+ else s.get("docstring", "")
1071
+ )
1072
+ return f" {doc}"
1073
+ return None
1074
+
1075
+ if classes:
1076
+ lines.append(f"classes ({len(classes)}):")
1077
+ for c in classes:
1078
+ lines.append(_format_sym(c))
1079
+ doc = _format_doc(c)
1080
+ if doc:
1081
+ lines.append(doc)
1082
+
1083
+ if functions:
1084
+ lines.append(f"functions ({len(functions)}):")
1085
+ for f in functions:
1086
+ lines.append(_format_sym(f))
1087
+ doc = _format_doc(f)
1088
+ if doc:
1089
+ lines.append(doc)
1090
+
1091
+ if methods:
1092
+ lines.append(f"methods ({len(methods)}):")
1093
+ limit = 15 if detail_level == "minimal" else 30
1094
+ for m in methods[:limit]:
1095
+ lines.append(_format_sym(m))
1096
+ doc = _format_doc(m)
1097
+ if doc:
1098
+ lines.append(doc)
1099
+ if len(methods) > limit:
1100
+ lines.append(f" ... +{len(methods)-limit} more")
1101
+
1102
+ return "\n".join(lines)
1103
+
1104
+ except Exception as e:
1105
+ logger.exception(f"Error getting structure for {file_path}")
1106
+ return f"Error: {e}"
1107
+
1108
+
1109
+ # ==============================================================================
1110
+ # NEW TOOLS - Phase A
1111
+ # ==============================================================================
1112
+
1113
+
1114
+ @mcp.tool()
1115
+ def codegraph_get_minimal_context(
1116
+ path: str,
1117
+ task: str = "",
1118
+ base: str = "HEAD~1",
1119
+ ) -> str:
1120
+ """Get a quick ~100 token orientation for any codebase.
1121
+
1122
+ Always call this first. Returns project stats, top hubs, recent changes,
1123
+ and suggested next tools based on your task.
1124
+
1125
+ Args:
1126
+ path: Root directory of the project
1127
+ task: What you're trying to do (e.g., "fix auth bug", "add feature")
1128
+ base: Git ref for change detection (default: HEAD~1)
1129
+
1130
+ Returns:
1131
+ Compact project primer under 100 tokens
1132
+ """
1133
+ handler = get_handler()
1134
+ abs_path = os.path.abspath(path)
1135
+
1136
+ # Check if map exists
1137
+ exists, error_msg = handler._check_map_exists(abs_path)
1138
+ if not exists:
1139
+ return f'no index. run codegraph_scan(path="{path}") first'
1140
+
1141
+ try:
1142
+ code_map = handler._get_code_map(abs_path)
1143
+ files = code_map.get("files", {})
1144
+ lines_out = []
1145
+
1146
+ # Project primer: files, symbols, languages
1147
+ total_symbols = sum(len(f.get("symbols", [])) for f in files.values())
1148
+ ext_counts: Counter = Counter()
1149
+ for fpath in files:
1150
+ ext = Path(fpath).suffix.lstrip(".")
1151
+ if ext:
1152
+ ext_counts[ext] += 1
1153
+ top_langs = ",".join(ext for ext, _ in ext_counts.most_common(3))
1154
+ lines_out.append(f"project: {len(files)} files · {total_symbols} symbols · {top_langs}")
1155
+
1156
+ # Top 3 hubs
1157
+ hubs = handler._compute_hubs(code_map, top_n=3, min_imports=2)
1158
+ if hubs:
1159
+ hub_strs = [f"{h['file']}({h['imports']}←)" for h in hubs]
1160
+ lines_out.append(f"hubs: {', '.join(hub_strs)}")
1161
+
1162
+ # Git changes
1163
+ try:
1164
+ map_path = handler._get_map_path(abs_path)
1165
+ searcher = CodeSearcher(str(map_path))
1166
+ changes = searcher.get_changes_since_commit(base, abs_path)
1167
+ if not changes.get("error") and changes.get("total_changed", 0) > 0:
1168
+ changed_syms = sum(
1169
+ len(f.get("symbols", [])) for f in changes.get("changed_files", [])
1170
+ )
1171
+ lines_out.append(
1172
+ f"changes({base}): {changes['total_changed']} files · "
1173
+ f"{changed_syms} symbols modified"
1174
+ )
1175
+ except Exception:
1176
+ pass # Git not available, skip changes line
1177
+
1178
+ # Tool suggestions based on task keywords
1179
+ if task:
1180
+ task_lower = task.lower()
1181
+ if any(kw in task_lower for kw in ("bug", "fix", "error", "crash", "fail")):
1182
+ suggest = "codegraph_search → codegraph_read → codegraph_get_dependencies"
1183
+ elif any(kw in task_lower for kw in ("add", "feature", "implement", "create", "new")):
1184
+ suggest = "codegraph_get_structure → codegraph_search → codegraph_read"
1185
+ elif any(kw in task_lower for kw in ("review", "pr", "diff", "change")):
1186
+ suggest = "codegraph_test_gaps → codegraph_search → codegraph_read"
1187
+ elif any(kw in task_lower for kw in ("understand", "architecture", "how", "what")):
1188
+ suggest = (
1189
+ "codegraph_get_hubs → codegraph_get_dependencies → codegraph_get_structure"
1190
+ )
1191
+ elif any(kw in task_lower for kw in ("onboard", "learn", "explore")):
1192
+ suggest = "codegraph_stats → codegraph_get_hubs → codegraph_get_structure"
1193
+ else:
1194
+ suggest = "codegraph_search → codegraph_read → codegraph_get_dependencies"
1195
+ lines_out.append(f"suggest: {suggest}")
1196
+
1197
+ return "\n".join(lines_out)
1198
+
1199
+ except Exception as e:
1200
+ logger.exception(f"Error getting minimal context for {path}")
1201
+ return f"Error: {e}"
1202
+
1203
+
1204
+ @mcp.tool()
1205
+ def codegraph_test_gaps(
1206
+ path: str,
1207
+ changed_only: bool = False,
1208
+ base: str = "HEAD~1",
1209
+ detail_level: str = "minimal",
1210
+ ) -> str:
1211
+ """Find symbols that lack test coverage using index-based heuristics.
1212
+
1213
+ Matches production symbols against test symbols by naming convention
1214
+ (test_foo tests foo). No code execution needed.
1215
+
1216
+ Args:
1217
+ path: Root directory of the project
1218
+ changed_only: Only check symbols in files changed since base
1219
+ base: Git ref for change detection (default: HEAD~1)
1220
+ detail_level: Output detail: 'minimal' (default) or 'standard' for per-file breakdown
1221
+
1222
+ Returns:
1223
+ Test gap summary with untested symbol names
1224
+ """
1225
+ handler = get_handler()
1226
+ detail_level = _validate_detail_level(detail_level)
1227
+ abs_path = os.path.abspath(path)
1228
+
1229
+ # Check if map exists
1230
+ exists, error_msg = handler._check_map_exists(abs_path)
1231
+ if not exists:
1232
+ return error_msg
1233
+
1234
+ try:
1235
+ code_map = handler._get_code_map(abs_path)
1236
+
1237
+ # Get changed files if needed
1238
+ changed_files = None
1239
+ if changed_only:
1240
+ try:
1241
+ map_path = handler._get_map_path(abs_path)
1242
+ searcher = CodeSearcher(str(map_path))
1243
+ changes = searcher.get_changes_since_commit(base, abs_path)
1244
+ if not changes.get("error"):
1245
+ changed_files = {f["file"] for f in changes.get("changed_files", [])}
1246
+ else:
1247
+ return f"Git error: {changes['error']}"
1248
+ except Exception as e:
1249
+ return f"Error detecting changes: {e}"
1250
+
1251
+ untested = handler._find_untested_symbols(code_map, changed_files)
1252
+
1253
+ # Count total production symbols for context
1254
+ test_files, prod_files = handler._classify_test_files(code_map)
1255
+ total_prod_symbols = 0
1256
+ for fpath in prod_files:
1257
+ if changed_files is not None and fpath not in changed_files:
1258
+ continue
1259
+ for sym in code_map.get("files", {}).get(fpath, {}).get("symbols", []):
1260
+ if sym.get("type") in ("function", "method") and not sym.get("name", "").startswith(
1261
+ "_"
1262
+ ):
1263
+ total_prod_symbols += 1
1264
+
1265
+ scope = "changed" if changed_only else "total"
1266
+
1267
+ if detail_level in ("standard", "verbose"):
1268
+ # Per-file breakdown
1269
+ by_file: dict[str, list[dict]] = {}
1270
+ for item in untested:
1271
+ by_file.setdefault(item["file"], []).append(item)
1272
+
1273
+ lines = [
1274
+ f"{len(untested)} untested symbols (of {total_prod_symbols} {scope})",
1275
+ f"test files: {len(test_files)} | production files: {len(prod_files)}",
1276
+ "",
1277
+ ]
1278
+ for fpath, items in sorted(by_file.items()):
1279
+ file_total = 0
1280
+ for sym in code_map.get("files", {}).get(fpath, {}).get("symbols", []):
1281
+ if sym.get("type") in ("function", "method") and not sym.get(
1282
+ "name", ""
1283
+ ).startswith("_"):
1284
+ file_total += 1
1285
+ confidence = items[0]["confidence"] if items else "UNKNOWN"
1286
+ lines.append(f"{fpath}: {len(items)}/{file_total} untested [{confidence}]")
1287
+ names = [item["name"] for item in items[:10]]
1288
+ lines.append(f" untested: {', '.join(names)}")
1289
+ if len(items) > 10:
1290
+ lines[-1] += f" +{len(items)-10} more"
1291
+
1292
+ return "\n".join(lines)
1293
+ else:
1294
+ # Minimal: one-line summary
1295
+ gap_names = [item["name"] for item in untested[:10]]
1296
+ gaps_str = ", ".join(gap_names)
1297
+ if len(untested) > 10:
1298
+ gaps_str += f" +{len(untested)-10} more"
1299
+ return (
1300
+ f"{len(untested)} untested symbols (of {total_prod_symbols} {scope}) "
1301
+ f"| gaps: {gaps_str}"
1302
+ )
1303
+
1304
+ except Exception as e:
1305
+ logger.exception(f"Error analyzing test gaps for {path}")
1306
+ return f"Error: {e}"
1307
+
1308
+
1309
+ # ==============================================================================
1310
+ # NEW TOOLS - Phase B (Graph Intelligence)
1311
+ # ==============================================================================
1312
+
1313
+ # Lazy graph store cache on handler
1314
+ _graph_store_cache: dict[str, "GraphStore"] = {}
1315
+
1316
+
1317
+ def _get_graph_store(path: str, auto_build: bool = True) -> "GraphStore | None":
1318
+ """Get or create a GraphStore for the given path. Auto-builds from JSON if needed."""
1319
+ from ..graph import GraphBuilder, GraphStore
1320
+
1321
+ abs_path = os.path.abspath(path)
1322
+ if abs_path in _graph_store_cache:
1323
+ return _graph_store_cache[abs_path]
1324
+
1325
+ db_path = Path(abs_path) / ".codegraph.db"
1326
+ json_path = Path(abs_path) / ".codegraph.json"
1327
+
1328
+ if db_path.exists():
1329
+ store = GraphStore(db_path)
1330
+ _graph_store_cache[abs_path] = store
1331
+ return store
1332
+
1333
+ if auto_build and json_path.exists():
1334
+ # Lazy build from JSON
1335
+ import json as _json
1336
+
1337
+ with open(json_path, encoding="utf-8") as f:
1338
+ code_map = _json.load(f)
1339
+ store = GraphStore(db_path)
1340
+ builder = GraphBuilder(store)
1341
+ builder.build_from_code_map(code_map, root_path=abs_path)
1342
+ _graph_store_cache[abs_path] = store
1343
+ return store
1344
+
1345
+ return None
1346
+
1347
+
1348
+ @mcp.tool()
1349
+ def codegraph_graph_build(
1350
+ path: str,
1351
+ mode: str = "lazy",
1352
+ detail_level: str = "minimal",
1353
+ ) -> str:
1354
+ """Build the optional graph database from the code index.
1355
+
1356
+ Creates .codegraph.db with nodes, edges, execution flows, and FTS5 index.
1357
+ Required for blast_radius, detect_changes, list_flows, and search_graph tools.
1358
+
1359
+ Args:
1360
+ path: Root directory of the project
1361
+ mode: 'lazy' (skip unchanged files) or 'full' (rebuild everything)
1362
+ detail_level: Output detail: 'minimal' (default), 'standard', or 'verbose'
1363
+
1364
+ Returns:
1365
+ Build statistics
1366
+ """
1367
+ from ..graph import GraphBuilder, GraphStore
1368
+ from ..graph.flows import store_flows, trace_flows
1369
+
1370
+ detail_level = _validate_detail_level(detail_level)
1371
+ abs_path = os.path.abspath(path)
1372
+ json_path = Path(abs_path) / ".codegraph.json"
1373
+
1374
+ if not json_path.exists():
1375
+ return f'No .codegraph.json found. Run codegraph_scan(path="{path}") first.'
1376
+
1377
+ try:
1378
+ with open(json_path, encoding="utf-8") as f:
1379
+ code_map = json.load(f)
1380
+
1381
+ db_path = Path(abs_path) / ".codegraph.db"
1382
+ store = GraphStore(db_path)
1383
+ builder = GraphBuilder(store)
1384
+ stats = builder.build_from_code_map(
1385
+ code_map, root_path=abs_path, incremental=(mode == "lazy")
1386
+ )
1387
+
1388
+ # Trace flows
1389
+ flows = trace_flows(store)
1390
+ store_flows(store, flows)
1391
+ stats["flows_detected"] = len(flows)
1392
+
1393
+ _graph_store_cache[abs_path] = store
1394
+
1395
+ if detail_level == "minimal":
1396
+ return (
1397
+ f"graph built: {stats['nodes_created']} nodes · {stats['edges_created']} edges "
1398
+ f"· {stats.get('flows_detected', 0)} flows · {stats['build_time']}s"
1399
+ )
1400
+ else:
1401
+ lines = [
1402
+ f"Graph built in {stats['build_time']}s:",
1403
+ f" nodes: {stats['nodes_created']}",
1404
+ f" edges: {stats['edges_created']}",
1405
+ f" flows: {stats.get('flows_detected', 0)}",
1406
+ f" files processed: {stats['files_processed']}",
1407
+ f" files skipped: {stats['files_skipped']}",
1408
+ f" errors: {stats['errors']}",
1409
+ f" fts5: {'available' if store.fts_available else 'unavailable'}",
1410
+ ]
1411
+ return "\n".join(lines)
1412
+
1413
+ except Exception as e:
1414
+ logger.exception(f"Error building graph for {path}")
1415
+ return f"Error: {e}"
1416
+
1417
+
1418
+ @mcp.tool()
1419
+ def codegraph_blast_radius(
1420
+ path: str,
1421
+ changed_files: list[str] | None = None,
1422
+ base: str = "HEAD~1",
1423
+ max_depth: int = 2,
1424
+ detail_level: str = "minimal",
1425
+ ) -> str:
1426
+ """Find what breaks if files change. Uses recursive graph traversal.
1427
+
1428
+ Auto-builds graph DB if not present. Uses git diff if changed_files not specified.
1429
+
1430
+ Args:
1431
+ path: Root directory of the project
1432
+ changed_files: Specific files to analyze (or auto-detect from git diff)
1433
+ base: Git ref for auto-detecting changes (default: HEAD~1)
1434
+ max_depth: How many hops to traverse (default: 2)
1435
+ detail_level: Output detail: 'minimal' (default), 'standard', or 'verbose'
1436
+
1437
+ Returns:
1438
+ Impact analysis showing affected files and nodes
1439
+ """
1440
+ from ..graph.query import format_blast_radius_minimal, get_blast_radius
1441
+
1442
+ detail_level = _validate_detail_level(detail_level)
1443
+ abs_path = os.path.abspath(path)
1444
+
1445
+ store = _get_graph_store(abs_path)
1446
+ if not store:
1447
+ return f'No index found. Run codegraph_scan(path="{path}") first.'
1448
+
1449
+ try:
1450
+ # Auto-detect changed files from git if not specified
1451
+ if not changed_files:
1452
+ try:
1453
+ result = subprocess.run(
1454
+ ["git", "diff", "--name-only", base, "HEAD"],
1455
+ cwd=abs_path,
1456
+ capture_output=True,
1457
+ text=True,
1458
+ timeout=30,
1459
+ )
1460
+ if result.returncode == 0 and result.stdout.strip():
1461
+ changed_files = result.stdout.strip().split("\n")
1462
+ else:
1463
+ return "No changes detected."
1464
+ except (subprocess.TimeoutExpired, FileNotFoundError):
1465
+ return "Git not available. Specify changed_files explicitly."
1466
+
1467
+ blast = get_blast_radius(store, changed_files, max_depth=max_depth)
1468
+
1469
+ if detail_level == "minimal":
1470
+ return format_blast_radius_minimal(blast)
1471
+ else:
1472
+ lines = [format_blast_radius_minimal(blast)]
1473
+ if blast["impacted_files"]:
1474
+ lines.append("\nAll impacted files:")
1475
+ for f in blast["impacted_files"]:
1476
+ lines.append(f" {f}")
1477
+ return "\n".join(lines)
1478
+
1479
+ except Exception as e:
1480
+ logger.exception(f"Error computing blast radius for {path}")
1481
+ return f"Error: {e}"
1482
+
1483
+
1484
+ @mcp.tool()
1485
+ def codegraph_list_flows(
1486
+ path: str,
1487
+ sort_by: str = "criticality",
1488
+ limit: int = 10,
1489
+ detail_level: str = "minimal",
1490
+ ) -> str:
1491
+ """List execution flows with criticality scores.
1492
+
1493
+ Flows trace call chains from entry points (main, route handlers, CLI commands).
1494
+ Criticality scores (0-1) reflect security sensitivity, test coverage, and complexity.
1495
+
1496
+ Args:
1497
+ path: Root directory of the project
1498
+ sort_by: Sort order: 'criticality' (default) or 'name'
1499
+ limit: Maximum flows to return
1500
+ detail_level: Output detail: 'minimal' (default), 'standard', or 'verbose'
1501
+
1502
+ Returns:
1503
+ Ranked list of execution flows with criticality scores
1504
+ """
1505
+ from ..graph.flows import format_flows_minimal, trace_flows
1506
+
1507
+ detail_level = _validate_detail_level(detail_level)
1508
+ abs_path = os.path.abspath(path)
1509
+
1510
+ store = _get_graph_store(abs_path)
1511
+ if not store:
1512
+ return f'No index found. Run codegraph_scan(path="{path}") first.'
1513
+
1514
+ try:
1515
+ # Check if flows exist in DB
1516
+ flows_db = store.get_flows(sort_by=sort_by, limit=limit)
1517
+ if flows_db:
1518
+ flows = []
1519
+ for f in flows_db:
1520
+ path_ids = json.loads(f["path_json"])
1521
+ path_names = []
1522
+ for nid in path_ids:
1523
+ node = store.get_node_by_id(nid)
1524
+ path_names.append(node["name"] if node else "?")
1525
+ flows.append(
1526
+ {
1527
+ "name": f["name"],
1528
+ "entry_point_id": f["entry_point_id"],
1529
+ "path_ids": path_ids,
1530
+ "path_names": path_names,
1531
+ "depth": f["depth"],
1532
+ "node_count": f["node_count"],
1533
+ "file_count": f["file_count"],
1534
+ "criticality": f["criticality"],
1535
+ }
1536
+ )
1537
+ else:
1538
+ # Trace flows on demand
1539
+ flows = trace_flows(store, limit=limit)
1540
+
1541
+ if detail_level == "minimal":
1542
+ return format_flows_minimal(flows, limit=limit)
1543
+ else:
1544
+ from ..graph.flows import format_flow_minimal
1545
+
1546
+ lines = [f"{len(flows)} flows detected:"]
1547
+ for flow in flows[:limit]:
1548
+ lines.append(f" {format_flow_minimal(flow)}")
1549
+ if detail_level == "verbose":
1550
+ lines.append(
1551
+ f" files: {', '.join(str(Path(f).name) for f in flow.get('files', []))}"
1552
+ )
1553
+ if len(flows) > limit:
1554
+ lines.append(f" ... +{len(flows) - limit} more")
1555
+ return "\n".join(lines)
1556
+
1557
+ except Exception as e:
1558
+ logger.exception(f"Error listing flows for {path}")
1559
+ return f"Error: {e}"
1560
+
1561
+
1562
+ @mcp.tool()
1563
+ def codegraph_detect_changes(
1564
+ path: str,
1565
+ base: str = "HEAD~1",
1566
+ detail_level: str = "minimal",
1567
+ ) -> str:
1568
+ """Risk-scored change impact analysis.
1569
+
1570
+ Parses git diff, maps changed lines to symbols, computes risk scores.
1571
+ Risk factors: flow participation, callers, test coverage, security keywords.
1572
+
1573
+ Args:
1574
+ path: Root directory of the project
1575
+ base: Git ref to compare against (default: HEAD~1)
1576
+ detail_level: Output detail: 'minimal' (default), 'standard', or 'verbose'
1577
+
1578
+ Returns:
1579
+ Risk report with scores, test gaps, and affected flows
1580
+ """
1581
+ from ..graph.query import detect_changes, format_changes_minimal
1582
+
1583
+ detail_level = _validate_detail_level(detail_level)
1584
+ abs_path = os.path.abspath(path)
1585
+
1586
+ store = _get_graph_store(abs_path)
1587
+ if not store:
1588
+ return f'No index found. Run codegraph_scan(path="{path}") first.'
1589
+
1590
+ try:
1591
+ result = detect_changes(store, root_path=abs_path, base=base)
1592
+
1593
+ if detail_level == "minimal":
1594
+ return format_changes_minimal(result)
1595
+ else:
1596
+ lines = [format_changes_minimal(result)]
1597
+ if detail_level == "verbose" and result["changed_nodes"]:
1598
+ lines.append("\nAll changed symbols:")
1599
+ for n in result["changed_nodes"]:
1600
+ lines.append(f" {n['file']}::{n['name']} [{n['kind']}] risk:{n['risk']:.2f}")
1601
+ return "\n".join(lines)
1602
+
1603
+ except Exception as e:
1604
+ logger.exception(f"Error detecting changes for {path}")
1605
+ return f"Error: {e}"
1606
+
1607
+
1608
+ @mcp.tool()
1609
+ def codegraph_search_graph(
1610
+ query: str,
1611
+ path: str | None = None,
1612
+ kind: str | None = None,
1613
+ limit: int = 20,
1614
+ detail_level: str = "minimal",
1615
+ ) -> str:
1616
+ """Hybrid search: FTS5 full-text + fuzzy name matching + RRF fusion.
1617
+
1618
+ More powerful than codegraph_search — searches signatures, file paths, and
1619
+ supports stemming (e.g., "authenticate" matches "authentication").
1620
+ Falls back to standard search if no graph DB exists.
1621
+
1622
+ Args:
1623
+ query: Search query (name, concept, or file path pattern)
1624
+ path: Root directory (uses current dir if not specified)
1625
+ kind: Filter by kind: 'Function', 'Class', 'Method', or None for all
1626
+ limit: Maximum results to return
1627
+ detail_level: Output detail: 'minimal' (default), 'standard', or 'verbose'
1628
+
1629
+ Returns:
1630
+ Ranked search results with file:line locations
1631
+ """
1632
+ from ..graph.search import format_search_results_minimal, hybrid_search
1633
+
1634
+ detail_level = _validate_detail_level(detail_level)
1635
+ handler = get_handler()
1636
+ search_path = os.path.abspath(str(path or handler.workspace_root))
1637
+
1638
+ store = _get_graph_store(search_path, auto_build=False)
1639
+ if not store:
1640
+ # Fall back to standard search
1641
+ return str(codegraph_search(query=query, path=path, limit=limit, detail_level=detail_level))
1642
+
1643
+ try:
1644
+ results = hybrid_search(store, query, limit=limit)
1645
+
1646
+ # Filter by kind if specified
1647
+ if kind:
1648
+ results = [r for r in results if r["kind"] == kind]
1649
+
1650
+ if detail_level == "minimal":
1651
+ return format_search_results_minimal(results, limit=limit)
1652
+ else:
1653
+ if not results:
1654
+ return "No matching symbols found."
1655
+ lines = [f"Found {len(results)} matches:"]
1656
+ for r in results[:limit]:
1657
+ abbr = {"Function": "fn", "Class": "cls", "Method": "mth"}.get(
1658
+ r["kind"], r["kind"][:3]
1659
+ )
1660
+ end = r["line_end"] or r["line_start"] or "?"
1661
+ line = f"{r['file_path']}:L{r['line_start']}-{end} [{abbr}] {r['name']}"
1662
+ if detail_level == "verbose" and r.get("signature"):
1663
+ line += f" :: {r['signature']}"
1664
+ lines.append(line)
1665
+ return "\n".join(lines)
1666
+
1667
+ except Exception as e:
1668
+ logger.exception(f"Error in graph search for {query}")
1669
+ return f"Error: {e}"
1670
+
1671
+
1672
+ # ==============================================================================
1673
+ # NEW TOOLS - Phase C (Communities, Domain, Architecture)
1674
+ # ==============================================================================
1675
+
1676
+
1677
+ @mcp.tool()
1678
+ def codegraph_list_communities(
1679
+ path: str,
1680
+ sort_by: str = "size",
1681
+ limit: int = 10,
1682
+ detail_level: str = "minimal",
1683
+ ) -> str:
1684
+ """List code communities with cohesion scores.
1685
+
1686
+ Communities group related code (by directory structure or graph clustering).
1687
+ Cohesion (0-1) measures how tightly connected a community's code is.
1688
+
1689
+ Args:
1690
+ path: Root directory of the project
1691
+ sort_by: Sort by 'size' (default), 'cohesion', or 'name'
1692
+ limit: Maximum communities to return
1693
+ detail_level: Output detail: 'minimal' (default), 'standard', or 'verbose'
1694
+
1695
+ Returns:
1696
+ Community list with sizes, cohesion scores, and keywords
1697
+ """
1698
+ from ..graph.communities import (
1699
+ detect_communities,
1700
+ format_communities_minimal,
1701
+ get_coupling_warnings,
1702
+ store_communities,
1703
+ )
1704
+
1705
+ detail_level = _validate_detail_level(detail_level)
1706
+ abs_path = os.path.abspath(path)
1707
+
1708
+ store = _get_graph_store(abs_path)
1709
+ if not store:
1710
+ return f'No index found. Run codegraph_scan(path="{path}") first.'
1711
+
1712
+ try:
1713
+ # Check if communities already exist in DB
1714
+ db_sort = {"size": "node_count", "cohesion": "cohesion", "name": "name"}.get(
1715
+ sort_by, "node_count"
1716
+ )
1717
+ communities_db = store.get_communities(sort_by=db_sort, limit=limit)
1718
+
1719
+ if communities_db:
1720
+ communities = [
1721
+ {
1722
+ "name": c["name"],
1723
+ "node_count": c["node_count"],
1724
+ "cohesion": c["cohesion"],
1725
+ "file_prefix": c["file_prefix"],
1726
+ "keywords": json.loads(c["keywords"]) if c["keywords"] else [],
1727
+ "member_ids": [],
1728
+ }
1729
+ for c in communities_db
1730
+ ]
1731
+ else:
1732
+ # Detect on demand
1733
+ communities = detect_communities(store)
1734
+ store_communities(store, communities)
1735
+
1736
+ output = format_communities_minimal(communities, limit=limit)
1737
+
1738
+ if detail_level in ("standard", "verbose"):
1739
+ warnings = get_coupling_warnings(store, communities)
1740
+ if warnings:
1741
+ output += "\n\n--- Coupling ---"
1742
+ for w in warnings[:5]:
1743
+ output += f"\n {w}"
1744
+
1745
+ return output
1746
+
1747
+ except Exception as e:
1748
+ logger.exception(f"Error listing communities for {path}")
1749
+ return f"Error: {e}"
1750
+
1751
+
1752
+ @mcp.tool()
1753
+ def codegraph_get_community(
1754
+ path: str,
1755
+ community_id: int,
1756
+ detail_level: str = "minimal",
1757
+ ) -> str:
1758
+ """Get details of a specific community: members, cohesion, coupling.
1759
+
1760
+ Args:
1761
+ path: Root directory of the project
1762
+ community_id: Community ID (from codegraph_list_communities)
1763
+ detail_level: Output detail: 'minimal' (default), 'standard', or 'verbose'
1764
+
1765
+ Returns:
1766
+ Community details with member list
1767
+ """
1768
+ detail_level = _validate_detail_level(detail_level)
1769
+ abs_path = os.path.abspath(path)
1770
+
1771
+ store = _get_graph_store(abs_path)
1772
+ if not store:
1773
+ return f'No index found. Run codegraph_scan(path="{path}") first.'
1774
+
1775
+ try:
1776
+ members = store.get_community_members(community_id)
1777
+ if not members:
1778
+ return f"Community {community_id} not found."
1779
+
1780
+ # Get community info
1781
+ comm_row = store.conn.execute(
1782
+ "SELECT * FROM communities WHERE id = ?", (community_id,)
1783
+ ).fetchone()
1784
+
1785
+ if not comm_row:
1786
+ return f"Community {community_id} not found."
1787
+
1788
+ lines = [
1789
+ f"Community: {comm_row['name']}",
1790
+ f"size: {comm_row['node_count']} · cohesion: {comm_row['cohesion']:.2f}",
1791
+ ]
1792
+
1793
+ if detail_level == "minimal":
1794
+ # Just names
1795
+ names = [m["name"] for m in members[:10]]
1796
+ lines.append(f"members: {', '.join(names)}")
1797
+ if len(members) > 10:
1798
+ lines[-1] += f" +{len(members) - 10} more"
1799
+ else:
1800
+ # Full member list with types
1801
+ type_abbr = {"Function": "fn", "Class": "cls", "Method": "mth", "Variable": "var"}
1802
+ lines.append("members:")
1803
+ for m in members[:30]:
1804
+ abbr = type_abbr.get(m["kind"], m["kind"][:3])
1805
+ lines.append(f" [{abbr}] {m['name']} ({m['file_path']})")
1806
+ if len(members) > 30:
1807
+ lines.append(f" ... +{len(members) - 30} more")
1808
+
1809
+ return "\n".join(lines)
1810
+
1811
+ except Exception as e:
1812
+ logger.exception(f"Error getting community {community_id}")
1813
+ return f"Error: {e}"
1814
+
1815
+
1816
+ @mcp.tool()
1817
+ def codegraph_get_architecture_overview(
1818
+ path: str,
1819
+ detail_level: str = "minimal",
1820
+ ) -> str:
1821
+ """Compact architecture summary: communities, coupling, hubs, flows.
1822
+
1823
+ Provides a high-level view of the codebase architecture in under 150 tokens.
1824
+
1825
+ Args:
1826
+ path: Root directory of the project
1827
+ detail_level: Output detail: 'minimal' (default), 'standard', or 'verbose'
1828
+
1829
+ Returns:
1830
+ Architecture overview with communities, coupling warnings, top hubs, flows
1831
+ """
1832
+ from ..graph.communities import (
1833
+ detect_communities,
1834
+ format_architecture_overview,
1835
+ get_coupling_warnings,
1836
+ store_communities,
1837
+ )
1838
+
1839
+ detail_level = _validate_detail_level(detail_level)
1840
+ abs_path = os.path.abspath(path)
1841
+
1842
+ store = _get_graph_store(abs_path)
1843
+ if not store:
1844
+ return f'No index found. Run codegraph_scan(path="{path}") first.'
1845
+
1846
+ try:
1847
+ handler = get_handler()
1848
+ code_map = handler._get_code_map(abs_path)
1849
+
1850
+ # Communities
1851
+ communities_db = store.get_communities(limit=20)
1852
+ if communities_db:
1853
+ communities = [
1854
+ {
1855
+ "name": c["name"],
1856
+ "node_count": c["node_count"],
1857
+ "cohesion": c["cohesion"],
1858
+ "member_ids": [],
1859
+ }
1860
+ for c in communities_db
1861
+ ]
1862
+ else:
1863
+ communities = detect_communities(store)
1864
+ store_communities(store, communities)
1865
+
1866
+ coupling_warnings = get_coupling_warnings(store, communities)
1867
+ hubs = handler._compute_hubs(code_map, top_n=3, min_imports=2)
1868
+ stats = store.get_stats()
1869
+
1870
+ output = format_architecture_overview(
1871
+ communities, coupling_warnings, hubs, stats.get("flows", 0)
1872
+ )
1873
+
1874
+ if detail_level in ("standard", "verbose"):
1875
+ output += f"\n\ngraph: {stats['nodes']} nodes · {stats['edges']} edges"
1876
+ output += f"\nroutes: {stats.get('routes', 0)} · schemas: {stats.get('schemas', 0)}"
1877
+
1878
+ return output
1879
+
1880
+ except Exception as e:
1881
+ logger.exception(f"Error getting architecture overview for {path}")
1882
+ return f"Error: {e}"
1883
+
1884
+
1885
+ @mcp.tool()
1886
+ def codegraph_list_routes(
1887
+ path: str,
1888
+ framework: str | None = None,
1889
+ group_crud: bool = True,
1890
+ detail_level: str = "minimal",
1891
+ ) -> str:
1892
+ """List detected HTTP routes with methods, paths, and domain tags.
1893
+
1894
+ Detects routes from 15+ frameworks: FastAPI, Flask, Django, Express,
1895
+ Next.js, NestJS, Gin, Rails, Spring, and more.
1896
+
1897
+ Args:
1898
+ path: Root directory of the project
1899
+ framework: Filter by framework (e.g., 'fastapi', 'express')
1900
+ group_crud: Group CRUD endpoints (default: True)
1901
+ detail_level: Output detail: 'minimal' (default), 'standard', or 'verbose'
1902
+
1903
+ Returns:
1904
+ Route list with methods, paths, handlers, and tags
1905
+ """
1906
+ from ..domain.routes import detect_routes, format_routes_minimal
1907
+
1908
+ detail_level = _validate_detail_level(detail_level)
1909
+ abs_path = os.path.abspath(path)
1910
+
1911
+ store = _get_graph_store(abs_path, auto_build=False)
1912
+
1913
+ try:
1914
+ # Check DB first
1915
+ if store:
1916
+ routes_db = store.get_routes(framework=framework)
1917
+ if routes_db:
1918
+ routes = [
1919
+ {
1920
+ "method": r["method"],
1921
+ "path": r["path"],
1922
+ "file_path": r["file_path"],
1923
+ "handler_name": r["handler_name"],
1924
+ "framework": r["framework"],
1925
+ "tags": json.loads(r["tags"]) if r["tags"] else [],
1926
+ "confidence": r["confidence"],
1927
+ }
1928
+ for r in routes_db
1929
+ ]
1930
+ return format_routes_minimal(routes, group_crud=group_crud)
1931
+
1932
+ # Detect on demand from code_map
1933
+ handler = get_handler()
1934
+ exists, error_msg = handler._check_map_exists(abs_path)
1935
+ if not exists:
1936
+ return error_msg
1937
+
1938
+ code_map = handler._get_code_map(abs_path)
1939
+ routes = detect_routes(code_map, root_path=abs_path)
1940
+
1941
+ if framework:
1942
+ routes = [r for r in routes if r["framework"] == framework]
1943
+
1944
+ # Store if we have a graph store
1945
+ if store:
1946
+ store.clear_routes()
1947
+ for r in routes:
1948
+ store.insert_route(**r)
1949
+
1950
+ return format_routes_minimal(routes, group_crud=group_crud)
1951
+
1952
+ except Exception as e:
1953
+ logger.exception(f"Error listing routes for {path}")
1954
+ return f"Error: {e}"
1955
+
1956
+
1957
+ @mcp.tool()
1958
+ def codegraph_list_schemas(
1959
+ path: str,
1960
+ orm: str | None = None,
1961
+ detail_level: str = "minimal",
1962
+ ) -> str:
1963
+ """List detected ORM models/schemas with fields and relations.
1964
+
1965
+ Detects schemas from 8+ ORMs: SQLAlchemy, Django, Prisma, Sequelize,
1966
+ TypeORM, GORM, Drizzle, Mongoose.
1967
+
1968
+ Args:
1969
+ path: Root directory of the project
1970
+ orm: Filter by ORM (e.g., 'sqlalchemy', 'django')
1971
+ detail_level: Output detail: 'minimal' (default), 'standard', or 'verbose'
1972
+
1973
+ Returns:
1974
+ Schema list with model names, field counts, and ORM type
1975
+ """
1976
+ from ..domain.schemas import detect_schemas, format_schemas_minimal
1977
+
1978
+ detail_level = _validate_detail_level(detail_level)
1979
+ abs_path = os.path.abspath(path)
1980
+
1981
+ store = _get_graph_store(abs_path, auto_build=False)
1982
+
1983
+ try:
1984
+ # Check DB first
1985
+ if store:
1986
+ schemas_db = store.get_schemas(orm=orm)
1987
+ if schemas_db:
1988
+ schemas = [
1989
+ {
1990
+ "name": s["name"],
1991
+ "file_path": s["file_path"],
1992
+ "orm": s["orm"],
1993
+ "fields": json.loads(s["fields"]) if s["fields"] else [],
1994
+ "relations": json.loads(s["relations"]) if s["relations"] else [],
1995
+ }
1996
+ for s in schemas_db
1997
+ ]
1998
+ output = format_schemas_minimal(schemas)
1999
+ if detail_level == "verbose":
2000
+ for s in schemas[:10]:
2001
+ if s["fields"]:
2002
+ fields_str = ", ".join(
2003
+ f"{f['name']}:{f['type']}" for f in s["fields"][:10]
2004
+ )
2005
+ output += f"\n {s['name']}: {fields_str}"
2006
+ return output
2007
+
2008
+ # Detect on demand
2009
+ handler = get_handler()
2010
+ exists, error_msg = handler._check_map_exists(abs_path)
2011
+ if not exists:
2012
+ return error_msg
2013
+
2014
+ code_map = handler._get_code_map(abs_path)
2015
+ schemas = detect_schemas(code_map, root_path=abs_path)
2016
+
2017
+ if orm:
2018
+ schemas = [s for s in schemas if s["orm"] == orm]
2019
+
2020
+ if store:
2021
+ store.clear_schemas()
2022
+ for s in schemas:
2023
+ store.insert_schema(**s)
2024
+
2025
+ return format_schemas_minimal(schemas)
2026
+
2027
+ except Exception as e:
2028
+ logger.exception(f"Error listing schemas for {path}")
2029
+ return f"Error: {e}"
2030
+
2031
+
2032
+ # ==============================================================================
2033
+ # MCP RESOURCES
2034
+ # ==============================================================================
2035
+
2036
+
2037
+ @mcp.resource("codegraph://code-map")
2038
+ def get_code_map_resource() -> str:
2039
+ """The current codebase structural map as JSON."""
2040
+ handler = get_handler()
2041
+ code_map = handler._get_code_map(handler.workspace_root)
2042
+ return json.dumps(code_map, indent=2)
2043
+
2044
+
2045
+ @mcp.resource("codegraph://dependencies")
2046
+ def get_dependencies_resource() -> str:
2047
+ """File dependency relationships as JSON."""
2048
+ handler = get_handler()
2049
+ code_map = handler._get_code_map(handler.workspace_root)
2050
+ deps = {fpath: info.get("imports", []) for fpath, info in code_map.get("files", {}).items()}
2051
+ return json.dumps(deps, indent=2)
2052
+
2053
+
2054
+ # ==============================================================================
2055
+ # MCP PROMPTS - Workflow Templates
2056
+ # ==============================================================================
2057
+
2058
+
2059
+ @mcp.prompt()
2060
+ def investigate_bug(path: str, description: str) -> str:
2061
+ """Investigate a bug using token-efficient navigation.
2062
+
2063
+ Args:
2064
+ path: Path to the codebase
2065
+ description: Bug description or error message
2066
+ """
2067
+ return f"""Investigate a bug in the codebase at {path}.
2068
+ Bug: {description}
2069
+
2070
+ Workflow:
2071
+ 1. codegraph_get_minimal_context(path="{path}", task="fix {description}")
2072
+ 2. codegraph_search(query="<keyword from bug description>", path="{path}")
2073
+ 3. codegraph_read the relevant symbol locations (only the specific line ranges)
2074
+ 4. codegraph_get_dependencies to understand what depends on the buggy code
2075
+ 5. codegraph_test_gaps(path="{path}", changed_only=True) to check test coverage
2076
+
2077
+ Rules:
2078
+ - Always start with codegraph_get_minimal_context for orientation
2079
+ - Use detail_level="minimal" unless you need more context
2080
+ - Read surgically: specific line ranges, never whole files
2081
+ - Only escalate to detail_level="standard" if minimal is insufficient"""
2082
+
2083
+
2084
+ @mcp.prompt()
2085
+ def add_feature(path: str, description: str) -> str:
2086
+ """Plan and implement a new feature using token-efficient navigation.
2087
+
2088
+ Args:
2089
+ path: Path to the codebase
2090
+ description: Feature description
2091
+ """
2092
+ return f"""Add a feature to the codebase at {path}.
2093
+ Feature: {description}
2094
+
2095
+ Workflow:
2096
+ 1. codegraph_get_minimal_context(path="{path}", task="add {description}")
2097
+ 2. codegraph_search for similar existing patterns to follow
2098
+ 3. codegraph_get_structure on the target file to understand its layout
2099
+ 4. codegraph_read the relevant sections for context
2100
+ 5. Implement the feature following existing patterns
2101
+
2102
+ Rules:
2103
+ - Always start with codegraph_get_minimal_context for orientation
2104
+ - Use detail_level="minimal" unless you need more context
2105
+ - Read surgically: specific line ranges, never whole files
2106
+ - Follow existing code patterns and conventions"""
2107
+
2108
+
2109
+ @mcp.prompt()
2110
+ def review_changes(path: str, base: str = "HEAD~1") -> str:
2111
+ """Review code changes using token-efficient navigation.
2112
+
2113
+ Args:
2114
+ path: Path to the codebase
2115
+ base: Git ref to compare against (default: HEAD~1)
2116
+ """
2117
+ return f"""Review code changes in the codebase at {path} since {base}.
2118
+
2119
+ Workflow:
2120
+ 1. codegraph_get_minimal_context(path="{path}", task="review changes", base="{base}")
2121
+ 2. codegraph_test_gaps(path="{path}", changed_only=True, base="{base}")
2122
+ 3. codegraph_search for the changed symbols to understand their context
2123
+ 4. codegraph_read the changed functions (only the specific line ranges)
2124
+ 5. codegraph_get_dependencies for high-risk changed files
2125
+
2126
+ Rules:
2127
+ - Always start with codegraph_get_minimal_context for orientation
2128
+ - Use detail_level="minimal" unless you need more context
2129
+ - Focus on untested changes first (from test_gaps)
2130
+ - Check dependencies of changed files for blast radius"""
2131
+
2132
+
2133
+ @mcp.prompt()
2134
+ def understand_architecture(path: str) -> str:
2135
+ """Understand the architecture of a codebase.
2136
+
2137
+ Args:
2138
+ path: Path to the codebase
2139
+ """
2140
+ return f"""Understand the architecture of the codebase at {path}.
2141
+
2142
+ Workflow:
2143
+ 1. codegraph_get_minimal_context(path="{path}", task="understand architecture")
2144
+ 2. codegraph_get_hubs(path="{path}") to find central files
2145
+ 3. codegraph_get_dependencies(path="{path}") for coupling overview
2146
+ 4. codegraph_get_structure on the top hub files
2147
+ 5. codegraph_read key sections of hub files for understanding
2148
+
2149
+ Rules:
2150
+ - Always start with codegraph_get_minimal_context for orientation
2151
+ - Use detail_level="minimal" first, then "standard" for hubs only if needed
2152
+ - Read surgically: specific line ranges, never whole files
2153
+ - Summarize: overall structure, key modules, coupling patterns"""
2154
+
2155
+
2156
+ @mcp.prompt()
2157
+ def onboard_project(path: str) -> str:
2158
+ """Get up to speed on a new project.
2159
+
2160
+ Args:
2161
+ path: Path to the codebase
2162
+ """
2163
+ return f"""Get up to speed on the project at {path}.
2164
+
2165
+ Workflow:
2166
+ 1. codegraph_get_minimal_context(path="{path}", task="onboard to project")
2167
+ 2. codegraph_stats(path="{path}") for project size overview
2168
+ 3. codegraph_get_hubs(path="{path}") to find the most important files
2169
+ 4. codegraph_get_structure on the top 3 hub files
2170
+ 5. codegraph_read entry points and main abstractions
2171
+
2172
+ Rules:
2173
+ - Always start with codegraph_get_minimal_context for orientation
2174
+ - Use detail_level="minimal" unless you need more context
2175
+ - Focus on understanding hubs and entry points first
2176
+ - Read surgically: specific line ranges, never whole files"""
2177
+
2178
+
2179
+ # ==============================================================================
2180
+ # ENTRY POINTS
2181
+ # ==============================================================================
2182
+
2183
+
2184
+ def create_server(workspace_root: str | None = None) -> FastMCP:
2185
+ """Create and return the MCP server instance."""
2186
+ global _handler
2187
+ _handler = CodegraphToolHandler(workspace_root)
2188
+ return mcp
2189
+
2190
+
2191
+ async def run_server(workspace_root: str | None = None):
2192
+ """Run the MCP server using stdio transport."""
2193
+ global _handler
2194
+ _handler = CodegraphToolHandler(workspace_root)
2195
+ await mcp.run_stdio_async()
2196
+
2197
+
2198
+ def main():
2199
+ """Entry point for the MCP server."""
2200
+ import argparse
2201
+
2202
+ parser = argparse.ArgumentParser(description="Codegraph-nav MCP Server")
2203
+ parser.add_argument(
2204
+ "--workspace",
2205
+ "-w",
2206
+ default=os.getcwd(),
2207
+ help="Workspace root directory",
2208
+ )
2209
+ parser.add_argument(
2210
+ "--debug",
2211
+ action="store_true",
2212
+ help="Enable debug logging",
2213
+ )
2214
+ args = parser.parse_args()
2215
+
2216
+ if args.debug:
2217
+ logging.basicConfig(level=logging.DEBUG)
2218
+ else:
2219
+ logging.basicConfig(level=logging.INFO)
2220
+
2221
+ global _handler
2222
+ _handler = CodegraphToolHandler(args.workspace)
2223
+
2224
+ mcp.run()
2225
+
2226
+
2227
+ if __name__ == "__main__":
2228
+ main()