pycode-kg 0.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. pycode_kg/.DS_Store +0 -0
  2. pycode_kg/__init__.py +91 -0
  3. pycode_kg/__main__.py +11 -0
  4. pycode_kg/analysis/__init__.py +15 -0
  5. pycode_kg/analysis/bridge.py +108 -0
  6. pycode_kg/analysis/centrality.py +412 -0
  7. pycode_kg/analysis/framework_detector.py +103 -0
  8. pycode_kg/analysis/hybrid_rank.py +53 -0
  9. pycode_kg/app.py +1335 -0
  10. pycode_kg/architecture.py +624 -0
  11. pycode_kg/build_pycodekg_lancedb.py +15 -0
  12. pycode_kg/build_pycodekg_sqlite.py +15 -0
  13. pycode_kg/cli/__init__.py +29 -0
  14. pycode_kg/cli/cmd_analyze.py +96 -0
  15. pycode_kg/cli/cmd_architecture.py +150 -0
  16. pycode_kg/cli/cmd_bridges.py +26 -0
  17. pycode_kg/cli/cmd_build.py +154 -0
  18. pycode_kg/cli/cmd_build_full.py +242 -0
  19. pycode_kg/cli/cmd_centrality.py +131 -0
  20. pycode_kg/cli/cmd_explain.py +180 -0
  21. pycode_kg/cli/cmd_framework_nodes.py +18 -0
  22. pycode_kg/cli/cmd_hooks.py +137 -0
  23. pycode_kg/cli/cmd_init.py +312 -0
  24. pycode_kg/cli/cmd_mcp.py +71 -0
  25. pycode_kg/cli/cmd_model.py +53 -0
  26. pycode_kg/cli/cmd_query.py +211 -0
  27. pycode_kg/cli/cmd_snapshot.py +421 -0
  28. pycode_kg/cli/cmd_viz.py +180 -0
  29. pycode_kg/cli/main.py +23 -0
  30. pycode_kg/cli/options.py +63 -0
  31. pycode_kg/config.py +78 -0
  32. pycode_kg/graph.py +125 -0
  33. pycode_kg/index.py +542 -0
  34. pycode_kg/kg.py +220 -0
  35. pycode_kg/layout3d.py +470 -0
  36. pycode_kg/mcp/bridge_tools.py +19 -0
  37. pycode_kg/mcp/framework_tools.py +18 -0
  38. pycode_kg/mcp_server.py +1965 -0
  39. pycode_kg/module/__init__.py +83 -0
  40. pycode_kg/module/base.py +720 -0
  41. pycode_kg/module/extractor.py +276 -0
  42. pycode_kg/module/types.py +532 -0
  43. pycode_kg/pycodekg.py +543 -0
  44. pycode_kg/pycodekg_query.py +1 -0
  45. pycode_kg/pycodekg_snippet_packer.py +1 -0
  46. pycode_kg/pycodekg_thorough_analysis.py +2751 -0
  47. pycode_kg/pycodekg_viz.py +1 -0
  48. pycode_kg/pycodekg_viz3d.py +1 -0
  49. pycode_kg/ranking/__init__.py +1 -0
  50. pycode_kg/ranking/cli_rank.py +92 -0
  51. pycode_kg/ranking/coderank.py +555 -0
  52. pycode_kg/snapshots.py +612 -0
  53. pycode_kg/sql/004_add_centrality_table.sql +12 -0
  54. pycode_kg/store.py +766 -0
  55. pycode_kg/utils.py +39 -0
  56. pycode_kg/visitor.py +413 -0
  57. pycode_kg/viz3d.py +1353 -0
  58. pycode_kg/viz3d_timeline.py +364 -0
  59. pycode_kg-0.16.0.dist-info/METADATA +305 -0
  60. pycode_kg-0.16.0.dist-info/RECORD +63 -0
  61. pycode_kg-0.16.0.dist-info/WHEEL +4 -0
  62. pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
  63. pycode_kg-0.16.0.dist-info/licenses/LICENSE +94 -0
