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.
- codegraph_nav/__init__.py +194 -0
- codegraph_nav/ast_grep_analyzer.py +448 -0
- codegraph_nav/cli.py +223 -0
- codegraph_nav/code_navigator.py +1328 -0
- codegraph_nav/code_search.py +1009 -0
- codegraph_nav/colors.py +209 -0
- codegraph_nav/completions.py +354 -0
- codegraph_nav/dart_analyzer.py +301 -0
- codegraph_nav/dependency_graph.py +814 -0
- codegraph_nav/domain/__init__.py +20 -0
- codegraph_nav/domain/routes.py +337 -0
- codegraph_nav/domain/schemas.py +229 -0
- codegraph_nav/domain/tags.py +87 -0
- codegraph_nav/exporters.py +563 -0
- codegraph_nav/go_analyzer.py +273 -0
- codegraph_nav/graph/__init__.py +72 -0
- codegraph_nav/graph/builder.py +409 -0
- codegraph_nav/graph/communities.py +402 -0
- codegraph_nav/graph/flows.py +311 -0
- codegraph_nav/graph/query.py +380 -0
- codegraph_nav/graph/schema.py +266 -0
- codegraph_nav/graph/search.py +257 -0
- codegraph_nav/graph/store.py +517 -0
- codegraph_nav/hints.py +195 -0
- codegraph_nav/import_resolver.py +891 -0
- codegraph_nav/js_ts_analyzer.py +564 -0
- codegraph_nav/line_reader.py +664 -0
- codegraph_nav/mcp/__init__.py +39 -0
- codegraph_nav/mcp/__main__.py +5 -0
- codegraph_nav/mcp/server.py +2228 -0
- codegraph_nav/py.typed +2 -0
- codegraph_nav/ruby_analyzer.py +259 -0
- codegraph_nav/rust_analyzer.py +379 -0
- codegraph_nav/token_efficient_renderer.py +743 -0
- codegraph_nav/watcher.py +382 -0
- codegraph_nav-0.1.0.dist-info/METADATA +487 -0
- codegraph_nav-0.1.0.dist-info/RECORD +41 -0
- codegraph_nav-0.1.0.dist-info/WHEEL +5 -0
- codegraph_nav-0.1.0.dist-info/entry_points.txt +4 -0
- codegraph_nav-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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()
|