codecompass-mcp 5.1.0__tar.gz → 5.2.0__tar.gz
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.
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/PKG-INFO +1 -1
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/PKG-INFO +1 -1
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/SOURCES.txt +1 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/ingestion/code_parser.py +6 -1
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/ingestion/enricher.py +46 -12
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/main.py +61 -16
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/mcp_server.py +47 -10
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/pyproject.toml +1 -1
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/tests/test_block_hook.py +14 -5
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/tests/test_enricher.py +32 -0
- codecompass_mcp-5.2.0/tests/test_ingest_progress.py +43 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/LICENSE +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/README.md +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/dependency_links.txt +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/entry_points.txt +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/requires.txt +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/top_level.txt +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/config.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/graph/__init__.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/graph/code_graph_client.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/graph/code_queries.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/graph/vector_store.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/ingestion/__init__.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/ingestion/code_normalizer.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/ingestion/file_watcher.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/ingestion/hierarchy_builder.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/ingestion/source_context.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/models/__init__.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/models/code_types.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/pi_setup.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/setup.cfg +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/tests/test_accuracy_fixes.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/tests/test_code_parser.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/tests/test_enrich_preserve.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/tests/test_init.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/tests/test_lit_css_parser.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/tests/test_mcp_server.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/tests/test_php_parser.py +0 -0
- {codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/tests/test_vector_store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecompass-mcp
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.2.0
|
|
4
4
|
Summary: Structural code context for AI coding agents — a local code knowledge graph for blast radius, impact, deps, dead code, and flow tracing
|
|
5
5
|
Author: CodeCompass contributors
|
|
6
6
|
License: MIT
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecompass-mcp
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.2.0
|
|
4
4
|
Summary: Structural code context for AI coding agents — a local code knowledge graph for blast radius, impact, deps, dead code, and flow tracing
|
|
5
5
|
Author: CodeCompass contributors
|
|
6
6
|
License: MIT
|
|
@@ -1753,6 +1753,7 @@ def parse_directory(
|
|
|
1753
1753
|
project_root: str,
|
|
1754
1754
|
skip_dirs: set[str] | None = None,
|
|
1755
1755
|
progress: bool = False,
|
|
1756
|
+
on_progress=None,
|
|
1756
1757
|
) -> list[CodeTriple]:
|
|
1757
1758
|
"""Recursively parse all supported files under project_root.
|
|
1758
1759
|
|
|
@@ -1760,6 +1761,8 @@ def parse_directory(
|
|
|
1760
1761
|
project_root: Absolute path to the repo root.
|
|
1761
1762
|
skip_dirs: Directory names to skip (merged with defaults).
|
|
1762
1763
|
progress: Show a tqdm progress bar while parsing.
|
|
1764
|
+
on_progress: Called as on_progress(files_done, files_total) after each
|
|
1765
|
+
file — for callers with no console (the MCP server).
|
|
1763
1766
|
"""
|
|
1764
1767
|
default_skip = {
|
|
1765
1768
|
".git", "node_modules", "__pycache__", ".venv", "venv",
|
|
@@ -1779,8 +1782,10 @@ def parse_directory(
|
|
|
1779
1782
|
source_files_iter = track(source_files, description="Parsing files") if progress else source_files
|
|
1780
1783
|
|
|
1781
1784
|
triples: list[CodeTriple] = []
|
|
1782
|
-
for full_path in source_files_iter:
|
|
1785
|
+
for done, full_path in enumerate(source_files_iter, 1):
|
|
1783
1786
|
triples.extend(parse_file(full_path, project_root))
|
|
1787
|
+
if on_progress:
|
|
1788
|
+
on_progress(done, len(source_files))
|
|
1784
1789
|
|
|
1785
1790
|
return triples
|
|
1786
1791
|
|
|
@@ -180,19 +180,28 @@ def prepare_enrich_batches(
|
|
|
180
180
|
return result
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
def _resolve_callee(client, project: str, name: str
|
|
184
|
-
|
|
183
|
+
def _resolve_callee(client, project: str, name: str,
|
|
184
|
+
allow_external: bool = False) -> str | None:
|
|
185
|
+
"""Node id of the single entity called `name`, else None.
|
|
186
|
+
|
|
187
|
+
In-project entities (those with a file) only, unless allow_external — the
|
|
188
|
+
parser also emits file-less nodes for imported stdlib/third-party modules,
|
|
189
|
+
and an IMPORTS target may legitimately be one of those.
|
|
185
190
|
|
|
186
191
|
Ambiguous names (multiple definitions) are never guessed — the parser's
|
|
187
192
|
whole unresolved-receiver problem lives here, and a wrong edge is worse
|
|
188
193
|
than a missing one.
|
|
189
194
|
"""
|
|
190
|
-
matches =
|
|
195
|
+
matches = _match_ids(client, project, name, allow_external)
|
|
196
|
+
return matches[0] if len(matches) == 1 else None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _match_ids(client, project: str, name: str, allow_external: bool = False) -> list:
|
|
200
|
+
return [
|
|
191
201
|
nid for nid, a in client.graph.nodes(data=True)
|
|
192
202
|
if a.get("type") == "Entity" and a.get("project") == project
|
|
193
|
-
and a.get("file") and a.get("name") == name
|
|
203
|
+
and (a.get("file") or allow_external) and a.get("name") == name
|
|
194
204
|
]
|
|
195
|
-
return matches[0] if len(matches) == 1 else None
|
|
196
205
|
|
|
197
206
|
|
|
198
207
|
def add_entity(repo_path: str, name: str, kind: str = "function",
|
|
@@ -247,30 +256,55 @@ def add_entity(repo_path: str, name: str, kind: str = "function",
|
|
|
247
256
|
client.close()
|
|
248
257
|
|
|
249
258
|
|
|
259
|
+
# Edge types an agent may record. CONTAINS/DEFINED_IN are structural — the
|
|
260
|
+
# parser owns the hierarchy and a guessed one corrupts it.
|
|
261
|
+
AGENT_RELATIONS = ("CALLS", "IMPORTS", "INHERITS")
|
|
262
|
+
|
|
263
|
+
|
|
250
264
|
def add_call(repo_path: str, caller: str, callee: str,
|
|
251
|
-
line: int | None = None) -> dict[str, str]:
|
|
252
|
-
"""Record
|
|
253
|
-
|
|
254
|
-
|
|
265
|
+
line: int | None = None, relation: str = "CALLS") -> dict[str, str]:
|
|
266
|
+
"""Record an edge the agent spotted in source but the parser missed.
|
|
267
|
+
relation is one of AGENT_RELATIONS (CALLS by default). Both ends must
|
|
268
|
+
resolve unambiguously — ambiguous names are skipped, never guessed.
|
|
269
|
+
Idempotent: an existing edge of the same type is left alone."""
|
|
270
|
+
relation = relation.upper()
|
|
271
|
+
if relation not in AGENT_RELATIONS:
|
|
272
|
+
return {"status": "skipped",
|
|
273
|
+
"reason": f"relation {relation!r} not one of {', '.join(AGENT_RELATIONS)}"}
|
|
255
274
|
repo_path = os.path.abspath(repo_path)
|
|
256
275
|
project = os.path.basename(repo_path)
|
|
257
276
|
client = get_client(repo_path)
|
|
258
277
|
try:
|
|
278
|
+
imports = relation == "IMPORTS"
|
|
259
279
|
from_id = _resolve_callee(client, project, caller)
|
|
260
|
-
|
|
280
|
+
# An import target may be a stdlib/third-party module, which lives in
|
|
281
|
+
# the graph as a file-less node — or not at all, the first time anyone
|
|
282
|
+
# records it.
|
|
283
|
+
to_id = _resolve_callee(client, project, callee, allow_external=imports)
|
|
261
284
|
if from_id is None:
|
|
262
285
|
return {"status": "skipped", "reason": f"caller {caller!r} not found or ambiguous"}
|
|
286
|
+
# Only when nothing of that name exists — an ambiguous name still skips.
|
|
287
|
+
if (to_id is None and imports
|
|
288
|
+
and not _match_ids(client, project, callee, allow_external=True)):
|
|
289
|
+
to_id = f"{project}:{callee}"
|
|
290
|
+
language = client.graph.nodes[from_id].get("language", "")
|
|
291
|
+
client.graph.add_node(
|
|
292
|
+
to_id, type="Entity", name=callee, project=project,
|
|
293
|
+
entity_type="module", kind=f"module:{language}" if language else "module",
|
|
294
|
+
language=language, description=f"{language} module".strip(),
|
|
295
|
+
agent_inferred=True, agent_created=True,
|
|
296
|
+
)
|
|
263
297
|
if to_id is None:
|
|
264
298
|
return {"status": "skipped", "reason": f"callee {callee!r} not found or ambiguous"}
|
|
265
299
|
already = any(
|
|
266
|
-
e.get("type") ==
|
|
300
|
+
e.get("type") == relation
|
|
267
301
|
for e in client.graph.get_edge_data(from_id, to_id, default={}).values()
|
|
268
302
|
)
|
|
269
303
|
if already:
|
|
270
304
|
return {"status": "exists", "from": from_id, "to": to_id}
|
|
271
305
|
client.graph.add_edge(
|
|
272
306
|
from_id, to_id,
|
|
273
|
-
type=
|
|
307
|
+
type=relation,
|
|
274
308
|
source_file=client.graph.nodes[from_id].get("file", ""),
|
|
275
309
|
line=line,
|
|
276
310
|
resolved=False,
|
|
@@ -195,8 +195,14 @@ _REGISTRY = os.environ.get(
|
|
|
195
195
|
_BLOCKED_TOOLS = {"Grep", "Glob"}
|
|
196
196
|
# Word-boundary match anywhere in the command: catches `grep foo`,
|
|
197
197
|
# `git grep foo`, `sudo cat f`, `xargs rg` — not just command position.
|
|
198
|
-
# (?![\w-])
|
|
199
|
-
|
|
198
|
+
# (?![\w-]) keeps the bare-`cat` rule off hyphenated names; git's own
|
|
199
|
+
# subcommands are matched by the `\bgit\b ...` alternatives below.
|
|
200
|
+
_BLOCKED_SHELL_RE = re.compile(
|
|
201
|
+
r"\b(?:grep|rg|cat)\b(?![\w-])"
|
|
202
|
+
# git's own search/dump: `git grep`, `git log -S/-G`, `git ls-files`, `git cat-file`
|
|
203
|
+
r"|\bgit\b[^|;&]*?\s(?:grep|ls-files|cat-file)\b"
|
|
204
|
+
r"|\bgit\b[^|;&]*?\slog\b[^|;&]*?\s-[SG]"
|
|
205
|
+
)
|
|
200
206
|
|
|
201
207
|
|
|
202
208
|
def _repos() -> list:
|
|
@@ -262,11 +268,11 @@ def main() -> None:
|
|
|
262
268
|
saw_path = True
|
|
263
269
|
repo = _repo_containing(p)
|
|
264
270
|
if repo:
|
|
265
|
-
_block("grep/rg/cat")
|
|
271
|
+
_block("grep/rg/cat/git grep")
|
|
266
272
|
if not saw_path: # unparseable — decide by where the agent stands
|
|
267
273
|
repo = _repo_containing(os.path.realpath(cwd))
|
|
268
274
|
if repo:
|
|
269
|
-
_block("grep/rg/cat")
|
|
275
|
+
_block("grep/rg/cat/git grep")
|
|
270
276
|
# every named path is outside all codecompass repos — allow
|
|
271
277
|
|
|
272
278
|
sys.exit(0)
|
|
@@ -375,10 +381,12 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
375
381
|
// Word-boundary match anywhere in the command: catches `grep foo`,
|
|
376
382
|
// `git grep foo`, `sudo cat f`, `xargs rg` — not just command position.
|
|
377
383
|
// (?![\w-]) avoids false positives like `git cat-file`.
|
|
378
|
-
|
|
384
|
+
// git's own search/dump is blocked too: `git grep`, `git log -S/-G`, `git ls-files`, `git cat-file`.
|
|
385
|
+
const BLOCKED_SHELL_RE =
|
|
386
|
+
/\b(?:grep|rg|cat)\b(?![\w-])|\bgit\b[^|;&]*?\s(?:grep|ls-files|cat-file)\b|\bgit\b[^|;&]*?\slog\b[^|;&]*?\s-[SG]/;
|
|
379
387
|
|
|
380
388
|
const REASON =
|
|
381
|
-
"Don't grep/cat/rg the repo. Discover through the codecompass MCP tools — " +
|
|
389
|
+
"Don't grep/cat/rg (or `git grep`) the repo. Discover through the codecompass MCP tools — " +
|
|
382
390
|
"`grep` to find what's relevant, then `flow`/`impact`/`deps` to trace — " +
|
|
383
391
|
"then read the specific slice with the Read tool (or sed -n/head/tail), " +
|
|
384
392
|
"not a whole-file dump.";
|
|
@@ -437,16 +445,24 @@ def _ensure_pi_agents_md(repo_path: str) -> None:
|
|
|
437
445
|
)
|
|
438
446
|
|
|
439
447
|
|
|
440
|
-
def ingest_code(repo_path: str, normalize: bool = False, dump_triples: str | None = None
|
|
448
|
+
def ingest_code(repo_path: str, normalize: bool = False, dump_triples: str | None = None,
|
|
449
|
+
on_progress=None) -> None:
|
|
441
450
|
"""Ingest a codebase into the local code knowledge graph.
|
|
442
451
|
|
|
443
452
|
Phase 1: Walk the repo and write the Project → Folder → File skeleton.
|
|
444
453
|
Phase 2: Parse every source file with tree-sitter into CodeTriples.
|
|
445
454
|
Phase 3: Normalize entity names via Haiku (only if --normalize is passed).
|
|
446
455
|
Phase 4: Write all triples to the local graph.json.
|
|
456
|
+
|
|
457
|
+
on_progress: called as on_progress(percent, message) — for callers with no
|
|
458
|
+
console (the MCP server). Percent is 0-100 across all phases.
|
|
447
459
|
"""
|
|
448
460
|
import json
|
|
449
461
|
|
|
462
|
+
def _report(pct: int, message: str) -> None:
|
|
463
|
+
if on_progress:
|
|
464
|
+
on_progress(pct, message)
|
|
465
|
+
|
|
450
466
|
repo_path = os.path.abspath(repo_path)
|
|
451
467
|
project_name = os.path.basename(repo_path)
|
|
452
468
|
console.print(f"[bold blue]Ingesting codebase:[/] {repo_path}")
|
|
@@ -474,11 +490,24 @@ def ingest_code(repo_path: str, normalize: bool = False, dump_triples: str | Non
|
|
|
474
490
|
client.graph.clear()
|
|
475
491
|
|
|
476
492
|
console.print("[dim]Phase 1/4 — Building hierarchy…[/]")
|
|
493
|
+
_report(2, "Building hierarchy…")
|
|
477
494
|
file_id_map = build_hierarchy(repo_path, project_name, client)
|
|
478
495
|
console.print(f"[dim] {len(file_id_map)} source files indexed[/]")
|
|
479
496
|
|
|
480
497
|
console.print("[dim]Phase 2/4 — Parsing source files…[/]")
|
|
481
|
-
|
|
498
|
+
# Parsing dominates the wall clock, so it owns 5-70% of the bar.
|
|
499
|
+
_report(5, f"Parsing {len(file_id_map)} source files…")
|
|
500
|
+
last_pct = 5
|
|
501
|
+
|
|
502
|
+
def _parsed(done: int, total: int) -> None:
|
|
503
|
+
nonlocal last_pct
|
|
504
|
+
pct = 5 + int(65 * done / max(total, 1))
|
|
505
|
+
if pct > last_pct: # one notification per percent, not per file
|
|
506
|
+
last_pct = pct
|
|
507
|
+
_report(pct, f"Parsing files ({done}/{total})…")
|
|
508
|
+
|
|
509
|
+
raw_triples = parse_directory(repo_path, progress=True,
|
|
510
|
+
on_progress=_parsed if on_progress else None)
|
|
482
511
|
console.print(f"[dim] {len(raw_triples)} raw triples extracted[/]")
|
|
483
512
|
|
|
484
513
|
if not raw_triples:
|
|
@@ -508,6 +537,7 @@ def ingest_code(repo_path: str, normalize: bool = False, dump_triples: str | Non
|
|
|
508
537
|
if normalize:
|
|
509
538
|
from ingestion.code_normalizer import normalize_triples
|
|
510
539
|
console.print("[dim]Phase 3/4 — Normalizing triples via Haiku…[/]")
|
|
540
|
+
_report(72, "Normalizing triples via Haiku…")
|
|
511
541
|
triples = normalize_triples(raw_triples, progress=True)
|
|
512
542
|
console.print(f"[dim] {len(triples)} triples after normalization[/]")
|
|
513
543
|
else:
|
|
@@ -515,6 +545,7 @@ def ingest_code(repo_path: str, normalize: bool = False, dump_triples: str | Non
|
|
|
515
545
|
triples = raw_triples
|
|
516
546
|
|
|
517
547
|
console.print("[dim]Phase 4/4 — Writing to local graph…[/]")
|
|
548
|
+
_report(85, f"Writing {len(triples)} triples to the graph…")
|
|
518
549
|
written = client.write_code_triples_batch(triples, file_id_map, project_name)
|
|
519
550
|
|
|
520
551
|
# Restore agent-authored data onto the new graph.
|
|
@@ -542,11 +573,13 @@ def ingest_code(repo_path: str, normalize: bool = False, dump_triples: str | Non
|
|
|
542
573
|
# plus restored agent-inferred ones). Wipes and rewrites like the graph.
|
|
543
574
|
try:
|
|
544
575
|
from graph.vector_store import index_entities
|
|
576
|
+
_report(95, "Rebuilding vector index…")
|
|
545
577
|
n = index_entities(repo_path)
|
|
546
578
|
console.print(f"[dim]Phase 5/5 — Vector index rebuilt ({n} entities embedded)[/]")
|
|
547
579
|
except Exception as exc:
|
|
548
580
|
console.print(f"[dim]Phase 5/5 — Vector index skipped ({exc})[/]")
|
|
549
581
|
|
|
582
|
+
_report(100, f"Done — {written} triples, {total_nodes} nodes.")
|
|
550
583
|
console.print(
|
|
551
584
|
f"[bold green]Done.[/] Wrote {written} triples. "
|
|
552
585
|
f"Graph now has {total_nodes} nodes."
|
|
@@ -689,14 +722,26 @@ doc → `memory.md`; code-comment warning to the next person → `learnings.md`.
|
|
|
689
722
|
- After major refactors (moved functions, renamed classes)
|
|
690
723
|
- If query results look stale or incomplete
|
|
691
724
|
|
|
692
|
-
### The graph improves with use — record what it missed
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
graph
|
|
725
|
+
### The graph improves with use — record what it missed (NOT optional)
|
|
726
|
+
|
|
727
|
+
Exploring the code is how you find what the parser missed, so write it back as
|
|
728
|
+
you go — every session, not only when asked:
|
|
729
|
+
|
|
730
|
+
- Read a function/class/constant the graph doesn't have (or `grep`/`search`
|
|
731
|
+
found nothing for) → `add_entity(name, kind, file, line, description)`.
|
|
732
|
+
- Read a call the graph doesn't show — dynamic dispatch, a callback, a handler
|
|
733
|
+
wired up at runtime → `add_call(caller, callee, line)`. Same tool for a
|
|
734
|
+
missed import or base class: `add_call(a, b, relation="IMPORTS")` /
|
|
735
|
+
`relation="INHERITS"`. IMPORTS targets may be stdlib or third-party
|
|
736
|
+
modules (`add_call("main", "pathlib", relation="IMPORTS")`).
|
|
737
|
+
- Worked out what an entity actually does → `add_entity` again with the real
|
|
738
|
+
one-line description; it overwrites the placeholder.
|
|
739
|
+
|
|
740
|
+
Record it in the same turn you learned it, before you answer the user — a fact
|
|
741
|
+
you postpone is a fact the next session re-derives. Both tools mark entries
|
|
742
|
+
`agent_inferred` and skip anything ambiguous rather than guess, so a wrong
|
|
743
|
+
guess costs nothing but a `skipped` status. Small opportunistic writes keep
|
|
744
|
+
the graph accurate between full `enrich` runs.
|
|
700
745
|
|
|
701
746
|
### Enrichment — user-triggered ONLY
|
|
702
747
|
|
|
@@ -15,10 +15,13 @@ agent says "use this repo" or "cd to this repo" to point it elsewhere.
|
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
+
import functools
|
|
18
19
|
import os
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
import anyio.from_thread
|
|
23
|
+
import anyio.to_thread
|
|
24
|
+
from fastmcp import Context, FastMCP
|
|
22
25
|
|
|
23
26
|
from graph.code_queries import (
|
|
24
27
|
DEFAULT_HOPS,
|
|
@@ -95,11 +98,25 @@ def _active_repo() -> str:
|
|
|
95
98
|
return _REPO_PATH
|
|
96
99
|
|
|
97
100
|
|
|
101
|
+
# Attached to every read result: exploring is when the agent learns what the
|
|
102
|
+
# parser missed, so the reminder to write it back rides along with the answer.
|
|
103
|
+
_RECORD_NUDGE = (
|
|
104
|
+
"Before moving on: anything you read that the graph is missing or "
|
|
105
|
+
"describes badly — record it now. add_entity(name, kind, file, line, "
|
|
106
|
+
"description) for entities, add_call(caller, callee, line) for calls. "
|
|
107
|
+
"Ambiguous entries are skipped, not guessed; agent-recorded data survives "
|
|
108
|
+
"re-ingest."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
98
112
|
def _tool(fetch_fn, *args, **kwargs):
|
|
99
113
|
"""Resolve the active repo, ensure it is initialized, and call a fetch helper."""
|
|
100
114
|
repo = _active_repo()
|
|
101
115
|
_ensure_initialized(repo)
|
|
102
|
-
|
|
116
|
+
result = fetch_fn(*args, repo, os.path.basename(repo), **kwargs)
|
|
117
|
+
if isinstance(result, dict):
|
|
118
|
+
result.setdefault("next", _RECORD_NUDGE)
|
|
119
|
+
return result
|
|
103
120
|
|
|
104
121
|
|
|
105
122
|
@mcp.tool()
|
|
@@ -178,7 +195,10 @@ def search(query: str, limit: int = 10) -> dict:
|
|
|
178
195
|
repo = _active_repo()
|
|
179
196
|
_ensure_initialized(repo)
|
|
180
197
|
from graph.vector_store import search_entities
|
|
181
|
-
|
|
198
|
+
result = search_entities(repo, query, limit=limit)
|
|
199
|
+
if isinstance(result, dict):
|
|
200
|
+
result.setdefault("next", _RECORD_NUDGE)
|
|
201
|
+
return result
|
|
182
202
|
|
|
183
203
|
|
|
184
204
|
@mcp.tool()
|
|
@@ -247,7 +267,8 @@ def init() -> dict:
|
|
|
247
267
|
|
|
248
268
|
|
|
249
269
|
@mcp.tool()
|
|
250
|
-
def ingest(normalize: bool = False,
|
|
270
|
+
async def ingest(ctx: Context, normalize: bool = False,
|
|
271
|
+
dump_triples: str | None = None) -> dict:
|
|
251
272
|
"""Re-index the currently configured repo and rebuild the code knowledge graph.
|
|
252
273
|
|
|
253
274
|
normalize: normalize entity names via Haiku (slower, needs an API key).
|
|
@@ -255,7 +276,15 @@ def ingest(normalize: bool = False, dump_triples: str | None = None) -> dict:
|
|
|
255
276
|
loading them into the graph (debugging the parser)."""
|
|
256
277
|
repo = _active_repo()
|
|
257
278
|
_ensure_initialized(repo)
|
|
258
|
-
|
|
279
|
+
|
|
280
|
+
# ingest_code is blocking, so it runs in a worker thread; progress hops back
|
|
281
|
+
# to the event loop from there so notifications flush while it works.
|
|
282
|
+
def on_progress(pct: int, message: str) -> None:
|
|
283
|
+
anyio.from_thread.run(ctx.report_progress, pct, 100, message)
|
|
284
|
+
|
|
285
|
+
await anyio.to_thread.run_sync(
|
|
286
|
+
functools.partial(ingest_code, repo, normalize=normalize,
|
|
287
|
+
dump_triples=dump_triples, on_progress=on_progress))
|
|
259
288
|
return {"status": "ok", "repo": repo, "project": os.path.basename(repo),
|
|
260
289
|
"normalize": normalize, "dump_triples": dump_triples,
|
|
261
290
|
"next": "Record anything the parser missed while you were reading "
|
|
@@ -302,16 +331,24 @@ def add_entity(name: str, kind: str = "function", file: str = "",
|
|
|
302
331
|
|
|
303
332
|
|
|
304
333
|
@mcp.tool()
|
|
305
|
-
def add_call(caller: str, callee: str, line: int | None = None
|
|
306
|
-
|
|
307
|
-
|
|
334
|
+
def add_call(caller: str, callee: str, line: int | None = None,
|
|
335
|
+
relation: str = "CALLS") -> dict:
|
|
336
|
+
"""Record an edge you spotted in source that the parser missed — a call via
|
|
337
|
+
dynamic dispatch, a callback, string-based lookup, a conditional import, a
|
|
338
|
+
runtime-registered base class.
|
|
339
|
+
|
|
340
|
+
relation is CALLS (default), IMPORTS, or INHERITS. Structural edges
|
|
341
|
+
(CONTAINS/DEFINED_IN) are parser-owned and cannot be added. Both names must
|
|
308
342
|
resolve unambiguously — ambiguous targets are skipped, never guessed.
|
|
309
|
-
|
|
343
|
+
IMPORTS may target a stdlib or third-party module (`add_call("main",
|
|
344
|
+
"pathlib", relation="IMPORTS")`); the module node is created if the graph
|
|
345
|
+
has never seen it. Idempotent: existing edges of the same type are left
|
|
346
|
+
alone.
|
|
310
347
|
"""
|
|
311
348
|
repo = _active_repo()
|
|
312
349
|
_ensure_initialized(repo)
|
|
313
350
|
from ingestion.enricher import add_call as _add
|
|
314
|
-
return _add(repo, caller, callee, line=line)
|
|
351
|
+
return _add(repo, caller, callee, line=line, relation=relation)
|
|
315
352
|
|
|
316
353
|
|
|
317
354
|
def main() -> None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "codecompass-mcp"
|
|
3
|
-
version = "5.
|
|
3
|
+
version = "5.2.0"
|
|
4
4
|
description = "Structural code context for AI coding agents — a local code knowledge graph for blast radius, impact, deps, dead code, and flow tracing"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = {text = "MIT"}
|
|
@@ -56,11 +56,20 @@ def test_hook_blocks_inside_repo_allows_outside(tmp_path):
|
|
|
56
56
|
"cwd": str(repo)}, env)
|
|
57
57
|
assert r.returncode == 2
|
|
58
58
|
|
|
59
|
-
# git
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
# git's other content searches -> blocked
|
|
60
|
+
for cmd in ("git ls-files", "git log -S needle", "git -C . grep foo",
|
|
61
|
+
"git cat-file -p HEAD"):
|
|
62
|
+
r = _run(hook, {"tool_name": "Bash",
|
|
63
|
+
"tool_input": {"command": cmd},
|
|
64
|
+
"cwd": str(repo)}, env)
|
|
65
|
+
assert r.returncode == 2, cmd
|
|
66
|
+
|
|
67
|
+
# ordinary git history/read commands -> allowed
|
|
68
|
+
for cmd in ("git log --oneline", "git status", "git show HEAD"):
|
|
69
|
+
r = _run(hook, {"tool_name": "Bash",
|
|
70
|
+
"tool_input": {"command": cmd},
|
|
71
|
+
"cwd": str(repo)}, env)
|
|
72
|
+
assert r.returncode == 0, cmd
|
|
64
73
|
|
|
65
74
|
# Grep tool defaulting to cwd (inside the repo) -> blocked
|
|
66
75
|
r = _run(hook, {"tool_name": "Grep",
|
|
@@ -109,12 +109,38 @@ def test_add_entity_and_add_call(tmp_path):
|
|
|
109
109
|
assert add_call(str(repo_path), "caller", "shared")["status"] == "skipped"
|
|
110
110
|
assert add_call(str(repo_path), "ghost", "callee")["status"] == "skipped"
|
|
111
111
|
|
|
112
|
+
# Non-CALLS relations ride the same tool; CALLS on the same pair already
|
|
113
|
+
# exists, so a distinct type must still be added.
|
|
114
|
+
assert add_call(str(repo_path), "caller", "callee",
|
|
115
|
+
relation="imports")["status"] == "added"
|
|
116
|
+
assert add_call(str(repo_path), "caller", "callee",
|
|
117
|
+
relation="IMPORTS")["status"] == "exists"
|
|
118
|
+
# Structural edges stay parser-owned
|
|
119
|
+
assert add_call(str(repo_path), "caller", "callee",
|
|
120
|
+
relation="CONTAINS")["status"] == "skipped"
|
|
121
|
+
|
|
122
|
+
# IMPORTS of a module the graph has never seen creates the external node;
|
|
123
|
+
# a CALLS to the same unknown name still skips (only imports go external).
|
|
124
|
+
assert add_call(str(repo_path), "caller", "pathlib",
|
|
125
|
+
relation="IMPORTS")["status"] == "added"
|
|
126
|
+
assert add_call(str(repo_path), "caller", "pathlib")["status"] == "skipped"
|
|
127
|
+
# Ambiguity still wins over node creation
|
|
128
|
+
assert add_call(str(repo_path), "caller", "shared",
|
|
129
|
+
relation="IMPORTS")["status"] == "skipped"
|
|
130
|
+
|
|
112
131
|
client = get_client(str(repo_path))
|
|
113
132
|
node = next(a for _, a in client.graph.nodes(data=True) if a.get("name") == "helper")
|
|
114
133
|
assert node["description"] == "Async helper." and node["agent_inferred"] is True
|
|
115
134
|
edge = next(e for e in client.graph.get_edge_data(
|
|
116
135
|
f"{project}:caller", f"{project}:callee").values() if e.get("type") == "CALLS")
|
|
117
136
|
assert edge["agent_inferred"] is True
|
|
137
|
+
imports = next(e for e in client.graph.get_edge_data(
|
|
138
|
+
f"{project}:caller", f"{project}:callee").values() if e.get("type") == "IMPORTS")
|
|
139
|
+
assert imports["agent_inferred"] is True
|
|
140
|
+
# The stdlib module node the agent created: file-less, like the parser's own
|
|
141
|
+
stdlib = client.graph.nodes[f"{project}:pathlib"]
|
|
142
|
+
assert stdlib["kind"] == "module:python" and not stdlib.get("file")
|
|
143
|
+
assert stdlib["agent_created"] is True
|
|
118
144
|
client.close()
|
|
119
145
|
|
|
120
146
|
|
|
@@ -136,6 +162,8 @@ def test_agent_data_survives_reingest(tmp_path):
|
|
|
136
162
|
|
|
137
163
|
add_entity(str(repo_path), "helper", file="src/a.py", description="Async helper.")
|
|
138
164
|
assert add_call(str(repo_path), "caller", "target", line=2)["status"] == "added"
|
|
165
|
+
assert add_call(str(repo_path), "caller", "pathlib",
|
|
166
|
+
relation="IMPORTS")["status"] == "added"
|
|
139
167
|
|
|
140
168
|
cc_main.ingest_code(str(repo_path)) # full rebuild
|
|
141
169
|
|
|
@@ -147,4 +175,8 @@ def test_agent_data_survives_reingest(tmp_path):
|
|
|
147
175
|
assert len(agent_edges) == 1
|
|
148
176
|
names = {client.graph.nodes[n].get("name") for n in agent_edges[0]}
|
|
149
177
|
assert names == {"caller", "target"}
|
|
178
|
+
# The file-less stdlib node and its IMPORTS edge survive too
|
|
179
|
+
assert client.graph.nodes[f"{project}:pathlib"]["agent_created"] is True
|
|
180
|
+
assert any(e.get("type") == "IMPORTS" and e.get("agent_inferred")
|
|
181
|
+
for _, _, e in client.graph.edges(data=True))
|
|
150
182
|
client.close()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The MCP ingest tool streams progress notifications while it works."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.anyio
|
|
14
|
+
async def test_ingest_reports_progress(tmp_path, monkeypatch):
|
|
15
|
+
from fastmcp import Client
|
|
16
|
+
|
|
17
|
+
import mcp_server
|
|
18
|
+
|
|
19
|
+
repo = tmp_path / "repo"
|
|
20
|
+
(repo / "pkg").mkdir(parents=True)
|
|
21
|
+
for i in range(5):
|
|
22
|
+
(repo / "pkg" / f"m{i}.py").write_text(f"def f{i}():\n return {i}\n")
|
|
23
|
+
|
|
24
|
+
monkeypatch.setattr(mcp_server, "_REPO_PATH", str(repo))
|
|
25
|
+
seen = []
|
|
26
|
+
|
|
27
|
+
async def handler(progress: float, total: float | None, message: str | None):
|
|
28
|
+
seen.append((progress, total, message))
|
|
29
|
+
|
|
30
|
+
async with Client(mcp_server.mcp, progress_handler=handler) as client:
|
|
31
|
+
result = await client.call_tool("ingest", {})
|
|
32
|
+
|
|
33
|
+
assert result.data["status"] == "ok"
|
|
34
|
+
percents = [p for p, _, _ in seen]
|
|
35
|
+
assert percents == sorted(percents) # monotonic
|
|
36
|
+
assert percents[0] < 100 and percents[-1] == 100
|
|
37
|
+
assert all(t == 100 for _, t, _ in seen)
|
|
38
|
+
assert any("Parsing" in (m or "") for _, _, m in seen)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def anyio_backend():
|
|
43
|
+
return "asyncio"
|
|
File without changes
|
|
File without changes
|
{codecompass_mcp-5.1.0 → codecompass_mcp-5.2.0}/codecompass_mcp.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|