@@ -0,0 +1,1965 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ mcp_server.py — PyCodeKG MCP Server
4
+
5
+ Exposes the PyCodeKG hybrid query and snippet-pack pipeline as
6
+ Model Context Protocol (MCP) tools, allowing any MCP-compatible
7
+ agent (Claude Desktop, Cursor, Continue, etc.) to query a codebase
8
+ knowledge graph directly.
9
+
10
+ Operational notes:
11
+ - Entry points and configuration: the CLI ``main()`` resolves repo, SQLite,
12
+ and LanceDB paths with sensible defaults under ``.pycodekg/``.
13
+ - Logging approach: startup diagnostics and warnings are written to stderr so
14
+ host MCP clients can capture runtime state.
15
+ - Error handling strategy: startup reports misconfiguration warnings clearly
16
+ (for example missing SQLite graph) before attempting tool execution.
17
+
18
+ Tools
19
+ -----
20
+ query_codebase(q, k, hop, rels, include_symbols, max_nodes, min_score, max_per_module, paths, rerank_mode, rerank_semantic_weight, rerank_lexical_weight, include_edge_provenance, format)
21
+ Hybrid semantic + structural query. Returns ranked nodes and edges.
22
+ Default rerank_mode='hybrid' (70% semantic + 30% lexical overlap).
23
+ Default format='json'; pass format='markdown' for a ranked Markdown
24
+ table instead of raw JSON.
25
+
26
+ pack_snippets(q, k, hop, rels, include_symbols, context, max_lines, max_nodes, min_score, max_per_module, rerank_mode, rerank_semantic_weight, rerank_lexical_weight, missing_lineno_policy, include_edge_provenance)
27
+ Hybrid query + source-grounded snippet extraction. Returns a
28
+ Markdown context pack suitable for direct LLM ingestion. Default
29
+ rerank_mode='hybrid'. Emits Warnings section when snippets are
30
+ capped or omitted due to missing line metadata.
31
+
32
+ get_node(node_id, include_edges)
33
+ Fetch a single node by its stable ID. Returns Markdown with kind,
34
+ location, and docstring. Pass include_edges=True to also render
35
+ outgoing edges by relation type and incoming CALLS callers, avoiding
36
+ a separate callers() round-trip. Use query_codebase() to discover
37
+ node IDs.
38
+
39
+ graph_stats()
40
+ Return node and edge counts by kind/relation as a Markdown summary
41
+ with tables, plus docstring_coverage and snapshot_count. Call this
42
+ first to understand graph scale before issuing query_codebase() or
43
+ pack_snippets().
44
+
45
+ callers(node_id, rel)
46
+ Find all callers of a node, resolving through ``sym:`` import stubs
47
+ with import-aware filtering for ambiguous same-name targets.
48
+ Returns JSON. Use get_node(include_edges=True) for a combined
49
+ node + callers view without a separate round-trip.
50
+
51
+ find_definition_at(file, line)
52
+ Reverse-resolve a (file, line) pair to the innermost graph node that
53
+ spans it, then return the same Markdown report as explain(). Useful
54
+ when reading a file in the IDE without knowing the node ID.
55
+
56
+ analyze_repo()
57
+ Run the full nine-phase architectural analysis pipeline and return
58
+ results as Markdown (complexity, coupling, docstring coverage, etc.).
59
+
60
+ explain(node_id, limit=10)
61
+ Return a Markdown natural-language explanation of a single code node,
62
+ including metadata, docstring, callers, callees, and role assessment.
63
+ limit controls how many callers/callees are listed (0 = all).
64
+
65
+ snapshot_list(limit, branch)
66
+ List saved temporal snapshots in reverse chronological order.
67
+ Returns JSON array of snapshot metadata with key metrics and
68
+ freshness indicator vs. current DB node count.
69
+
70
+ snapshot_show(key)
71
+ Show full details of a specific snapshot by key (tree hash), or the
72
+ most recent snapshot when key="latest". Returns JSON with
73
+ freshness indicator vs. current DB node count.
74
+
75
+ snapshot_diff(key_a, key_b)
76
+ Compare two snapshots side-by-side and return computed deltas for
77
+ nodes, edges, docstring coverage, and critical issues. Returns JSON
78
+ including freshness metadata and lists of introduced/resolved issues.
79
+
80
+ list_nodes(module_path, kind)
81
+ List nodes filtered by module path prefix and/or kind. Returns a
82
+ JSON array of matching node dicts. Useful for enumerating classes
83
+ or functions in a specific module.
84
+
85
+ find_node(name, kind)
86
+ Find nodes by plain name or qualname substring without knowing the
87
+ full stable ID. Returns a JSON array of matching node dicts.
88
+ Use when you know a function name from a traceback or reading code
89
+ and need to obtain its stable ID for explain(), callers(), etc.
90
+
91
+ centrality(top, kinds, group_by)
92
+ Compute Structural Importance Ranking (SIR): a deterministic weighted
93
+ PageRank over the resolved call graph that scores every node by
94
+ structural centrality. Returns a Markdown ranking table. Use
95
+ group_by='module' for per-module rollup.
96
+
97
+ rank_nodes(top, rels, persist_metric, exclude_tests)
98
+ Compute global weighted CodeRank (PageRank) over the repository graph.
99
+ Returns JSON array of top-ranked nodes with score, top_pct (e.g. "top 0.5%"),
100
+ kind, qualname, and module_path. Optionally persists scores to node_metrics.
101
+
102
+ query_ranked(q, k, mode, top, rels, radius, exclude_tests)
103
+ Rank query results using CodeRank-enhanced hybrid or personalized
104
+ PageRank. mode='hybrid' (default) or 'ppr'. Returns JSON with
105
+ per-node score components (semantic, centrality, proximity) and
106
+ explainability ``why`` strings.
107
+
108
+ explain_rank(node_id, q)
109
+ Explain the CodeRank score components for a specific node. Returns
110
+ Markdown with global rank position, inbound/outbound edge counts, and
111
+ optional query-conditioned semantic + proximity scores.
112
+
113
+ Usage
114
+ -----
115
+ Install the package, then run::
116
+
117
+ pycodekg mcp --repo /path/to/repo
118
+
119
+ ``--db`` and ``--lancedb`` are optional; they default to
120
+ ``.pycodekg/graph.sqlite`` and ``.pycodekg/lancedb`` relative to ``--repo``.
121
+
122
+ Per-project config for Claude Code and Kilo Code (``.mcp.json``)::
123
+
124
+ {
125
+ "mcpServers": {
126
+ "pycodekg": {
127
+ "command": "/path/to/.venv/bin/pycodekg",
128
+ "args": ["mcp", "--repo", "/path/to/repo"]
129
+ }
130
+ }
131
+ }
132
+
133
+ Author: Eric G. Suchanek, PhD
134
+ """
135
+
136
+ from __future__ import annotations
137
+
138
+ import argparse
139
+ import json
140
+ import sys
141
+ from pathlib import Path
142
+
143
+ from mcp.server.fastmcp import FastMCP
144
+
145
+ from pycode_kg import PyCodeKG
146
+ from pycode_kg.pycodekg import DEFAULT_MODEL
147
+ from pycode_kg.pycodekg_thorough_analysis import PyCodeKGAnalyzer
148
+ from pycode_kg.snapshots import SnapshotManager
149
+ from pycode_kg.store import DEFAULT_RELS
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Global state — initialised in main() before the server starts
153
+ # ---------------------------------------------------------------------------
154
+
155
+ _kg: PyCodeKG | None = None
156
+ _snapshot_mgr: SnapshotManager | None = None
157
+
158
+
159
+ def _get_kg() -> PyCodeKG:
160
+ """
161
+ Return the global PyCodeKG instance, raising if it has not been initialised.
162
+
163
+ :return: The active PyCodeKG instance.
164
+ :raises RuntimeError: If ``main()`` has not been called to initialise the graph.
165
+ """
166
+ if _kg is None:
167
+ raise RuntimeError(
168
+ "PyCodeKG not initialised. Run the server via 'pycodekg-mcp --repo /path/to/repo'"
169
+ )
170
+ return _kg
171
+
172
+
173
+ def _get_snapshot_mgr() -> SnapshotManager:
174
+ """
175
+ Return the global SnapshotManager instance, raising if not initialised.
176
+
177
+ :return: The active SnapshotManager instance.
178
+ :raises RuntimeError: If ``main()`` has not been called to initialise the server.
179
+ """
180
+ if _snapshot_mgr is None:
181
+ raise RuntimeError(
182
+ "SnapshotManager not initialised. Run the server via 'pycodekg-mcp --repo /path/to/repo'"
183
+ )
184
+ return _snapshot_mgr
185
+
186
+
187
+ def _enrich_edges_with_provenance(edges: list[dict]) -> list[dict]:
188
+ """Attach confidence/provenance fields to inferred edges when possible.
189
+
190
+ :param edges: Edge dictionaries from graph queries.
191
+ :return: New edge list with optional ``inferred``, ``confidence``, and
192
+ ``provenance`` fields.
193
+ """
194
+ enriched: list[dict] = []
195
+ for edge in edges:
196
+ out = dict(edge)
197
+ ev_raw = edge.get("evidence")
198
+ ev = None
199
+ if isinstance(ev_raw, str):
200
+ try:
201
+ ev = json.loads(ev_raw)
202
+ except json.JSONDecodeError:
203
+ ev = None
204
+ elif isinstance(ev_raw, dict):
205
+ ev = ev_raw
206
+
207
+ inferred = bool(
208
+ edge.get("rel") == "RESOLVES_TO"
209
+ or str(edge.get("src", "")).startswith("sym:")
210
+ or str(edge.get("dst", "")).startswith("sym:")
211
+ or (isinstance(ev, dict) and "resolution_mode" in ev)
212
+ )
213
+ if inferred:
214
+ out["inferred"] = True
215
+ if isinstance(ev, dict):
216
+ out["confidence"] = ev.get("confidence", "unknown")
217
+ out["provenance"] = {
218
+ "resolution_mode": ev.get("resolution_mode"),
219
+ "symbol": ev.get("symbol"),
220
+ "candidate_count": ev.get("candidate_count"),
221
+ }
222
+ out["evidence"] = ev
223
+ else:
224
+ out["confidence"] = "unknown"
225
+ out["provenance"] = {"resolution_mode": None}
226
+ enriched.append(out)
227
+ return enriched
228
+
229
+
230
+ def _snapshot_freshness(snapshot_total_nodes: int) -> dict:
231
+ """Compare a snapshot's node count against the currently loaded graph DB.
232
+
233
+ :param snapshot_total_nodes: ``metrics.total_nodes`` from a snapshot object.
234
+ :return: Freshness metadata payload.
235
+ """
236
+ current = _get_kg().stats()
237
+ current_nodes = int(current.get("total_nodes", 0))
238
+ delta = current_nodes - int(snapshot_total_nodes)
239
+
240
+ is_fresh = delta == 0
241
+ status = "fresh" if delta == 0 else ("behind" if delta > 0 else "ahead")
242
+ note = None
243
+
244
+ if 0 < delta < 50:
245
+ is_fresh = True
246
+ status = "near_fresh"
247
+ note = "Within tolerance (sym: stubs often accumulate between rebuilds)"
248
+
249
+ out = {
250
+ "snapshot_total_nodes": int(snapshot_total_nodes),
251
+ "current_total_nodes": current_nodes,
252
+ "delta_nodes": delta,
253
+ "is_fresh": is_fresh,
254
+ "status": status,
255
+ }
256
+ if note:
257
+ out["note"] = note
258
+ return out
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # MCP server
263
+ # ---------------------------------------------------------------------------
264
+
265
+ mcp = FastMCP(
266
+ "pycodekg",
267
+ instructions=(
268
+ "PyCodeKG is a hybrid semantic + structural knowledge graph for a Python codebase. "
269
+ "It indexes every module, class, function, and method as a node, with typed edges "
270
+ "(CALLS, IMPORTS, CONTAINS, INHERITS) connecting them. Use these tools to navigate "
271
+ "and understand the codebase precisely and efficiently.\n\n"
272
+ "## Tools\n\n"
273
+ "**graph_stats()** — Start here when you first engage with a repo. Returns a Markdown "
274
+ "summary with total node and edge counts broken down by kind (module, class, function, "
275
+ "method) and relation, plus meaningful_nodes (real code entities, excluding sym: stubs). "
276
+ "Use it to understand the size and shape of the codebase before issuing "
277
+ "query_codebase() or pack_snippets() queries.\n\n"
278
+ "**query_codebase(q, k, hop, rels, include_symbols, max_nodes, min_score, max_per_module, paths, rerank_mode, rerank_semantic_weight, rerank_lexical_weight, include_edge_provenance)** — Hybrid "
279
+ "semantic + structural search. Seeds on vector similarity then expands through the "
280
+ "graph. Use hop=0 for pure semantic lookup (highest precision), hop=1 (default) for "
281
+ "call chains and module relationships, hop=2 for broad dependency tracing. Use the "
282
+ "paths parameter to restrict results to a specific subtree (e.g. 'src/pycode_kg'). "
283
+ "rerank_mode controls result ordering: 'hybrid' (default) blends vector similarity "
284
+ "(70%) with lexical name/docstring overlap (30%) — best overall quality; 'semantic' "
285
+ "ranks by vector score only; 'legacy' uses hop-first ordering (pre-reranking behavior, "
286
+ "use only for stable comparisons). Set include_edge_provenance=True to annotate "
287
+ "inferred edges with confidence and resolution metadata. "
288
+ "For precision queries where you know the concept well, use min_score=0.5 to filter "
289
+ "out incidental docstring mentions. "
290
+ "Returns ranked nodes and edges as JSON.\n\n"
291
+ "**pack_snippets(q, k, hop, rels, include_symbols, context, max_lines, max_nodes, min_score, max_per_module, rerank_mode, rerank_semantic_weight, rerank_lexical_weight, missing_lineno_policy, include_edge_provenance)** — "
292
+ "Same hybrid search as query_codebase, but returns actual source code. Produces a "
293
+ "Markdown context pack with ranked, deduplicated snippets and line numbers. Prefer "
294
+ "this over query_codebase whenever you need to read or reason about implementation "
295
+ "details, trace logic, or answer 'how does X work?' questions. Default rerank_mode "
296
+ "is 'hybrid' (same as query_codebase). Includes a Warnings section when snippets "
297
+ "are capped or omitted due to missing line metadata.\n\n"
298
+ "**get_node(node_id, include_edges)** — Precise lookup of a single node by its stable ID "
299
+ "(format: '<kind>:<module_path>:<qualname>', e.g. "
300
+ "'cls:src/pycode_kg/store.py:GraphStore'). Returns Markdown with kind, location, ID, "
301
+ "and full docstring. Pass include_edges=True to also render the node's immediate "
302
+ "neighborhood: outgoing edges grouped by relation type (CALLS, CONTAINS, IMPORTS, "
303
+ "INHERITS) and resolved incoming CALLS callers, eliminating a separate callers() "
304
+ "round-trip. Use query_codebase() to discover node IDs, then get_node() to inspect "
305
+ "them, then pack_snippets() to read the source.\n\n"
306
+ "**list_nodes(module_path, kind)** — List nodes filtered by module path prefix "
307
+ "and/or kind (e.g. 'function', 'class'). Returns a JSON array of matching nodes. "
308
+ "Use this to enumerate the contents of a specific module before inspecting "
309
+ "individual nodes with get_node().\n\n"
310
+ "**find_node(name, kind)** — Find nodes by plain name or qualname when the stable "
311
+ "ID is unknown. Case-insensitive match against name and qualname columns; sym: stubs "
312
+ "are excluded. Optional kind filter narrows to 'function', 'class', 'method', or "
313
+ "'module'. Returns a JSON array of matching node dicts including id, kind, "
314
+ "module_path, lineno, and docstring. Use this as the first step when you see a "
315
+ "function name in a traceback or file and need its stable ID for explain() or "
316
+ "callers().\n\n"
317
+ "**centrality(top, kinds, group_by)** — Compute Structural Importance Ranking (SIR): "
318
+ "a deterministic weighted PageRank over the sym-stub-resolved call graph. Edge weights "
319
+ "favour CALLS > INHERITS > IMPORTS > CONTAINS; cross-module edges receive a boost and "
320
+ "private symbols are penalized. Returns a Markdown ranking table. Use top to cap "
321
+ "results (default 20), kinds to filter by node type (e.g. 'class,function'), and "
322
+ "group_by='module' for a per-module rollup. Ideal for identifying hotspots before "
323
+ "refactoring, prioritizing test coverage, or understanding which modules are most "
324
+ "depended upon.\n\n"
325
+ "**explain(node_id, limit=10)** — Natural-language orientation for a single node. Returns a "
326
+ "Markdown report with: kind and qualified name, module path and line range, full "
327
+ "docstring, list of callers (what calls this node), list of callees (what this node "
328
+ "calls), and a role assessment (high-value / utility / orphaned) based on call count. "
329
+ "Pass limit=0 to list all callers and callees without truncation. "
330
+ "Use this as the first step when a user asks about a specific function or class, "
331
+ "before reaching for pack_snippets.\n\n"
332
+ "**callers(node_id, rel, paths)** — Reverse edge lookup: find every node that calls "
333
+ "(or imports, or inherits from) a given node. Resolves through sym: import stubs so "
334
+ "cross-module callers are included. Use paths to restrict to production code only "
335
+ "(e.g. paths='src/'). Returns JSON with node_id, rel, caller_count, and a callers "
336
+ "list of node dicts — suitable for programmatic processing or chaining into other "
337
+ "tools. Useful for impact analysis — 'what breaks if I change X?'. "
338
+ "Alternatively, use get_node(include_edges=True) to get callers alongside the full "
339
+ "node details in a single call.\n\n"
340
+ "**analyze_repo()** — Full nine-phase architectural analysis: complexity hotspots, "
341
+ "high fan-out functions, module coupling, circular dependencies, key call chains, "
342
+ "public API surface, docstring coverage, code quality issues, and orphaned code. "
343
+ "Returns structured Markdown. Use when the user wants a health check or architectural "
344
+ "overview of the entire codebase.\n\n"
345
+ "**snapshot_list(limit, branch)** — List saved temporal snapshots in reverse "
346
+ "chronological order. Each entry has a ``key`` (tree hash), branch, timestamp, "
347
+ "version, and key metrics (nodes, edges, coverage, critical issues) plus deltas vs. "
348
+ "the previous snapshot. Optional ``branch`` filters to a specific branch. Each "
349
+ "entry includes a freshness indicator comparing snapshot node count to the current DB. "
350
+ "Use to answer 'how has the codebase grown?' or 'when did coverage improve?'\n\n"
351
+ "**snapshot_show(key)** — Show full details of a specific snapshot by its ``key`` "
352
+ "(tree hash from snapshot_list), or the most recent snapshot when key='latest' "
353
+ "(default). Returns full metrics, complexity hotspots, deltas vs. both the "
354
+ "previous and baseline snapshots, plus freshness vs. current DB.\n\n"
355
+ "**snapshot_diff(key_a, key_b)** — Compare two snapshots side-by-side. Returns "
356
+ "metrics for both snapshots and a computed delta (b − a) for nodes, edges, coverage, "
357
+ "and critical issues, along with lists of introduced and resolved issues. Also reports "
358
+ "freshness for both snapshots. Use snapshot_list() first to get the ``key`` values.\n\n"
359
+ "## Recommended Workflows\n\n"
360
+ "- **Explore unfamiliar code**: graph_stats → query_codebase → list_nodes (to enumerate a module) → explain → pack_snippets\n"
361
+ "- **Understand a specific function**: get_node(include_edges=True) → pack_snippets\n"
362
+ "- **Look up by name (no ID known)**: find_node(name) → explain or callers\n"
363
+ "- **Impact analysis before a change**: explain → callers (with paths='src/')\n"
364
+ "- **Architecture review**: analyze_repo\n"
365
+ "- **Answer 'how does X work?'**: pack_snippets with a descriptive query\n"
366
+ "- **Track codebase evolution**: snapshot_list → snapshot_diff(key_a=..., key_b=...)\n"
367
+ "- **Identify most important code**: centrality(top=20) → explain → pack_snippets\n\n"
368
+ "## CodeRank Tools\n\n"
369
+ "**rank_nodes(top, rels, persist_metric, exclude_tests)** — Compute global weighted "
370
+ "CodeRank (PageRank) over the repository graph. Returns a JSON array of the top-N "
371
+ "most structurally important nodes with score, top_pct (e.g. 'top 0.5%'), kind, "
372
+ "qualname, and module_path. "
373
+ "Pass persist_metric='coderank_global' to save scores into the node_metrics table "
374
+ "for later use at query time. Relation weights: CALLS=1.0, IMPORTS=0.9, INHERITS=0.75. "
375
+ "Test paths are excluded by default.\n\n"
376
+ "**query_ranked(q, k, mode, top, rels, radius, exclude_tests)** — Rank query results "
377
+ "using CodeRank-enhanced hybrid or personalized PageRank. Combines semantic seed scores "
378
+ "from the vector index with structural centrality and graph proximity. "
379
+ "mode='hybrid' (default): 0.60×semantic + 0.25×centrality + 0.15×proximity. "
380
+ "mode='ppr': 0.70×personalized PageRank + 0.30×semantic. "
381
+ "Returns JSON with per-node score components and explainability 'why' strings. "
382
+ "Use this instead of query_codebase when you want structure-aware ranking.\n\n"
383
+ "**explain_rank(node_id, q)** — Explain the CodeRank score components for a specific "
384
+ "node. Returns Markdown with global rank position, inbound/outbound edge counts "
385
+ "(callers, importers, inheritors), and optional query-conditioned semantic + proximity "
386
+ "scores when q is provided. Use after rank_nodes or query_ranked to understand why "
387
+ "a node ranked where it did.\n\n"
388
+ "## CodeRank Workflows\n\n"
389
+ "- **Find most important nodes globally**: rank_nodes(top=25) → explain_rank\n"
390
+ "- **Persist global rank for later use**: rank_nodes(persist_metric='coderank_global')\n"
391
+ "- **Structure-aware query**: query_ranked(q='database connection', mode='hybrid')\n"
392
+ "- **PPR query**: query_ranked(q='...', mode='ppr') → explain_rank(node_id, q='...')"
393
+ ),
394
+ )
395
+
396
+
397
+ @mcp.tool()
398
+ def query_codebase(
399
+ q: str,
400
+ k: int = 8,
401
+ hop: int = 1,
402
+ rels: str = "CONTAINS,CALLS,IMPORTS,INHERITS",
403
+ include_symbols: bool = False,
404
+ max_nodes: int = 25,
405
+ min_score: float = 0.0,
406
+ max_per_module: int = 3,
407
+ paths: str = "",
408
+ rerank_mode: str = "hybrid",
409
+ rerank_semantic_weight: float = 0.7,
410
+ rerank_lexical_weight: float = 0.3,
411
+ include_edge_provenance: bool = False,
412
+ format: str = "json",
413
+ ) -> str:
414
+ """
415
+ Hybrid semantic + structural query over the codebase knowledge graph.
416
+
417
+ Performs vector-similarity seeding followed by graph expansion to return
418
+ a ranked set of relevant nodes (modules, classes, functions, methods) and
419
+ the edges between them.
420
+
421
+ **hop heuristic:**
422
+
423
+ - ``hop=0`` — pure semantic search; highest precision, no graph expansion.
424
+ Best for concept lookup ("find the retry logic").
425
+ - ``hop=1`` — semantic seeds + 1-hop expansion (default); reveals call
426
+ chains and module relationships. Best for architecture questions.
427
+ - ``hop=2`` — deep dependency tracing; may return many loosely related
428
+ nodes. Use only when you need broad coverage.
429
+
430
+ **rerank_mode** controls result ordering after graph expansion:
431
+
432
+ - ``hybrid`` (default) — blends vector similarity (70%) with lexical
433
+ overlap on name/qualname/module/docstring (30%). Best overall quality:
434
+ surfaces the right node even when the embedding alone is weak.
435
+ - ``semantic`` — ranks purely by vector similarity score. Use when
436
+ exact name matching is not important.
437
+ - ``legacy`` — hop distance first, then vector distance. Preserves
438
+ pre-reranking behavior; use only if you need stable ordering for
439
+ comparisons against older results.
440
+
441
+ **format** controls the output representation:
442
+
443
+ - ``json`` (default) — structured JSON with full relevance metadata.
444
+ Best for programmatic consumption or when scores matter.
445
+ - ``markdown`` — compact ranked Markdown table. Easier to read in
446
+ conversation context; omits raw score fields.
447
+
448
+ :param q: Natural-language query, e.g. "database connection setup".
449
+ :param k: Number of semantic seed nodes (default 8).
450
+ :param hop: Graph expansion hops from each seed (default 1).
451
+ :param rels: Comma-separated edge types to follow
452
+ (CONTAINS, CALLS, IMPORTS, INHERITS).
453
+ :param include_symbols: Include low-level symbol nodes (default False).
454
+ :param max_nodes: Maximum nodes to return (default 25).
455
+ :param min_score: Minimum semantic score for seed inclusion in ``[0, 1]``.
456
+ :param max_per_module: Maximum nodes per module (default 3; 0 disables).
457
+ Prevents a single popular module from dominating hop-1 expansion results.
458
+ Lower for precision (e.g. 2), raise or disable for broad coverage queries.
459
+ :param paths: Comma-separated module path prefixes to include, e.g.
460
+ ``"src/pycode_kg"`` to exclude tests and scripts.
461
+ Empty string (default) returns all paths.
462
+ :param rerank_mode: Ranking strategy: ``hybrid`` (default), ``semantic``,
463
+ or ``legacy``.
464
+ :param rerank_semantic_weight: Semantic component weight for ``hybrid``
465
+ mode (default 0.7).
466
+ :param rerank_lexical_weight: Lexical component weight for ``hybrid``
467
+ mode (default 0.3).
468
+ :param include_edge_provenance: When ``True``, inferred edges include
469
+ confidence/provenance fields when available.
470
+ :param format: Output format: ``json`` (default) or ``markdown``.
471
+ :return: JSON string (format='json') or Markdown table (format='markdown').
472
+ """
473
+ rel_tuple = tuple(r.strip() for r in rels.split(",") if r.strip())
474
+ result = _get_kg().query(
475
+ q,
476
+ k=k,
477
+ hop=hop,
478
+ rels=rel_tuple or DEFAULT_RELS,
479
+ include_symbols=include_symbols,
480
+ max_nodes=max_nodes,
481
+ min_score=min_score,
482
+ max_per_module=max_per_module if max_per_module > 0 else None,
483
+ rerank_mode=rerank_mode,
484
+ rerank_semantic_weight=rerank_semantic_weight,
485
+ rerank_lexical_weight=rerank_lexical_weight,
486
+ )
487
+ data = json.loads(result.to_json())
488
+ if include_edge_provenance:
489
+ data["edges"] = _enrich_edges_with_provenance(data.get("edges", []))
490
+
491
+ if paths:
492
+ path_prefixes = [p.strip() for p in paths.split(",") if p.strip()]
493
+ included_ids = {
494
+ n["id"]
495
+ for n in data["nodes"]
496
+ if any((n.get("module_path") or "").startswith(pfx) for pfx in path_prefixes)
497
+ }
498
+ data["nodes"] = [n for n in data["nodes"] if n["id"] in included_ids]
499
+ data["edges"] = [
500
+ e for e in data["edges"] if e["src"] in included_ids and e["dst"] in included_ids
501
+ ]
502
+ data["returned_nodes"] = len(data["nodes"])
503
+
504
+ if format == "markdown":
505
+ out: list[str] = [
506
+ f"## Query Results: `{q}`\n",
507
+ f"**Seeds:** {data['seeds']} | "
508
+ f"**Expanded:** {data['expanded_nodes']} | "
509
+ f"**Returned:** {data['returned_nodes']} | "
510
+ f"**hop:** {data['hop']}\n",
511
+ "| Rank | Score | Kind | Name | Module |",
512
+ "|-----:|------:|------|------|--------|",
513
+ ]
514
+ for rank_idx, node in enumerate(data["nodes"], start=1):
515
+ score = node.get("relevance", {}).get("score", 0.0)
516
+ kind = node.get("kind", "?")
517
+ name = node.get("qualname") or node.get("name", "?")
518
+ module = node.get("module_path", "")
519
+ out.append(f"| {rank_idx} | {score:.3f} | {kind} | `{name}` | `{module}` |")
520
+ if data.get("edges"):
521
+ out.append("\n### Edges\n")
522
+ for e in data["edges"][:20]:
523
+ out.append(f"- `{e['src']}` -[{e['rel']}]-> `{e['dst']}`")
524
+ return "\n".join(out)
525
+
526
+ return json.dumps(data, indent=2, ensure_ascii=False)
527
+
528
+
529
+ @mcp.tool()
530
+ def pack_snippets(
531
+ q: str,
532
+ k: int = 8,
533
+ hop: int = 1,
534
+ rels: str = "CONTAINS,CALLS,IMPORTS,INHERITS",
535
+ include_symbols: bool = False,
536
+ context: int = 5,
537
+ max_lines: int = 60,
538
+ max_nodes: int = 15,
539
+ min_score: float = 0.0,
540
+ max_per_module: int = 3,
541
+ rerank_mode: str = "hybrid",
542
+ rerank_semantic_weight: float = 0.7,
543
+ rerank_lexical_weight: float = 0.3,
544
+ missing_lineno_policy: str = "cap_or_skip",
545
+ include_edge_provenance: bool = False,
546
+ ) -> str:
547
+ """
548
+ Hybrid query + source-grounded snippet extraction.
549
+
550
+ Returns a Markdown context pack containing ranked, deduplicated code
551
+ snippets with line numbers — ready for direct LLM ingestion.
552
+
553
+ **rerank_mode** controls result ordering after graph expansion:
554
+
555
+ - ``hybrid`` (default) — blends vector similarity (70%) with lexical
556
+ overlap on name/qualname/module/docstring (30%). Best overall quality:
557
+ surfaces the right node even when the embedding alone is weak.
558
+ - ``semantic`` — ranks purely by vector similarity score. Use when
559
+ exact name matching is not important.
560
+ - ``legacy`` — hop distance first, then vector distance. Preserves
561
+ pre-reranking behavior; use only if you need stable ordering for
562
+ comparisons against older results.
563
+
564
+ :param q: Natural-language query, e.g. "configuration loading".
565
+ :param k: Number of semantic seed nodes (default 8).
566
+ :param hop: Graph expansion hops (default 1).
567
+ :param rels: Comma-separated edge types to follow.
568
+ :param include_symbols: Include symbol nodes (default False).
569
+ :param context: Extra context lines around each definition (default 5).
570
+ :param max_lines: Maximum lines per snippet block (default 60).
571
+ :param max_nodes: Maximum nodes to include in the pack (default 15).
572
+ :param min_score: Minimum semantic score for seed inclusion in ``[0, 1]``.
573
+ :param max_per_module: Maximum nodes per module (default 3; 0 disables).
574
+ Prevents a single popular module from dominating hop-1 expansion results.
575
+ :param rerank_mode: Ranking strategy: ``hybrid`` (default), ``semantic``,
576
+ or ``legacy``.
577
+ :param rerank_semantic_weight: Semantic component weight for ``hybrid``
578
+ mode (default 0.7).
579
+ :param rerank_lexical_weight: Lexical component weight for ``hybrid``
580
+ mode (default 0.3).
581
+ :param missing_lineno_policy: Behavior when a non-module node has missing
582
+ line metadata: ``cap_or_skip`` (default) falls back to a capped parent
583
+ span or omits the snippet; ``legacy`` uses the old uncapped behavior.
584
+ :param include_edge_provenance: When ``True``, enrich edges in markdown
585
+ output with inferred confidence/provenance fields.
586
+ :return: Markdown string with source-grounded code snippets.
587
+ """
588
+ rel_tuple = tuple(r.strip() for r in rels.split(",") if r.strip())
589
+ pack = _get_kg().pack(
590
+ q,
591
+ k=k,
592
+ hop=hop,
593
+ rels=rel_tuple or DEFAULT_RELS,
594
+ include_symbols=include_symbols,
595
+ context=context,
596
+ max_lines=max_lines,
597
+ max_nodes=max_nodes,
598
+ min_score=min_score,
599
+ max_per_module=max_per_module if max_per_module > 0 else None,
600
+ rerank_mode=rerank_mode,
601
+ rerank_semantic_weight=rerank_semantic_weight,
602
+ rerank_lexical_weight=rerank_lexical_weight,
603
+ missing_lineno_policy=missing_lineno_policy,
604
+ )
605
+ if include_edge_provenance:
606
+ pack.edges = _enrich_edges_with_provenance(pack.edges)
607
+ return pack.to_markdown()
608
+
609
+
610
+ @mcp.tool()
611
+ def callers(node_id: str, rel: str = "CALLS", paths: str = "") -> str:
612
+ """
613
+ Return all nodes that call a given node, resolving through ``sym:`` stubs.
614
+
615
+ Unlike ``query_codebase`` (which seeds on semantics and expands outward),
616
+ this tool performs a precise reverse lookup: it finds every caller of the
617
+ specified node, including cross-module callers that reference it via an
618
+ import alias recorded as a ``sym:`` stub.
619
+
620
+ The ``rel`` parameter accepts any edge relation, not just ``CALLS``::
621
+
622
+ callers(node_id, rel="INHERITS") # find all subclasses
623
+ callers(node_id, rel="IMPORTS") # find all importers
624
+
625
+ Typical workflow::
626
+
627
+ # 1. Resolve the exact node ID
628
+ get_node("fn:src/my_pkg/utils.py:helper")
629
+
630
+ # 2. Find all callers (production code only)
631
+ callers("fn:src/my_pkg/utils.py:helper", paths="src/")
632
+
633
+ :param node_id: Target node identifier, e.g.
634
+ ``fn:src/pycode_kg/store.py:GraphStore.expand``.
635
+ :param rel: Relation type to invert (default ``"CALLS"``).
636
+ :param paths: Comma-separated module path prefixes to include, e.g.
637
+ ``"src/"`` to exclude test callers.
638
+ Empty string (default) returns all callers.
639
+ :return: JSON with ``node_id``, ``rel``, ``caller_count``, and
640
+ ``callers`` list of node dicts.
641
+ """
642
+ caller_list = _get_kg().callers(node_id, rel=rel)
643
+ if paths:
644
+ path_prefixes = [p.strip() for p in paths.split(",") if p.strip()]
645
+ caller_list = [
646
+ c
647
+ for c in caller_list
648
+ if any((c.get("module_path") or "").startswith(pfx) for pfx in path_prefixes)
649
+ ]
650
+ return json.dumps(
651
+ {
652
+ "node_id": node_id,
653
+ "rel": rel,
654
+ "caller_count": len(caller_list),
655
+ "callers": caller_list,
656
+ },
657
+ indent=2,
658
+ ensure_ascii=False,
659
+ )
660
+
661
+
662
+ @mcp.tool()
663
+ def get_node(node_id: str, include_edges: bool = False) -> str:
664
+ """
665
+ Fetch a single node by its stable ID and render it as Markdown.
666
+
667
+ Node IDs follow the pattern ``<kind>:<module_path>:<qualname>``,
668
+ e.g. ``cls:src/pycode_kg/store.py:GraphStore`` or
669
+ ``fn:src/pycode_kg/store.py:GraphStore.expand``. Use
670
+ ``query_codebase()`` to discover node IDs when the exact ID is
671
+ unknown.
672
+
673
+ When *include_edges* is ``True`` the Markdown report also contains
674
+ the node's immediate neighborhood — outgoing edges grouped by
675
+ relation type (CALLS, CONTAINS, IMPORTS, INHERITS) and all resolved
676
+ incoming CALLS callers — eliminating a separate ``callers()``
677
+ round-trip for routine inspection. Follow up with ``pack_snippets()``
678
+ to retrieve the actual source implementation.
679
+
680
+ Example workflow::
681
+
682
+ # 1. Discover the node ID
683
+ query_codebase("graph database storage")
684
+
685
+ # 2. Inspect the node and its neighborhood
686
+ get_node("cls:src/pycode_kg/store.py:GraphStore", include_edges=True)
687
+
688
+ # 3. Read the source
689
+ pack_snippets("GraphStore implementation")
690
+
691
+ :param node_id: Stable node identifier in ``<kind>:<module_path>:<qualname>``
692
+ format, e.g. ``fn:src/pycode_kg/store.py:GraphStore.expand``.
693
+ :param include_edges: If ``True``, append outgoing edges by relation type
694
+ and incoming CALLS callers to the report
695
+ (default ``False``).
696
+ :return: Markdown-formatted node summary with optional neighborhood, or an
697
+ error section if the node does not exist.
698
+ """
699
+ kg = _get_kg()
700
+ node = kg.node(node_id)
701
+ if node is None:
702
+ return f"## Node Not Found\n\nNode ID `{node_id}` does not exist in the knowledge graph."
703
+
704
+ kind = node.get("kind", "unknown")
705
+ name = node.get("qualname") or node.get("name", "unknown")
706
+ out: list[str] = [f"## `{name}` ({kind})\n"]
707
+
708
+ module = node.get("module_path", "")
709
+ lineno = node.get("lineno")
710
+ end_lineno = node.get("end_lineno")
711
+ if module:
712
+ out.append(f"- **Module:** `{module}`")
713
+ if lineno is not None:
714
+ loc = f"line {lineno}"
715
+ if end_lineno:
716
+ loc += f"–{end_lineno}"
717
+ out.append(f"- **Location:** {loc}")
718
+ out.append(f"- **ID:** `{node_id}`")
719
+ out.append("")
720
+
721
+ docstring = node.get("docstring", "").strip()
722
+ if docstring:
723
+ out.append("### Documentation\n")
724
+ out.append(docstring)
725
+ out.append("")
726
+
727
+ if not include_edges:
728
+ return "\n".join(out)
729
+
730
+ # Build neighborhood: outgoing edges per relation type
731
+ store = getattr(kg, "_store", None)
732
+ if store is not None:
733
+ for rel in ("CALLS", "CONTAINS", "IMPORTS", "INHERITS"):
734
+ edges = store.edges_from(node_id, rel=rel)
735
+ visible = [e for e in edges if not e["dst"].startswith("sym:")] if edges else []
736
+ if visible:
737
+ out.append(f"### Outgoing {rel}\n")
738
+ for e in visible:
739
+ out.append(f"- `{e['dst']}`")
740
+ out.append("")
741
+
742
+ # Incoming CALLS callers (resolved through sym: stubs)
743
+ try:
744
+ caller_nodes = kg.callers(node_id, rel="CALLS")
745
+ if caller_nodes:
746
+ out.append("### Incoming Calls\n")
747
+ for c in caller_nodes:
748
+ cname = c.get("qualname") or c.get("name", "")
749
+ cmod = c.get("module_path", "")
750
+ cline = c.get("lineno")
751
+ cid = c.get("id", "")
752
+ loc_str = f" (line {cline})" if cline else ""
753
+ out.append(f"- `{cid}` — `{cname}` in `{cmod}`{loc_str}")
754
+ out.append("")
755
+ except (AttributeError, ValueError, RuntimeError):
756
+ pass
757
+
758
+ return "\n".join(out)
759
+
760
+
761
+ @mcp.tool()
762
+ def graph_stats() -> str:
763
+ """
764
+ Return node and edge counts broken down by kind and relation as Markdown.
765
+
766
+ Call this first when engaging with a new repo to understand the scale and
767
+ shape of the knowledge graph before issuing ``query_codebase()`` or
768
+ ``pack_snippets()`` queries. ``meaningful_nodes`` excludes ``sym:``
769
+ infrastructure stubs so the count reflects real code entities (modules,
770
+ classes, functions, methods).
771
+
772
+ Also reports ``docstring_coverage`` (fraction of functions/methods with
773
+ docstrings) and ``snapshot_count`` (number of saved temporal snapshots).
774
+
775
+ :return: Markdown-formatted summary with total counts, domain metrics,
776
+ a nodes-by-kind table, and an edges-by-relation table.
777
+ """
778
+ stats = _get_kg().stats()
779
+ out: list[str] = ["## PyCodeKG Graph Statistics\n"]
780
+ out.append(f"- **Database:** `{stats.get('db_path', '')}`")
781
+ out.append(f"- **Total nodes:** {stats.get('total_nodes', 0):,}")
782
+ out.append(
783
+ f"- **Meaningful nodes:** {stats.get('meaningful_nodes', 0):,} *(excludes sym: stubs)*"
784
+ )
785
+ out.append(f"- **Total edges:** {stats.get('total_edges', 0):,}")
786
+ cov = stats.get("docstring_coverage")
787
+ if cov is not None:
788
+ out.append(f"- **Docstring coverage:** {cov:.1%} *(functions + methods)*")
789
+ snap = stats.get("snapshot_count")
790
+ if snap is not None:
791
+ out.append(f"- **Snapshots:** {snap:,}")
792
+ out.append("")
793
+
794
+ node_counts: dict = stats.get("node_counts", {})
795
+ if node_counts:
796
+ out.append("### Nodes by Kind\n")
797
+ out.append("| Kind | Count |")
798
+ out.append("|------|------:|")
799
+ for kind, count in sorted(node_counts.items(), key=lambda x: -x[1]):
800
+ out.append(f"| {kind} | {count:,} |")
801
+ out.append("")
802
+
803
+ edge_counts: dict = stats.get("edge_counts", {})
804
+ if edge_counts:
805
+ out.append("### Edges by Relation\n")
806
+ out.append("| Relation | Count |")
807
+ out.append("|----------|------:|")
808
+ for rel, count in sorted(edge_counts.items(), key=lambda x: -x[1]):
809
+ out.append(f"| {rel} | {count:,} |")
810
+ out.append("")
811
+
812
+ out.append(
813
+ "> `sym:` nodes are import stub placeholders used for cross-module caller resolution — they are not local code entities."
814
+ )
815
+ return "\n".join(out)
816
+
817
+
818
+ @mcp.tool()
819
+ def list_nodes(module_path: str = "", kind: str = "") -> str:
820
+ """
821
+ List nodes filtered by module path prefix and/or kind.
822
+
823
+ :param module_path: Module path prefix filter (e.g. "src/pycode_kg/store.py").
824
+ :param kind: Node kind filter: module | class | function | method.
825
+ :return: JSON array of matching node dicts.
826
+ """
827
+ kg = _get_kg()
828
+ store = getattr(kg, "_store", None)
829
+ if not store:
830
+ return json.dumps({"error": "No database store available."}, indent=2)
831
+
832
+ query = "SELECT id, name, qualname, kind, module_path, lineno, docstring FROM nodes WHERE 1=1"
833
+ params = []
834
+
835
+ if module_path:
836
+ query += " AND module_path LIKE ?"
837
+ params.append(f"{module_path}%")
838
+
839
+ if kind:
840
+ query += " AND kind = ?"
841
+ params.append(kind)
842
+
843
+ query += " ORDER BY module_path, lineno"
844
+
845
+ try:
846
+ rows = store.con.execute(query, params).fetchall()
847
+ result = []
848
+ for r in rows:
849
+ doc = r[6]
850
+ if doc and len(doc) > 100:
851
+ doc = doc[:100] + "..."
852
+ result.append(
853
+ {
854
+ "id": r[0],
855
+ "name": r[1],
856
+ "qualname": r[2],
857
+ "kind": r[3],
858
+ "module_path": r[4],
859
+ "lineno": r[5],
860
+ "docstring": doc,
861
+ }
862
+ )
863
+ return json.dumps(result, indent=2, ensure_ascii=False)
864
+ except Exception as e: # pylint: disable=broad-except
865
+ return json.dumps({"error": str(e)}, indent=2)
866
+
867
+
868
+ @mcp.tool()
869
+ def find_node(name: str, kind: str = "") -> str:
870
+ """
871
+ Find graph nodes by name without knowing their full stable ID.
872
+
873
+ Performs a case-insensitive search against both the ``name`` and
874
+ ``qualname`` columns. Use this when you know (or partially know) a
875
+ function, class, or method name but do not yet have its stable ID
876
+ (``<kind>:<module_path>:<qualname>``). Once you have the ID, pass
877
+ it to ``get_node()``, ``explain()``, or ``callers()``.
878
+
879
+ Example workflow::
880
+
881
+ # 1. Look up a name you saw in a traceback
882
+ find_node("_get_kg")
883
+
884
+ # 2. Use the returned id with explain or get_node
885
+ explain("fn:src/pycode_kg/mcp_server.py:_get_kg")
886
+
887
+ :param name: Function, method, or class name to search for.
888
+ Matched case-insensitively against ``name`` and ``qualname``.
889
+ :param kind: Optional kind filter: ``module`` | ``class`` | ``function``
890
+ | ``method``. Empty string (default) searches all kinds.
891
+ :return: JSON array of matching node dicts with ``id``, ``name``,
892
+ ``qualname``, ``kind``, ``module_path``, ``lineno``, and a
893
+ truncated ``docstring``. Returns an empty array when no match
894
+ is found.
895
+ """
896
+ kg = _get_kg()
897
+ store = getattr(kg, "_store", None)
898
+ if not store:
899
+ return json.dumps({"error": "No database store available."}, indent=2)
900
+
901
+ name_lower = name.lower()
902
+ query = (
903
+ "SELECT id, name, qualname, kind, module_path, lineno, docstring "
904
+ "FROM nodes WHERE (LOWER(name) = ? OR LOWER(qualname) LIKE ?)"
905
+ )
906
+ params: list = [name_lower, f"%{name_lower}%"]
907
+
908
+ if kind:
909
+ query += " AND kind = ?"
910
+ params.append(kind)
911
+
912
+ query += " AND id NOT LIKE 'sym:%' ORDER BY module_path, lineno"
913
+
914
+ try:
915
+ rows = store.con.execute(query, params).fetchall()
916
+ result = []
917
+ for r in rows:
918
+ doc = r[6]
919
+ if doc and len(doc) > 120:
920
+ doc = doc[:120] + "..."
921
+ result.append(
922
+ {
923
+ "id": r[0],
924
+ "name": r[1],
925
+ "qualname": r[2],
926
+ "kind": r[3],
927
+ "module_path": r[4],
928
+ "lineno": r[5],
929
+ "docstring": doc,
930
+ }
931
+ )
932
+ return json.dumps(result, indent=2, ensure_ascii=False)
933
+ except Exception as e: # pylint: disable=broad-except
934
+ return json.dumps({"error": str(e)}, indent=2)
935
+
936
+
937
+ @mcp.tool()
938
+ def centrality(
939
+ top: int = 20,
940
+ kinds: str = "",
941
+ group_by: str = "node",
942
+ ) -> str:
943
+ """
944
+ Compute Structural Importance Ranking (SIR) for the indexed codebase.
945
+
946
+ Runs a deterministic weighted PageRank over the sym-stub-resolved call
947
+ graph. Edge weights are tuned per relation type
948
+ (CALLS > INHERITS > IMPORTS > CONTAINS) and amplified for cross-module
949
+ links; private symbols receive a post-convergence penalty. Scores are
950
+ normalized to sum to 1.0.
951
+
952
+ Use this to:
953
+
954
+ - Identify the most structurally critical functions and classes
955
+ - Understand which modules are most depended upon
956
+ - Prioritize code review, refactoring, or test coverage efforts
957
+
958
+ :param top: Maximum number of ranked entries to return (default 20).
959
+ :param kinds: Comma-separated node kinds to include: ``module``, ``class``,
960
+ ``function``, ``method``. Empty string returns all kinds.
961
+ Ignored when ``group_by='module'`` (all kinds contribute to
962
+ module aggregation).
963
+ :param group_by: ``node`` (default) returns individual node rankings with
964
+ score, inbound edge count, and cross-module inbound count;
965
+ ``module`` aggregates node scores per module (class nodes
966
+ weighted ×1.2).
967
+ :return: Markdown-formatted ranking table.
968
+ """
969
+ try:
970
+ from pycode_kg.analysis.centrality import ( # pylint: disable=import-outside-toplevel
971
+ StructuralImportanceRanker,
972
+ aggregate_module_scores,
973
+ )
974
+
975
+ db_path = _get_kg().db_path
976
+ ranker = StructuralImportanceRanker(db_path)
977
+ all_records = ranker.compute()
978
+ except Exception as e: # pylint: disable=broad-except
979
+ return f"## Centrality Error\n\nFailed to compute SIR scores: `{e}`"
980
+
981
+ out: list[str] = ["## Structural Importance Ranking (SIR)\n"]
982
+
983
+ if group_by == "module":
984
+ payload = aggregate_module_scores(all_records)[:top]
985
+ out.append(f"**Group by:** module | **Top:** {top}\n")
986
+ out.append("| Rank | Score | Members | Module |")
987
+ out.append("|-----:|------:|--------:|--------|")
988
+ for row in payload:
989
+ out.append(
990
+ f"| {row['rank']} | {row['score']:.6f}"
991
+ f" | {row['member_count']} | `{row['module_path']}` |"
992
+ )
993
+ else:
994
+ kind_set: set[str] | None = None
995
+ if kinds.strip():
996
+ kind_set = {k.strip().lower() for k in kinds.split(",") if k.strip()}
997
+
998
+ filtered = [r for r in all_records if kind_set is None or r.kind in kind_set][:top]
999
+ label = kinds if kind_set else "all kinds"
1000
+ out.append(f"**Group by:** node | **Top:** {top} | **Filter:** {label}\n")
1001
+ out.append("| Rank | Score | Kind | Name | Module | Inbound | XMod |")
1002
+ out.append("|-----:|------:|------|------|--------|--------:|-----:|")
1003
+ for r in filtered:
1004
+ module = f"`{r.module_path}`" if r.module_path else "—"
1005
+ out.append(
1006
+ f"| {r.rank} | {r.score:.6f} | {r.kind} | `{r.name}`"
1007
+ f" | {module} | {r.inbound_count} | {r.cross_module_inbound} |"
1008
+ )
1009
+
1010
+ out.append("")
1011
+ out.append(
1012
+ "> SIR scores are normalized to sum 1.0 across all nodes. "
1013
+ "Higher score = more structurally central. "
1014
+ "XMod = cross-module inbound edges."
1015
+ )
1016
+ return "\n".join(out)
1017
+
1018
+
1019
+ @mcp.tool()
1020
+ def bridge_centrality(
1021
+ top: int = 20,
1022
+ include_imports: bool = True,
1023
+ ) -> str:
1024
+ """
1025
+ Compute module connectivity: how many unique modules each module interacts with.
1026
+
1027
+ For well-modularized codebases, identifies orchestrator and hub modules that
1028
+ touch many other modules. Replaces betweenness centrality (which is meaningless
1029
+ when inter-module edges are zero).
1030
+
1031
+ **Connectivity score** = (unique modules called + unique modules calling this) / 30 + frequency / 50
1032
+ Higher score = more complex coupling with other modules.
1033
+
1034
+ Scores are persisted to the ``centrality_scores`` table under the
1035
+ ``module_connectivity`` metric for use by ``framework_nodes()``.
1036
+
1037
+ :param top: Number of top connectivity modules to return (default 20).
1038
+ :param include_imports: Whether to include IMPORTS in connectivity (default True).
1039
+ :return: Markdown-formatted ranking table of modules by connectivity.
1040
+ """
1041
+ try:
1042
+ from pycode_kg.analysis.bridge import ( # pylint: disable=import-outside-toplevel
1043
+ compute_bridge_centrality,
1044
+ )
1045
+
1046
+ db_path = str(_get_kg().db_path)
1047
+ modules = compute_bridge_centrality(
1048
+ kind="module",
1049
+ include_imports=include_imports,
1050
+ top=top,
1051
+ db_path=db_path,
1052
+ )
1053
+ except Exception as e: # pylint: disable=broad-except
1054
+ return f"## Module Connectivity Error\n\nFailed to compute connectivity: `{e}`"
1055
+
1056
+ out: list[str] = ["## Module Connectivity (Interaction Complexity)\n"]
1057
+ out.append(f"**Top:** {top} | **Include imports:** {include_imports}\n")
1058
+ out.append("| Rank | Connectivity | Module |")
1059
+ out.append("|-----:|-------------:|--------|")
1060
+ for rank_idx, (mod, score) in enumerate(modules, start=1):
1061
+ out.append(f"| {rank_idx} | {score:.6f} | `{mod}` |")
1062
+ out.append("")
1063
+ out.append(
1064
+ "> Connectivity = unique modules called + unique modules calling this module. "
1065
+ "Higher score = orchestrator/hub module with complex interactions. "
1066
+ "Scores are persisted as `module_connectivity` metric for use by `framework_nodes()`."
1067
+ )
1068
+ return "\n".join(out)
1069
+
1070
+
1071
+ @mcp.tool()
1072
+ def framework_nodes(top: int = 20) -> str:
1073
+ """
1074
+ Identify framework-like (hub) modules using SIR + module connectivity.
1075
+
1076
+ A "framework node" is a module that is both:
1077
+ - Structurally important (high SIR/PageRank — central to the graph)
1078
+ - Highly connected (calls/imports many modules — orchestrator/hub role)
1079
+
1080
+ Framework score = 0.6 × normalized SIR + 0.4 × normalized connectivity,
1081
+ both auto-computed on first call. High-scoring modules are critical hubs:
1082
+ architecturally central AND complex in their interactions.
1083
+
1084
+ :param top: Number of top framework-like modules to return (default 20).
1085
+ :return: Markdown-formatted ranking table of framework nodes.
1086
+ """
1087
+ try:
1088
+ from pycode_kg.analysis.bridge import ( # pylint: disable=import-outside-toplevel
1089
+ compute_bridge_centrality,
1090
+ )
1091
+ from pycode_kg.analysis.centrality import ( # pylint: disable=import-outside-toplevel
1092
+ StructuralImportanceRanker,
1093
+ )
1094
+ from pycode_kg.analysis.framework_detector import ( # pylint: disable=import-outside-toplevel
1095
+ detect_framework_nodes,
1096
+ )
1097
+
1098
+ kg = _get_kg()
1099
+ db_path = str(kg.db_path)
1100
+
1101
+ # Compute and persist SIR scores (structural importance)
1102
+ try:
1103
+ ranker = StructuralImportanceRanker(db_path)
1104
+ records = ranker.compute()
1105
+ ranker.write_scores(records, metric="sir_pagerank")
1106
+ except Exception as e: # pylint: disable=broad-except
1107
+ return f"## Framework Nodes Error\n\nFailed to compute SIR scores: `{e}`"
1108
+
1109
+ # Compute and persist module connectivity scores (interaction complexity)
1110
+ try:
1111
+ compute_bridge_centrality(kind="module", include_imports=True, top=25, db_path=db_path)
1112
+ except Exception as e: # pylint: disable=broad-except
1113
+ return f"## Framework Nodes Error\n\nFailed to compute module connectivity: `{e}`"
1114
+
1115
+ # Detect framework nodes by combining both metrics
1116
+ nodes = detect_framework_nodes(limit=top, db_path=db_path)
1117
+ except Exception as e: # pylint: disable=broad-except
1118
+ return f"## Framework Nodes Error\n\nFailed to detect framework nodes: `{e}`"
1119
+
1120
+ out: list[str] = ["## Framework-like Modules (Critical Hubs)\n"]
1121
+ out.append(f"**Top:** {top} | **Score:** 0.6 × SIR + 0.4 × connectivity (both normalized)\n")
1122
+ out.append("| Rank | Score | Module |")
1123
+ out.append("|-----:|------:|--------|")
1124
+ for rank_idx, (_, score, label) in enumerate(nodes, start=1):
1125
+ out.append(f"| {rank_idx} | {score:.6f} | `{label}` |")
1126
+ out.append("")
1127
+ out.append(
1128
+ "> Framework nodes: both architecturally central (SIR) AND heavily connected "
1129
+ "(calls/imports many modules). High-scoring modules are critical orchestrators/hubs."
1130
+ )
1131
+ return "\n".join(out)
1132
+
1133
+
1134
+ @mcp.tool()
1135
+ def find_definition_at(file: str, line: int) -> str:
1136
+ """
1137
+ Find the code node whose definition spans a given file location.
1138
+
1139
+ Reverse-resolves a ``(file, line)`` pair to a graph node ID and returns the
1140
+ same Markdown report as ``explain()``. Useful when reading a file in an IDE
1141
+ and wanting to understand the symbol at a specific line without constructing
1142
+ a node ID manually.
1143
+
1144
+ Matches the innermost (most-specific) function, method, or class whose
1145
+ ``lineno ≤ line ≤ end_lineno``. Falls back to the module node when no
1146
+ narrower match exists.
1147
+
1148
+ :param file: Module path as stored in the graph, e.g. ``src/pycode_kg/store.py``.
1149
+ Leading ``./`` is stripped automatically.
1150
+ :param line: Line number (1-indexed) within the file.
1151
+ :return: Markdown explanation from ``explain()``, or an informative error
1152
+ message if no node spans that location.
1153
+ """
1154
+ kg = _get_kg()
1155
+ store = getattr(kg, "_store", None) or getattr(kg, "store", None)
1156
+ if store is None:
1157
+ return "## Error\n\nNo graph store available."
1158
+
1159
+ norm_file = file.lstrip("./")
1160
+
1161
+ # Innermost span: smallest (end_lineno - lineno) that still contains `line`.
1162
+ rows = store.con.execute(
1163
+ """
1164
+ SELECT id
1165
+ FROM nodes
1166
+ WHERE (module_path = :f OR module_path LIKE :like)
1167
+ AND kind IN ('function', 'method', 'class')
1168
+ AND lineno IS NOT NULL
1169
+ AND lineno <= :ln
1170
+ AND (end_lineno IS NULL OR end_lineno >= :ln)
1171
+ ORDER BY (COALESCE(end_lineno, lineno) - lineno) ASC
1172
+ LIMIT 1
1173
+ """,
1174
+ {"f": norm_file, "like": f"%{norm_file}", "ln": line},
1175
+ ).fetchall()
1176
+
1177
+ if not rows:
1178
+ # Fall back to the module node itself
1179
+ mod_rows = store.con.execute(
1180
+ "SELECT id FROM nodes WHERE kind = 'module' AND (module_path = ? OR module_path LIKE ?)",
1181
+ (norm_file, f"%{norm_file}"),
1182
+ ).fetchall()
1183
+ if not mod_rows:
1184
+ return (
1185
+ f"## No Definition Found\n\n"
1186
+ f"No function, method, or class spans `{file}:{line}` in the graph.\n\n"
1187
+ "Check that the file path matches the module path stored in the graph "
1188
+ "(use `graph_stats()` or `list_nodes()` to browse available modules)."
1189
+ )
1190
+ node_id = mod_rows[0][0]
1191
+ else:
1192
+ node_id = rows[0][0]
1193
+
1194
+ return explain(node_id)
1195
+
1196
+
1197
+ @mcp.tool()
1198
+ def analyze_repo() -> str:
1199
+ """
1200
+ Run a full architectural analysis of the indexed repository.
1201
+
1202
+ Executes the nine-phase PyCodeKG analysis pipeline — complexity hotspots,
1203
+ call chains, dependency coupling, orphaned code, public API surface,
1204
+ circular dependencies, module cohesion, integration points, and docstring
1205
+ coverage — and returns the results as a structured Markdown document.
1206
+
1207
+ This is the same analysis produced by ``pycodekg analyze``; calling it from
1208
+ MCP gives agents on-demand access to structural health metrics without
1209
+ leaving the conversation. The analysis is read-only and does not modify
1210
+ the knowledge graph.
1211
+
1212
+ The output is optimized for LLM ingestion, with clear sections for metrics,
1213
+ hotspots, issues, strengths, and recommendations — similar to the
1214
+ ``pack_snippets()`` tool's Markdown output format.
1215
+
1216
+ :return: Markdown-formatted string containing timestamp, baseline metrics,
1217
+ complexity hotspots, high fan-out functions, module architecture,
1218
+ key call chains, public APIs, docstring coverage, code quality
1219
+ issues, architectural strengths, and orphaned code.
1220
+ """
1221
+ from io import StringIO # pylint: disable=import-outside-toplevel
1222
+
1223
+ from rich.console import Console # pylint: disable=import-outside-toplevel
1224
+
1225
+ silent = Console(file=StringIO(), highlight=False)
1226
+ analyzer = PyCodeKGAnalyzer(_get_kg(), console=silent, snapshot_mgr=_snapshot_mgr)
1227
+ analyzer.run_analysis()
1228
+ return analyzer.to_markdown()
1229
+
1230
+
1231
+ @mcp.tool()
1232
+ def explain(node_id: str, limit: int = 10) -> str:
1233
+ """
1234
+ Return a natural-language explanation of a code node.
1235
+
1236
+ Given a node ID (e.g., ``fn:src/pycode_kg/store.py:GraphStore.expand``),
1237
+ returns a markdown-formatted explanation that includes:
1238
+
1239
+ - **What it is**: The node's kind, short description from its docstring
1240
+ - **Where it lives**: Module path and source location
1241
+ - **What calls it**: The callers (reverse call graph)
1242
+ - **What it calls**: The callees (functions/methods this node invokes)
1243
+ - **Documentation**: Full docstring if available
1244
+
1245
+ This is ideal for understanding the role and context of a specific node
1246
+ without needing to read the full source code. Use ``pack_snippets()``
1247
+ to then retrieve the actual implementation.
1248
+
1249
+ :param node_id: Stable node identifier, e.g.
1250
+ ``fn:src/pycode_kg/store.py:GraphStore.expand``.
1251
+ :param limit: Maximum callers and callees to list (default 10). Pass 0
1252
+ to list all.
1253
+ :return: Markdown-formatted explanation ready for LLM consumption.
1254
+ """
1255
+ kg = _get_kg()
1256
+ node = kg.node(node_id)
1257
+
1258
+ if node is None:
1259
+ return f"# Node Not Found\n\nNode ID `{node_id}` does not exist in the knowledge graph."
1260
+
1261
+ out: list[str] = []
1262
+
1263
+ # Header with kind and name
1264
+ kind = node.get("kind", "unknown")
1265
+ name = node.get("qualname") or node.get("name", "unknown")
1266
+ out.append(f"# {kind.capitalize()}: `{name}`\n")
1267
+
1268
+ # Metadata
1269
+ out.append("## Metadata\n")
1270
+ if node.get("module_path"):
1271
+ out.append(f"- **Module**: `{node['module_path']}`")
1272
+ if node.get("lineno") is not None:
1273
+ out.append(
1274
+ f"- **Location**: line {node['lineno']}"
1275
+ + (f"–{node['end_lineno']}" if node.get("end_lineno") else "")
1276
+ )
1277
+ out.append(f"- **ID**: `{node_id}`")
1278
+ out.append("")
1279
+
1280
+ # Docstring
1281
+ docstring = node.get("docstring", "").strip()
1282
+ if docstring:
1283
+ out.append("## Documentation\n")
1284
+ out.append(docstring)
1285
+ out.append("")
1286
+
1287
+ # Get callers
1288
+ try:
1289
+ caller_list = kg.callers(node_id, rel="CALLS")
1290
+ if caller_list:
1291
+ out.append("## Called By (Callers)\n")
1292
+ out.append(f"This {kind} is called by **{len(caller_list)}** other function(s):\n")
1293
+ shown_callers = caller_list[:limit] if limit > 0 else caller_list
1294
+ for caller in shown_callers:
1295
+ caller_name = caller.get("qualname") or caller.get("name", "unknown")
1296
+ caller_module = caller.get("module_path", "")
1297
+ out.append(f"- `{caller_name}` ({caller_module})")
1298
+ if limit > 0 and len(caller_list) > limit:
1299
+ out.append(f"- ... and {len(caller_list) - limit} more")
1300
+ out.append("")
1301
+ except (AttributeError, ValueError, RuntimeError):
1302
+ pass
1303
+
1304
+ # Get callees (what this function calls)
1305
+ try:
1306
+ if hasattr(kg, "_store") and kg._store is not None:
1307
+ store = kg._store
1308
+ edges = store.edges_from(node_id, rel="CALLS", limit=50)
1309
+ if edges:
1310
+ callees = set()
1311
+ for edge in edges:
1312
+ dst = edge.get("dst")
1313
+ if dst is not None:
1314
+ dst_node = kg.node(dst)
1315
+ if (
1316
+ dst_node
1317
+ and dst_node.get("kind") != "symbol"
1318
+ and dst_node.get("module_path") # exclude builtins/stdlib
1319
+ ):
1320
+ dst_name = dst_node.get("qualname") or dst_node.get("name", "unknown")
1321
+ callees.add(f"- `{dst_name}`")
1322
+ if callees:
1323
+ out.append("## Calls (Callees)\n")
1324
+ out.append(f"This {kind} calls **{len(callees)}** other function(s):\n")
1325
+ sorted_callees = sorted(callees)
1326
+ shown_callees = sorted_callees[:limit] if limit > 0 else sorted_callees
1327
+ for callee in shown_callees:
1328
+ out.append(callee)
1329
+ if limit > 0 and len(callees) > limit:
1330
+ out.append(f"- ... and {len(callees) - limit} more")
1331
+ out.append("")
1332
+ except (AttributeError, ValueError, RuntimeError):
1333
+ pass
1334
+
1335
+ # Role assessment — use relative thresholds so that a node called by >5% of
1336
+ # meaningful nodes is always flagged high-value, regardless of codebase size.
1337
+ out.append("## Role in Codebase\n")
1338
+ try:
1339
+ caller_count = len(kg.callers(node_id, rel="CALLS"))
1340
+ # Compute callee count for orchestrator detection — a node that calls many
1341
+ # things is a coordination hub even if it has few callers.
1342
+ callee_count = 0
1343
+ try:
1344
+ if hasattr(kg, "_store") and kg._store is not None:
1345
+ _callee_edges = kg._store.edges_from(node_id, rel="CALLS", limit=100)
1346
+ callee_count = sum(
1347
+ 1
1348
+ for _e in (_callee_edges or [])
1349
+ if not (_e.get("dst") or "").startswith("sym:")
1350
+ and kg.node(_e.get("dst") or "")
1351
+ and (kg.node(_e.get("dst") or "") or {}).get("module_path")
1352
+ )
1353
+ except (AttributeError, ValueError, RuntimeError):
1354
+ pass
1355
+ try:
1356
+ meaningful_nodes = kg.stats().get("meaningful_nodes", 100)
1357
+ except (AttributeError, ValueError, RuntimeError):
1358
+ meaningful_nodes = 100
1359
+ _thresh_high_value = max(15, int(meaningful_nodes * 0.05)) # top 5%
1360
+ _thresh_important = max(5, int(meaningful_nodes * 0.02)) # top 2%
1361
+ _thresh_orchestrator = 8 # calling 8+ distinct functions signals a coordination hub
1362
+ if caller_count > _thresh_high_value:
1363
+ out.append(
1364
+ f"**High-value function**: Called {caller_count} times "
1365
+ f"(>{_thresh_high_value} = top 5% of this codebase). "
1366
+ "This is likely a core API or bottleneck. Changes here may have wide impact."
1367
+ )
1368
+ elif caller_count > _thresh_important:
1369
+ out.append(
1370
+ f"**Important function**: Called {caller_count} times "
1371
+ f"(>{_thresh_important} = top 2% of this codebase). "
1372
+ "Part of the essential infrastructure."
1373
+ )
1374
+ elif callee_count >= _thresh_orchestrator and caller_count > 0:
1375
+ out.append(
1376
+ f"**Core orchestrator**: Called {caller_count} time(s), calls {callee_count} others. "
1377
+ "Low caller count likely reflects a top-level entry point — "
1378
+ "the high fan-out indicates a coordination hub, not a utility."
1379
+ )
1380
+ elif caller_count > 0:
1381
+ try:
1382
+ _callers_for_role = kg.callers(node_id, rel="CALLS")
1383
+ _caller_mods = sorted(
1384
+ {
1385
+ c.get("module_path", "").split("/")[-1].replace(".py", "")
1386
+ for c in _callers_for_role
1387
+ if c.get("module_path")
1388
+ }
1389
+ )
1390
+ _mod_summary = (
1391
+ ", ".join(f"`{m}`" for m in _caller_mods[:4])
1392
+ + (" and more" if len(_caller_mods) > 4 else "")
1393
+ if _caller_mods
1394
+ else "various callers"
1395
+ )
1396
+ except (AttributeError, ValueError, RuntimeError):
1397
+ _mod_summary = "various callers"
1398
+ out.append(f"**Utility function**: Called {caller_count} time(s) from {_mod_summary}.")
1399
+ else:
1400
+ module = node.get("module_path", "")
1401
+ name = node.get("name", "")
1402
+ if name.startswith("__") and name.endswith("__"):
1403
+ out.append(
1404
+ "**Protocol method**: Zero internal callers by design. "
1405
+ "Invoked by Python's runtime machinery (e.g., `__init__`, `__str__`, `__exit__`)."
1406
+ )
1407
+ elif "mcp_server" in module or name.startswith("_get_"):
1408
+ out.append(
1409
+ "**MCP Tool / Framework entry point**: Zero internal callers by design. "
1410
+ "Invoked by the MCP protocol dispatcher, not by code."
1411
+ )
1412
+ elif "/cli/" in module or module.endswith("cli.py"):
1413
+ out.append(
1414
+ "**CLI entry point**: Zero internal callers by design. "
1415
+ "Invoked by Click's CLI router when the user runs the command."
1416
+ )
1417
+ else:
1418
+ out.append(
1419
+ "**Orphaned**: Never called internally. "
1420
+ "May be dead code, a public API, or a framework entry point."
1421
+ )
1422
+ except (AttributeError, ValueError, RuntimeError):
1423
+ out.append("Unable to determine call graph role.")
1424
+
1425
+ out.append("")
1426
+ out.append("---\n")
1427
+ out.append("*Use `pack_snippets()` to retrieve the full source code.*")
1428
+
1429
+ return "\n".join(out)
1430
+
1431
+
1432
+ # ---------------------------------------------------------------------------
1433
+ # CodeRank MCP tools
1434
+ # ---------------------------------------------------------------------------
1435
+
1436
+
1437
+ @mcp.tool()
1438
+ def rank_nodes(
1439
+ top: int = 25,
1440
+ rels: str = "CALLS,IMPORTS,INHERITS",
1441
+ persist_metric: str = "",
1442
+ exclude_tests: bool = True,
1443
+ ) -> str:
1444
+ """
1445
+ Compute global weighted CodeRank (PageRank) over the repository graph.
1446
+
1447
+ Builds a directed weighted graph from the SQLite store and runs weighted
1448
+ PageRank to identify the most structurally important nodes. Relation
1449
+ weights follow the CodeRank defaults: CALLS=1.0, IMPORTS=0.9,
1450
+ INHERITS=0.75. Test paths are excluded by default.
1451
+
1452
+ Optionally persists the scores into the ``node_metrics`` table under the
1453
+ given metric name so they can be loaded at query time without recomputing.
1454
+
1455
+ :param top: Number of top-ranked nodes to return (default 25).
1456
+ :param rels: Comma-separated relations to include in the graph
1457
+ (default ``"CALLS,IMPORTS,INHERITS"``).
1458
+ :param persist_metric: If non-empty, persist scores to ``node_metrics``
1459
+ under this metric name (e.g. ``"coderank_global"``).
1460
+ :param exclude_tests: Exclude test-path nodes from the graph (default True).
1461
+ :return: JSON array of ranked node dicts with ``node_id``, ``score``,
1462
+ ``top_pct`` (e.g. ``"top 0.5%"``), ``kind``, ``qualname``,
1463
+ ``module_path``, and ``rank`` fields.
1464
+ """
1465
+ from pycode_kg.ranking.coderank import ( # pylint: disable=import-outside-toplevel
1466
+ build_code_graph,
1467
+ compute_coderank,
1468
+ persist_metric_scores,
1469
+ )
1470
+
1471
+ db_path = str(_get_kg().db_path)
1472
+ rel_list = [r.strip() for r in rels.split(",") if r.strip()]
1473
+
1474
+ try:
1475
+ graph = build_code_graph(
1476
+ db_path,
1477
+ include_relations=rel_list,
1478
+ exclude_test_paths=exclude_tests,
1479
+ )
1480
+ scores = compute_coderank(graph)
1481
+ except Exception as exc: # pylint: disable=broad-except
1482
+ return json.dumps({"error": str(exc)}, indent=2)
1483
+
1484
+ if persist_metric:
1485
+ try:
1486
+ persist_metric_scores(db_path, persist_metric, scores)
1487
+ except Exception: # pylint: disable=broad-except
1488
+ pass # non-fatal — still return results
1489
+
1490
+ # Filter out sym: stubs (import placeholders) — only return real code entities
1491
+ all_real_nodes = [
1492
+ (nid, s)
1493
+ for nid, s in sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
1494
+ if not nid.startswith("sym:")
1495
+ ]
1496
+ total_real = len(all_real_nodes)
1497
+ results = []
1498
+ for rank_idx, (node_id, score) in enumerate(all_real_nodes[:top], start=1):
1499
+ attrs = graph.nodes.get(node_id, {})
1500
+ top_pct = round(rank_idx / total_real * 100, 1) if total_real > 0 else 0.0
1501
+ results.append(
1502
+ {
1503
+ "rank": rank_idx,
1504
+ "node_id": node_id,
1505
+ "score": round(score, 8),
1506
+ "top_pct": f"top {top_pct:.1f}%",
1507
+ "kind": attrs.get("kind"),
1508
+ "qualname": attrs.get("qualname"),
1509
+ "module_path": attrs.get("module_path"),
1510
+ }
1511
+ )
1512
+
1513
+ return json.dumps(results, indent=2, ensure_ascii=False)
1514
+
1515
+
1516
+ @mcp.tool()
1517
+ def query_ranked(
1518
+ q: str,
1519
+ k: int = 8,
1520
+ mode: str = "hybrid",
1521
+ top: int = 25,
1522
+ rels: str = "CALLS,IMPORTS,INHERITS",
1523
+ radius: int = 2,
1524
+ exclude_tests: bool = True,
1525
+ ) -> str:
1526
+ """
1527
+ Rank query results using CodeRank-enhanced hybrid or personalized PageRank.
1528
+
1529
+ Combines semantic seed scores from the vector index with structural
1530
+ centrality and graph proximity to produce a final ranked list with
1531
+ explainability components.
1532
+
1533
+ Two modes are available:
1534
+
1535
+ - ``hybrid`` (default): 0.60 × semantic + 0.25 × centrality + 0.15 × proximity
1536
+ - ``ppr``: 0.70 × personalized PageRank + 0.30 × semantic
1537
+
1538
+ :param q: Natural-language query string.
1539
+ :param k: Number of semantic seed nodes to retrieve (default 8).
1540
+ :param mode: Ranking mode — ``"hybrid"`` (default) or ``"ppr"``.
1541
+ :param top: Maximum ranked results to return (default 25).
1542
+ :param rels: Comma-separated relations to include in the local graph.
1543
+ :param radius: Graph expansion radius around seeds (default 2).
1544
+ :param exclude_tests: Exclude test-path nodes (default True).
1545
+ :return: JSON array of ranked result dicts with score components and
1546
+ ``why`` explanation strings. ``sym:`` import stub nodes are
1547
+ always excluded from the output.
1548
+ """
1549
+ from pycode_kg.ranking.coderank import ( # pylint: disable=import-outside-toplevel
1550
+ build_code_graph,
1551
+ compute_coderank,
1552
+ rank_query_hybrid,
1553
+ rank_query_ppr,
1554
+ )
1555
+
1556
+ kg = _get_kg()
1557
+ db_path = str(kg.db_path)
1558
+ rel_list = [r.strip() for r in rels.split(",") if r.strip()]
1559
+
1560
+ # Get semantic seeds from the vector index
1561
+ try:
1562
+ raw = kg.query(q, k=k, hop=0, rels=tuple(rel_list))
1563
+ seed_data = json.loads(raw.to_json())
1564
+ seed_nodes = seed_data.get("nodes", [])
1565
+ semantic_scores: dict[str, float] = {
1566
+ n["id"]: float((n.get("relevance") or {}).get("score", 0.0))
1567
+ for n in seed_nodes
1568
+ if (n.get("relevance") or {}).get("score", 0.0) > 0
1569
+ }
1570
+ except Exception as exc: # pylint: disable=broad-except
1571
+ return json.dumps({"error": f"Seed retrieval failed: {exc}"}, indent=2)
1572
+
1573
+ if not semantic_scores:
1574
+ return json.dumps({"error": "No semantic seeds found for query."}, indent=2)
1575
+
1576
+ try:
1577
+ graph = build_code_graph(
1578
+ db_path,
1579
+ include_relations=rel_list,
1580
+ exclude_test_paths=exclude_tests,
1581
+ )
1582
+ except Exception as exc: # pylint: disable=broad-except
1583
+ return json.dumps({"error": f"Graph build failed: {exc}"}, indent=2)
1584
+
1585
+ global_cr = compute_coderank(graph)
1586
+ try:
1587
+ if mode == "ppr":
1588
+ results = rank_query_ppr(graph, semantic_scores, radius=radius, top_k=top)
1589
+ else:
1590
+ results = rank_query_hybrid(
1591
+ graph, semantic_scores, global_coderank=global_cr, radius=radius, top_k=top
1592
+ )
1593
+ except Exception as exc: # pylint: disable=broad-except
1594
+ return json.dumps({"error": f"Ranking failed: {exc}"}, indent=2)
1595
+
1596
+ output = []
1597
+ for rank_idx, r in enumerate(results, start=1):
1598
+ if r.node_id.startswith("sym:"):
1599
+ continue
1600
+ output.append(
1601
+ {
1602
+ "rank": rank_idx,
1603
+ "node_id": r.node_id,
1604
+ "adjusted_score": round(r.adjusted_score, 6),
1605
+ "final_score": round(r.final_score, 6),
1606
+ "semantic_score": round(r.semantic_score, 6),
1607
+ "centrality_score": round(r.centrality_score, 6),
1608
+ "proximity_score": round(r.proximity_score, 6),
1609
+ "kind": r.kind,
1610
+ "qualname": r.qualname,
1611
+ "module_path": r.module_path,
1612
+ "why": list(r.why),
1613
+ }
1614
+ )
1615
+
1616
+ return json.dumps(
1617
+ {"query": q, "mode": mode, "returned": len(output), "results": output},
1618
+ indent=2,
1619
+ ensure_ascii=False,
1620
+ )
1621
+
1622
+
1623
+ @mcp.tool()
1624
+ def explain_rank(node_id: str, q: str = "") -> str:
1625
+ """
1626
+ Explain the CodeRank score components for a specific node.
1627
+
1628
+ Returns a Markdown report showing the node's structural position in the
1629
+ graph: how many nodes call it, import it, or inherit from it; its global
1630
+ CodeRank score; and, when a query is provided, its semantic relevance and
1631
+ proximity to the query seed set.
1632
+
1633
+ :param node_id: Stable node identifier, e.g.
1634
+ ``fn:src/pycode_kg/store.py:GraphStore.expand``.
1635
+ :param q: Optional query string. When provided, semantic score and
1636
+ proximity to the query seed set are included in the report.
1637
+ :return: Markdown-formatted explanation of the node's rank components.
1638
+ """
1639
+ from pycode_kg.ranking.coderank import ( # pylint: disable=import-outside-toplevel
1640
+ DEFAULT_GLOBAL_RELS,
1641
+ build_code_graph,
1642
+ compute_coderank,
1643
+ compute_seed_proximity,
1644
+ )
1645
+
1646
+ kg = _get_kg()
1647
+ db_path = str(kg.db_path)
1648
+
1649
+ node = kg.node(node_id)
1650
+ if node is None:
1651
+ return f"## Node Not Found\n\nNode ID `{node_id}` does not exist."
1652
+
1653
+ kind = node.get("kind", "unknown")
1654
+ name = node.get("qualname") or node.get("name", "unknown")
1655
+ out: list[str] = [f"## CodeRank Explanation: `{name}` ({kind})\n"]
1656
+ out.append(f"- **ID:** `{node_id}`")
1657
+ if node.get("module_path"):
1658
+ out.append(f"- **Module:** `{node['module_path']}`")
1659
+ out.append("")
1660
+
1661
+ # Build graph and compute global CodeRank
1662
+ try:
1663
+ graph = build_code_graph(
1664
+ db_path,
1665
+ include_relations=list(DEFAULT_GLOBAL_RELS),
1666
+ exclude_test_paths=True,
1667
+ )
1668
+ scores = compute_coderank(graph)
1669
+ except Exception as exc: # pylint: disable=broad-except
1670
+ return f"## Error\n\nFailed to build graph: `{exc}`"
1671
+
1672
+ global_score = scores.get(node_id, 0.0)
1673
+ meaningful_scores = sorted(
1674
+ (v for k, v in scores.items() if not k.startswith("sym:")), reverse=True
1675
+ )
1676
+ rank_pos = next(
1677
+ (i + 1 for i, s in enumerate(meaningful_scores) if s <= global_score),
1678
+ len(meaningful_scores),
1679
+ )
1680
+
1681
+ out.append("### Global CodeRank\n")
1682
+ out.append(f"- **Score:** `{global_score:.8f}`")
1683
+ out.append(f"- **Rank:** #{rank_pos} of {len(meaningful_scores)} meaningful nodes")
1684
+ out.append("")
1685
+
1686
+ # Structural context from graph
1687
+ if node_id in graph:
1688
+ in_edges = list(graph.in_edges(node_id, data=True))
1689
+ out.append("### Structural Inbound Edges\n")
1690
+ callers_count = sum(1 for _, _, d in in_edges if "CALLS" in d.get("relations", set()))
1691
+ importers_count = sum(1 for _, _, d in in_edges if "IMPORTS" in d.get("relations", set()))
1692
+ inheritors_count = sum(1 for _, _, d in in_edges if "INHERITS" in d.get("relations", set()))
1693
+ if callers_count:
1694
+ out.append(f"- Called by **{callers_count}** upstream node(s)")
1695
+ if importers_count:
1696
+ out.append(f"- Imported by **{importers_count}** upstream node(s)")
1697
+ if inheritors_count:
1698
+ out.append(f"- Inherited by **{inheritors_count}** subclass node(s)")
1699
+ if not (callers_count or importers_count or inheritors_count):
1700
+ out.append("- No inbound structural edges found in the ranked graph")
1701
+ out.append("")
1702
+
1703
+ out_edges = list(graph.out_edges(node_id, data=True))
1704
+ if out_edges:
1705
+ out.append("### Structural Outbound Edges\n")
1706
+ out.append(f"- Calls/imports/inherits **{len(out_edges)}** downstream node(s)")
1707
+ out.append("")
1708
+
1709
+ # Optional query-conditioned scores
1710
+ if q:
1711
+ out.append("### Query-Conditioned Scores\n")
1712
+ try:
1713
+ raw = kg.query(q, k=8, hop=0)
1714
+ seed_data = json.loads(raw.to_json())
1715
+ seed_nodes = seed_data.get("nodes", [])
1716
+ semantic_scores: dict[str, float] = {
1717
+ n["id"]: float((n.get("relevance") or {}).get("score", 0.0)) for n in seed_nodes
1718
+ }
1719
+ this_semantic = semantic_scores.get(node_id, 0.0)
1720
+ out.append(f"- **Query:** `{q}`")
1721
+ out.append(f"- **Semantic score:** `{this_semantic:.4f}`")
1722
+
1723
+ if node_id in graph:
1724
+ seeds = list(semantic_scores.keys())
1725
+ proximity = compute_seed_proximity(graph, seeds)
1726
+ prox = proximity.get(node_id, 0.0)
1727
+ out.append(f"- **Proximity to seeds:** `{prox:.4f}`")
1728
+ if prox >= 1.0:
1729
+ out.append(" → Direct semantic seed")
1730
+ elif prox >= 0.5:
1731
+ out.append(" → One hop from a semantic seed")
1732
+ elif prox > 0:
1733
+ out.append(" → Within local query neighborhood")
1734
+ else:
1735
+ out.append(" → Outside query neighborhood")
1736
+ except Exception as exc: # pylint: disable=broad-except
1737
+ out.append(f"- Query scoring failed: `{exc}`")
1738
+ out.append("")
1739
+
1740
+ out.append("---\n")
1741
+ out.append(
1742
+ "*Use `rank_nodes()` for global top-N ranking, or `query_ranked()` for query-conditioned ranking.*"
1743
+ )
1744
+ return "\n".join(out)
1745
+
1746
+
1747
+ # ---------------------------------------------------------------------------
1748
+ # Temporal snapshot tools
1749
+ # ---------------------------------------------------------------------------
1750
+
1751
+
1752
+ @mcp.tool()
1753
+ def snapshot_list(limit: int = 10, branch: str = "") -> str:
1754
+ """
1755
+ List saved temporal snapshots of codebase metrics in reverse chronological order.
1756
+
1757
+ Each entry in the returned list contains a ``key`` (tree hash snapshot
1758
+ identifier), ``branch``, ``timestamp``, ``version``, and a summary of
1759
+ key metrics (node count, edge count, docstring coverage, critical issues)
1760
+ plus deltas vs. the previous snapshot. Use the ``key`` field when calling
1761
+ ``snapshot_show()`` or ``snapshot_diff(key_a=..., key_b=...)``.
1762
+
1763
+ Use this tool to answer questions like "how has the codebase grown?" or
1764
+ "when did docstring coverage improve?" or "show me only main-branch snapshots".
1765
+
1766
+ :param limit: Maximum number of snapshots to return (default 10; pass 0 for all).
1767
+ :param branch: If provided, filter to snapshots from this branch only
1768
+ (e.g. ``"main"`` or ``"develop"``).
1769
+ :return: JSON array of snapshot metadata dicts, most recent first.
1770
+ """
1771
+ mgr = _get_snapshot_mgr()
1772
+ snapshots = mgr.list_snapshots(
1773
+ limit=limit if limit > 0 else None,
1774
+ branch=branch if branch else None,
1775
+ )
1776
+ for snap in snapshots:
1777
+ snap_metrics = snap.get("metrics", {})
1778
+ snap["freshness"] = _snapshot_freshness(snap_metrics.get("total_nodes", 0))
1779
+ return json.dumps(snapshots, indent=2, ensure_ascii=False)
1780
+
1781
+
1782
+ @mcp.tool()
1783
+ def snapshot_show(key: str = "latest") -> str:
1784
+ """
1785
+ Show full details of a specific codebase metrics snapshot.
1786
+
1787
+ Pass a snapshot key (tree hash) to retrieve that exact snapshot, or use
1788
+ the special value ``"latest"`` (default) to retrieve the most recent one.
1789
+
1790
+ Snapshot keys are the ``key`` field returned by ``snapshot_list()``.
1791
+
1792
+ The returned object contains the full ``SnapshotMetrics`` (total_nodes,
1793
+ total_edges, meaningful_nodes, docstring_coverage, node_counts,
1794
+ edge_counts, critical_issues, complexity_median), the top complexity
1795
+ hotspots, and deltas computed vs. both the previous and the baseline
1796
+ (oldest) snapshots.
1797
+
1798
+ :param key: Snapshot key to load, or ``"latest"`` for the most
1799
+ recent snapshot (default ``"latest"``). Keys are tree
1800
+ hashes returned by ``snapshot_list()``.
1801
+ :return: JSON object with full snapshot details, or an error dict if
1802
+ the requested snapshot does not exist.
1803
+ """
1804
+ mgr = _get_snapshot_mgr()
1805
+
1806
+ if key == "latest":
1807
+ entries = mgr.list_snapshots(limit=1)
1808
+ if not entries:
1809
+ return json.dumps({"error": "No snapshots found."})
1810
+ key = entries[0]["key"]
1811
+
1812
+ snapshot = mgr.load_snapshot(key)
1813
+ if snapshot is None:
1814
+ return json.dumps({"error": f"Snapshot not found for key: {key!r}"})
1815
+ out = snapshot.to_dict()
1816
+ out["freshness"] = _snapshot_freshness(snapshot.metrics.total_nodes)
1817
+ return json.dumps(out, indent=2, ensure_ascii=False)
1818
+
1819
+
1820
+ @mcp.tool()
1821
+ def snapshot_diff(key_a: str, key_b: str) -> str:
1822
+ """
1823
+ Compare two codebase metric snapshots side-by-side.
1824
+
1825
+ Returns the full ``SnapshotMetrics`` for both snapshots and a computed
1826
+ delta (b − a) covering: node count, edge count, docstring coverage
1827
+ change, and critical-issues change. It also includes an ``issues_delta``
1828
+ listing newly introduced and resolved issues.
1829
+
1830
+ Typical workflow::
1831
+
1832
+ # 1. List available snapshots — note the 'key' field in each entry
1833
+ snapshot_list()
1834
+
1835
+ # 2. Diff any two using the key= field values
1836
+ snapshot_diff(key_a="abc1234ef...", key_b="def5678ab...")
1837
+
1838
+ :param key_a: First (older) snapshot key — the ``key`` field from
1839
+ ``snapshot_list()`` output (a tree-hash string).
1840
+ :param key_b: Second (newer) snapshot key — the ``key`` field from
1841
+ ``snapshot_list()`` output (a tree-hash string).
1842
+ :return: JSON object with keys ``a`` (metrics + issues list for key_a),
1843
+ ``b`` (metrics + issues list for key_b), ``delta`` (b − a),
1844
+ and ``module_node_counts_delta`` (per-module node count changes,
1845
+ only modules that differ). Returns an error dict if either snapshot
1846
+ is missing.
1847
+ """
1848
+ mgr = _get_snapshot_mgr()
1849
+ result = mgr.diff_snapshots(key_a, key_b)
1850
+ if "error" not in result:
1851
+ result["freshness"] = {
1852
+ "a": _snapshot_freshness(result.get("a", {}).get("metrics", {}).get("total_nodes", 0)),
1853
+ "b": _snapshot_freshness(result.get("b", {}).get("metrics", {}).get("total_nodes", 0)),
1854
+ }
1855
+ return json.dumps(result, indent=2, ensure_ascii=False)
1856
+
1857
+
1858
+ # ---------------------------------------------------------------------------
1859
+ # CLI entry point
1860
+ # ---------------------------------------------------------------------------
1861
+
1862
+
1863
+ def _parse_args(argv: list | None = None) -> argparse.Namespace:
1864
+ """
1865
+ Parse command-line arguments for the PyCodeKG MCP server entry point.
1866
+
1867
+ Centralizes runtime configuration for repository path, graph/index paths,
1868
+ embedder model selection, and transport wiring (stdio vs sse).
1869
+
1870
+ :param argv: Argument list to parse; defaults to ``sys.argv[1:]`` when ``None``.
1871
+ :return: Parsed argument namespace.
1872
+ """
1873
+ p = argparse.ArgumentParser(
1874
+ prog="pycodekg-mcp",
1875
+ description="PyCodeKG MCP server — exposes codebase query tools to AI agents.",
1876
+ )
1877
+ p.add_argument(
1878
+ "--repo",
1879
+ default=".",
1880
+ help="Repository root directory (default: current directory)",
1881
+ )
1882
+ p.add_argument(
1883
+ "--db",
1884
+ default=".pycodekg/graph.sqlite",
1885
+ help="Path to the SQLite knowledge graph (default: .pycodekg/graph.sqlite)",
1886
+ )
1887
+ p.add_argument(
1888
+ "--lancedb",
1889
+ default=".pycodekg/lancedb",
1890
+ help="Path to the LanceDB vector index directory (default: .pycodekg/lancedb)",
1891
+ )
1892
+ p.add_argument(
1893
+ "--model",
1894
+ default=DEFAULT_MODEL,
1895
+ help=f"Sentence-transformer model name (default: {DEFAULT_MODEL})",
1896
+ )
1897
+ p.add_argument(
1898
+ "--transport",
1899
+ choices=["stdio", "sse"],
1900
+ default="stdio",
1901
+ help="MCP transport: stdio (default, for Claude Desktop) or sse (HTTP)",
1902
+ )
1903
+ return p.parse_args(argv)
1904
+
1905
+
1906
+ def main(argv: list | None = None) -> None:
1907
+ """
1908
+ CLI entry point for the PyCodeKG MCP server.
1909
+
1910
+ Initialises the PyCodeKG instance, the SnapshotManager, and starts the
1911
+ MCP server using the requested transport (stdio for Claude Desktop,
1912
+ sse for HTTP clients).
1913
+
1914
+ This startup path is the operational "entry point and configuration"
1915
+ surface for MCP usage, including model selection and database/index
1916
+ location handling. It also provides warning output for common
1917
+ misconfiguration states (missing SQLite graph before startup).
1918
+
1919
+ Logging approach: writes startup diagnostics and warnings to stderr so
1920
+ MCP hosts can capture them without mixing with tool payloads.
1921
+ Error handling strategy: emits actionable warnings for missing inputs
1922
+ and continues initialization when safe to do so.
1923
+
1924
+ :param argv: Argument list forwarded to ``_parse_args``; defaults to
1925
+ ``sys.argv[1:]`` when ``None``.
1926
+ """
1927
+ global _kg, _snapshot_mgr
1928
+
1929
+ args = _parse_args(argv)
1930
+
1931
+ repo = Path(args.repo).resolve()
1932
+ db = Path(args.db) if Path(args.db).is_absolute() else repo / args.db
1933
+ lancedb_dir = Path(args.lancedb) if Path(args.lancedb).is_absolute() else repo / args.lancedb
1934
+
1935
+ if not db.exists():
1936
+ print(
1937
+ f"WARNING: SQLite database not found at '{db}'.\n"
1938
+ "Run 'pycodekg-build-sqlite' and 'pycodekg-build-lancedb' first.",
1939
+ file=sys.stderr,
1940
+ )
1941
+
1942
+ print(
1943
+ f"PyCodeKG MCP server starting\n"
1944
+ f" repo : {repo}\n"
1945
+ f" db : {db}\n"
1946
+ f" lancedb : {lancedb_dir}\n"
1947
+ f" model : {args.model}\n"
1948
+ f" transport: {args.transport}",
1949
+ file=sys.stderr,
1950
+ )
1951
+
1952
+ _kg = PyCodeKG(
1953
+ repo_root=repo,
1954
+ db_path=db,
1955
+ lancedb_dir=lancedb_dir,
1956
+ model=args.model,
1957
+ )
1958
+
1959
+ _snapshot_mgr = SnapshotManager(repo / ".pycodekg" / "snapshots", db_path=db)
1960
+
1961
+ mcp.run(transport=args.transport)
1962
+
1963
+
1964
+ if __name__ == "__main__":
1965
+ main()