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.
- pycode_kg/.DS_Store +0 -0
- pycode_kg/__init__.py +91 -0
- pycode_kg/__main__.py +11 -0
- pycode_kg/analysis/__init__.py +15 -0
- pycode_kg/analysis/bridge.py +108 -0
- pycode_kg/analysis/centrality.py +412 -0
- pycode_kg/analysis/framework_detector.py +103 -0
- pycode_kg/analysis/hybrid_rank.py +53 -0
- pycode_kg/app.py +1335 -0
- pycode_kg/architecture.py +624 -0
- pycode_kg/build_pycodekg_lancedb.py +15 -0
- pycode_kg/build_pycodekg_sqlite.py +15 -0
- pycode_kg/cli/__init__.py +29 -0
- pycode_kg/cli/cmd_analyze.py +96 -0
- pycode_kg/cli/cmd_architecture.py +150 -0
- pycode_kg/cli/cmd_bridges.py +26 -0
- pycode_kg/cli/cmd_build.py +154 -0
- pycode_kg/cli/cmd_build_full.py +242 -0
- pycode_kg/cli/cmd_centrality.py +131 -0
- pycode_kg/cli/cmd_explain.py +180 -0
- pycode_kg/cli/cmd_framework_nodes.py +18 -0
- pycode_kg/cli/cmd_hooks.py +137 -0
- pycode_kg/cli/cmd_init.py +312 -0
- pycode_kg/cli/cmd_mcp.py +71 -0
- pycode_kg/cli/cmd_model.py +53 -0
- pycode_kg/cli/cmd_query.py +211 -0
- pycode_kg/cli/cmd_snapshot.py +421 -0
- pycode_kg/cli/cmd_viz.py +180 -0
- pycode_kg/cli/main.py +23 -0
- pycode_kg/cli/options.py +63 -0
- pycode_kg/config.py +78 -0
- pycode_kg/graph.py +125 -0
- pycode_kg/index.py +542 -0
- pycode_kg/kg.py +220 -0
- pycode_kg/layout3d.py +470 -0
- pycode_kg/mcp/bridge_tools.py +19 -0
- pycode_kg/mcp/framework_tools.py +18 -0
- pycode_kg/mcp_server.py +1965 -0
- pycode_kg/module/__init__.py +83 -0
- pycode_kg/module/base.py +720 -0
- pycode_kg/module/extractor.py +276 -0
- pycode_kg/module/types.py +532 -0
- pycode_kg/pycodekg.py +543 -0
- pycode_kg/pycodekg_query.py +1 -0
- pycode_kg/pycodekg_snippet_packer.py +1 -0
- pycode_kg/pycodekg_thorough_analysis.py +2751 -0
- pycode_kg/pycodekg_viz.py +1 -0
- pycode_kg/pycodekg_viz3d.py +1 -0
- pycode_kg/ranking/__init__.py +1 -0
- pycode_kg/ranking/cli_rank.py +92 -0
- pycode_kg/ranking/coderank.py +555 -0
- pycode_kg/snapshots.py +612 -0
- pycode_kg/sql/004_add_centrality_table.sql +12 -0
- pycode_kg/store.py +766 -0
- pycode_kg/utils.py +39 -0
- pycode_kg/visitor.py +413 -0
- pycode_kg/viz3d.py +1353 -0
- pycode_kg/viz3d_timeline.py +364 -0
- pycode_kg-0.16.0.dist-info/METADATA +305 -0
- pycode_kg-0.16.0.dist-info/RECORD +63 -0
- pycode_kg-0.16.0.dist-info/WHEEL +4 -0
- pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
- pycode_kg-0.16.0.dist-info/licenses/LICENSE +94 -0
pycode_kg/mcp_server.py
ADDED
|
@@ -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()
|