rust-analyzer-db 0.2.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.
@@ -0,0 +1,516 @@
1
+ """MCP server for rust-analyzer-db.
2
+
3
+ Exposes Rust code analysis capabilities as MCP tools, allowing AI
4
+ assistants to scan, query, and analyze Rust codebases via the
5
+ Model Context Protocol.
6
+
7
+ Usage:
8
+ rust-analyzer-db mcp --db rust_code.db
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ from mcp.server.fastmcp import FastMCP
19
+
20
+ from . import __version__
21
+ from .db import RustCodeDB
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Helpers
25
+ # ---------------------------------------------------------------------------
26
+
27
+ _db_path: str = "rust_code.db"
28
+
29
+
30
+ def _get_db() -> RustCodeDB:
31
+ return RustCodeDB(_db_path)
32
+
33
+
34
+ def _rows(rows: list) -> list[dict]:
35
+ return [dict(r) for r in rows]
36
+
37
+
38
+ def _row(row) -> dict:
39
+ if row is None:
40
+ return {}
41
+ return dict(row)
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # MCP Server
46
+ # ---------------------------------------------------------------------------
47
+
48
+ mcp = FastMCP(
49
+ f"rust-analyzer-db v{__version__}",
50
+ instructions=(
51
+ "MCP server for Rust code analysis. Provides tools to scan Rust "
52
+ "projects, query code entities, search code, analyze complexity, "
53
+ "inspect call graphs, public API surfaces, and dependencies."
54
+ ),
55
+ )
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Tools
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ @mcp.tool()
64
+ def scan_project(path: str, force: bool = False) -> str:
65
+ """Scan a Rust project directory or file and populate the database.
66
+
67
+ Extracts all code items (structs, enums, traits, functions, methods, etc.),
68
+ computes complexity metrics, builds call edges, and stores everything
69
+ in the SQLite database.
70
+
71
+ Args:
72
+ path: Absolute path to a Rust file or directory to scan.
73
+ force: If true, re-parse even unchanged files. Default false.
74
+
75
+ Returns:
76
+ Summary of the scan results.
77
+ """
78
+ import hashlib
79
+ import time
80
+
81
+ from .cli import _hash_bytes, _store_item, _count_items
82
+ from .exceptions import ParseError
83
+ from .extractor import extract_file, extract_calls
84
+
85
+ root = Path(path)
86
+ if not root.exists():
87
+ return f"Error: Path not found: {path}"
88
+
89
+ rs_files = [root] if root.is_file() else sorted(root.rglob("*.rs"))
90
+ if not rs_files:
91
+ return f"No .rs files found in {path}"
92
+
93
+ skip_dirs = {"target", ".git", "node_modules", ".cargo"}
94
+ rs_files = [
95
+ f for f in rs_files
96
+ if not any(part in skip_dirs for part in f.parts)
97
+ ]
98
+
99
+ db = _get_db()
100
+ total_items = 0
101
+ total_uses = 0
102
+ total_externs = 0
103
+ scanned = 0
104
+ skipped = 0
105
+ errors = 0
106
+ start_time = time.monotonic()
107
+
108
+ try:
109
+ for f in rs_files:
110
+ data = f.read_bytes()
111
+ file_hash = _hash_bytes(data)
112
+ abs_path = str(f.resolve())
113
+ if not force and db.file_unchanged(abs_path, file_hash):
114
+ skipped += 1
115
+ continue
116
+
117
+ file_id = db.upsert_file(abs_path, f.stat().st_mtime, file_hash)
118
+ try:
119
+ extraction = extract_file(data)
120
+ except ParseError:
121
+ errors += 1
122
+ continue
123
+ except Exception:
124
+ errors += 1
125
+ continue
126
+
127
+ for item in extraction.items:
128
+ _store_item(db, file_id, item, data)
129
+
130
+ db.update_file_lines(file_id, extraction.total_lines)
131
+
132
+ for use in extraction.use_declarations:
133
+ db.insert_use_decl(
134
+ file_id, use.path, use.alias, use.is_glob,
135
+ use.start_line, use.end_line,
136
+ )
137
+ total_uses += 1
138
+
139
+ for ext in extraction.extern_crates:
140
+ db.insert_extern_crate(
141
+ file_id, ext.name, ext.alias, ext.start_line, ext.end_line,
142
+ )
143
+ total_externs += 1
144
+
145
+ db.commit()
146
+ count = _count_items(extraction.items)
147
+ total_items += count
148
+ scanned += 1
149
+
150
+ if scanned:
151
+ total, resolved = db.resolve_calls()
152
+
153
+ elapsed = time.monotonic() - start_time
154
+ return (
155
+ f"Scanned {scanned} file(s), skipped {skipped} unchanged, "
156
+ f"errors {errors}. Extracted {total_items} items, "
157
+ f"{total_uses} use declarations, {total_externs} extern crates. "
158
+ f"({elapsed:.1f}s)"
159
+ )
160
+ finally:
161
+ db.close()
162
+
163
+
164
+ @mcp.tool()
165
+ def list_items(
166
+ kind: Optional[str] = None,
167
+ name: Optional[str] = None,
168
+ target: Optional[str] = None,
169
+ file_pattern: Optional[str] = None,
170
+ limit: int = 50,
171
+ ) -> str:
172
+ """List code items (structs, enums, functions, methods, etc.) with optional filters.
173
+
174
+ Args:
175
+ kind: Filter by kind (e.g. 'function', 'struct', 'method', 'trait', 'impl').
176
+ name: Substring match on item name.
177
+ target: Substring match on impl/method target type.
178
+ file_pattern: Substring match on file path.
179
+ limit: Maximum number of results (default 50, max 500).
180
+
181
+ Returns:
182
+ JSON string with matching items.
183
+ """
184
+ db = _get_db()
185
+ try:
186
+ rows = db.list_items(
187
+ kind=kind, name=name, target=target,
188
+ file_like=file_pattern, limit=min(limit, 500),
189
+ )
190
+ items = _rows(rows)
191
+ if not items:
192
+ return "No matching items found."
193
+ return json.dumps(items, indent=2, default=str)
194
+ finally:
195
+ db.close()
196
+
197
+
198
+ @mcp.tool()
199
+ def get_item(item_id: int) -> str:
200
+ """Get full details and source code for a specific item by ID.
201
+
202
+ Args:
203
+ item_id: The numeric ID of the item.
204
+
205
+ Returns:
206
+ JSON string with full item details including source code.
207
+ """
208
+ db = _get_db()
209
+ try:
210
+ row = db.get_item(item_id)
211
+ if row is None:
212
+ return f"No item found with ID {item_id}."
213
+ return json.dumps(_row(row), indent=2, default=str)
214
+ finally:
215
+ db.close()
216
+
217
+
218
+ @mcp.tool()
219
+ def search_code(query: str, kind: Optional[str] = None, limit: int = 30) -> str:
220
+ """Full-text search across item names, signatures, doc comments, and source code.
221
+
222
+ Uses SQLite FTS5 for fast text search. Supports FTS5 query syntax
223
+ (e.g. 'parse OR tokenize', '"exact phrase"', 'NOT keyword').
224
+
225
+ Args:
226
+ query: Search query string (FTS5 syntax supported).
227
+ kind: Optional filter by item kind.
228
+ limit: Maximum results (default 30).
229
+
230
+ Returns:
231
+ JSON string with matching items.
232
+ """
233
+ db = _get_db()
234
+ try:
235
+ rows = db.search(query, kind=kind, limit=limit)
236
+ items = _rows(rows)
237
+ if not items:
238
+ return f"No matches found for '{query}'."
239
+ return json.dumps(items, indent=2, default=str)
240
+ finally:
241
+ db.close()
242
+
243
+
244
+ @mcp.tool()
245
+ def get_stats() -> str:
246
+ """Get project statistics: file count, item counts by kind, call graph summary.
247
+
248
+ Returns:
249
+ JSON string with project statistics.
250
+ """
251
+ db = _get_db()
252
+ try:
253
+ n_files, kind_rows = db.stats()
254
+ total_calls, resolved_calls = db.call_graph_stats()
255
+ total_items = sum(r["n"] for r in kind_rows)
256
+ return json.dumps({
257
+ "files": n_files,
258
+ "total_items": total_items,
259
+ "items_by_kind": {r["kind"]: r["n"] for r in kind_rows},
260
+ "total_call_edges": total_calls,
261
+ "resolved_call_edges": resolved_calls,
262
+ }, indent=2)
263
+ finally:
264
+ db.close()
265
+
266
+
267
+ @mcp.tool()
268
+ def complexity_report(min_complexity: int = 5) -> str:
269
+ """Report functions/methods with cyclomatic complexity above a threshold.
270
+
271
+ Higher complexity indicates harder-to-test and harder-to-maintain code.
272
+
273
+ Args:
274
+ min_complexity: Minimum cyclomatic complexity to report (default 5).
275
+
276
+ Returns:
277
+ JSON string with complex functions sorted by complexity descending.
278
+ """
279
+ db = _get_db()
280
+ try:
281
+ rows = db.complexity_report(min_complexity=min_complexity)
282
+ items = _rows(rows)
283
+ if not items:
284
+ return f"No functions exceed complexity threshold {min_complexity}."
285
+ return json.dumps(items, indent=2, default=str)
286
+ finally:
287
+ db.close()
288
+
289
+
290
+ @mcp.tool()
291
+ def api_surface() -> str:
292
+ """List all public API items (pub functions, structs, traits, etc.).
293
+
294
+ Returns:
295
+ JSON string with public items grouped by kind.
296
+ """
297
+ db = _get_db()
298
+ try:
299
+ rows = db.api_surface()
300
+ items = _rows(rows)
301
+ if not items:
302
+ return "No public items found."
303
+ by_kind: dict = {}
304
+ for item in items:
305
+ by_kind.setdefault(item["kind"], []).append(item)
306
+ return json.dumps(by_kind, indent=2, default=str)
307
+ finally:
308
+ db.close()
309
+
310
+
311
+ @mcp.tool()
312
+ def dependencies() -> str:
313
+ """Show external dependencies: extern crate declarations and use imports.
314
+
315
+ Returns:
316
+ JSON string with extern crates and grouped use declarations.
317
+ """
318
+ db = _get_db()
319
+ try:
320
+ externs = _rows(db.all_extern_crates())
321
+ uses = _rows(db.get_use_declarations())
322
+
323
+ crate_groups: dict[str, list[str]] = {}
324
+ for r in uses:
325
+ path = r["path"]
326
+ top = path.split("::")[0] if path else "(unknown)"
327
+ crate_groups.setdefault(top, []).append(path)
328
+
329
+ return json.dumps({
330
+ "extern_crates": externs,
331
+ "use_groups": {k: v for k, v in sorted(crate_groups.items())},
332
+ "total_use_declarations": len(uses),
333
+ }, indent=2, default=str)
334
+ finally:
335
+ db.close()
336
+
337
+
338
+ @mcp.tool()
339
+ def call_graph_info(function_name: Optional[str] = None) -> str:
340
+ """Get call graph information for a function or the whole project.
341
+
342
+ If a function name is provided, shows its callers and callees.
343
+ Otherwise, returns overall call graph statistics.
344
+
345
+ Args:
346
+ function_name: Name of a function/method to trace. If empty, shows project-wide stats.
347
+
348
+ Returns:
349
+ JSON string with call graph data.
350
+ """
351
+ db = _get_db()
352
+ try:
353
+ if not function_name:
354
+ total, resolved = db.call_graph_stats()
355
+ return json.dumps({
356
+ "total_call_edges": total,
357
+ "resolved_call_edges": resolved,
358
+ "unresolved": total - resolved,
359
+ }, indent=2)
360
+
361
+ rows = db.list_items(name=function_name, limit=10)
362
+ rows = [r for r in rows if r["kind"] in ("function", "method")]
363
+ if not rows:
364
+ return f"No function/method found matching '{function_name}'."
365
+
366
+ results = []
367
+ for row in rows:
368
+ item = _row(row)
369
+ callees = _rows(db.get_calls_from(row["id"]))
370
+ callers = _rows(db.get_calls_to(row["id"]))
371
+ results.append({
372
+ "item": item,
373
+ "calls": callees,
374
+ "called_by": callers,
375
+ })
376
+ return json.dumps(results, indent=2, default=str)
377
+ finally:
378
+ db.close()
379
+
380
+
381
+ @mcp.tool()
382
+ def methods_of(type_name: str) -> str:
383
+ """List all methods defined on a type or trait.
384
+
385
+ Args:
386
+ type_name: Name of the type or trait (substring match).
387
+
388
+ Returns:
389
+ JSON string with methods including their signatures and complexity.
390
+ """
391
+ db = _get_db()
392
+ try:
393
+ rows = db.get_methods_of(type_name)
394
+ items = _rows(rows)
395
+ if not items:
396
+ return f"No methods found for type matching '{type_name}'."
397
+ return json.dumps(items, indent=2, default=str)
398
+ finally:
399
+ db.close()
400
+
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # Resources
404
+ # ---------------------------------------------------------------------------
405
+
406
+
407
+ @mcp.resource("rust-analyzer://schema")
408
+ def db_schema() -> str:
409
+ """Database schema documentation describing all tables and columns."""
410
+ return """# rust-analyzer-db Schema
411
+
412
+ ## Tables
413
+
414
+ ### files
415
+ | Column | Type | Description |
416
+ |--------|------|-------------|
417
+ | id | INTEGER | Primary key |
418
+ | path | TEXT | Absolute file path (unique) |
419
+ | mtime | REAL | File modification time |
420
+ | hash | TEXT | Content hash (SHA-256) |
421
+ | total_lines | INTEGER | Total lines in file |
422
+ | scanned_at | TIMESTAMP | When the file was scanned |
423
+
424
+ ### items
425
+ | Column | Type | Description |
426
+ |--------|------|-------------|
427
+ | id | INTEGER | Primary key |
428
+ | file_id | INTEGER | FK to files |
429
+ | kind | TEXT | struct/enum/trait/impl/function/method/const/static/mod/macro/type_alias/union |
430
+ | name | TEXT | Item name |
431
+ | target | TEXT | impl target type |
432
+ | trait_name | TEXT | Trait name (for impl blocks) |
433
+ | visibility | TEXT | pub, pub(crate), etc. |
434
+ | signature | TEXT | Function signature (no body) |
435
+ | doc | TEXT | Doc comments |
436
+ | attributes | TEXT | #[derive(...)] etc. |
437
+ | start_line / end_line | INTEGER | Line range |
438
+ | source | TEXT | Full source text |
439
+ | is_pub | INTEGER | 1 if public |
440
+ | cyclomatic_complexity | INTEGER | Cyclomatic complexity |
441
+ | cognitive_complexity | INTEGER | Cognitive complexity |
442
+ | nesting_depth | INTEGER | Max nesting depth |
443
+ | num_branches | INTEGER | Branch count |
444
+ | num_function_calls | INTEGER | Function call count |
445
+ | lines_of_code | INTEGER | Lines of code |
446
+
447
+ ### calls
448
+ | Column | Type | Description |
449
+ |--------|------|-------------|
450
+ | caller_id | INTEGER | FK to items (caller) |
451
+ | callee_name | TEXT | Name of called function |
452
+ | callee_id | INTEGER | FK to items (resolved) |
453
+ | is_method_call | INTEGER | 1 if method call |
454
+ | receiver | TEXT | Receiver object (e.g. 'self') |
455
+
456
+ ### use_declarations
457
+ | Column | Type | Description |
458
+ |--------|------|-------------|
459
+ | file_id | INTEGER | FK to files |
460
+ | path | TEXT | Import path |
461
+ | alias | TEXT | Import alias |
462
+
463
+ ### extern_crates
464
+ | Column | Type | Description |
465
+ |--------|------|-------------|
466
+ | name | TEXT | Crate name |
467
+ | alias | TEXT | Crate alias |
468
+
469
+ ### generic_params / lifetime_params
470
+ | Column | Type | Description |
471
+ |--------|------|-------------|
472
+ | item_id | INTEGER | FK to items |
473
+ | name | TEXT | Parameter name |
474
+ | bounds | TEXT | Trait bounds |
475
+ """
476
+
477
+
478
+ # ---------------------------------------------------------------------------
479
+ # Prompts
480
+ # ---------------------------------------------------------------------------
481
+
482
+
483
+ @mcp.prompt()
484
+ def review_function(name: str) -> str:
485
+ """Prompt for reviewing a specific function's quality and complexity."""
486
+ return (
487
+ f"Please review the function '{name}' in the codebase. "
488
+ f"Use the call_graph_info tool to see its callers and callees, "
489
+ f"then use get_item to read its source code. "
490
+ f"Evaluate its complexity, testability, and suggest improvements."
491
+ )
492
+
493
+
494
+ @mcp.prompt()
495
+ def analyze_project() -> str:
496
+ """Prompt for a comprehensive project analysis."""
497
+ return (
498
+ "Analyze this Rust project comprehensively:\n"
499
+ "1. Use get_stats() for an overview\n"
500
+ "2. Use complexity_report() to find hotspots\n"
501
+ "3. Use api_surface() to review the public API\n"
502
+ "4. Use dependencies() to understand external deps\n"
503
+ "5. Summarize findings and recommendations"
504
+ )
505
+
506
+
507
+ # ---------------------------------------------------------------------------
508
+ # Run
509
+ # ---------------------------------------------------------------------------
510
+
511
+
512
+ def run_server(db_path: str) -> None:
513
+ """Start the MCP server over stdio transport."""
514
+ global _db_path
515
+ _db_path = db_path
516
+ mcp.run(transport="stdio")