codecompass-mcp 5.1.0__tar.gz → 5.3.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.
Files changed (39) hide show
  1. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/PKG-INFO +1 -1
  2. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/codecompass_mcp.egg-info/PKG-INFO +1 -1
  3. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/codecompass_mcp.egg-info/SOURCES.txt +1 -0
  4. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/graph/code_graph_client.py +8 -1
  5. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/graph/code_queries.py +2 -1
  6. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/ingestion/code_parser.py +6 -1
  7. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/ingestion/enricher.py +46 -12
  8. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/main.py +61 -16
  9. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/mcp_server.py +47 -10
  10. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/pyproject.toml +1 -1
  11. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/tests/test_block_hook.py +14 -5
  12. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/tests/test_enricher.py +32 -0
  13. codecompass_mcp-5.3.0/tests/test_ingest_progress.py +43 -0
  14. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/LICENSE +0 -0
  15. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/README.md +0 -0
  16. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/codecompass_mcp.egg-info/dependency_links.txt +0 -0
  17. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/codecompass_mcp.egg-info/entry_points.txt +0 -0
  18. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/codecompass_mcp.egg-info/requires.txt +0 -0
  19. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/codecompass_mcp.egg-info/top_level.txt +0 -0
  20. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/config.py +0 -0
  21. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/graph/__init__.py +0 -0
  22. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/graph/vector_store.py +0 -0
  23. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/ingestion/__init__.py +0 -0
  24. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/ingestion/code_normalizer.py +0 -0
  25. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/ingestion/file_watcher.py +0 -0
  26. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/ingestion/hierarchy_builder.py +0 -0
  27. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/ingestion/source_context.py +0 -0
  28. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/models/__init__.py +0 -0
  29. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/models/code_types.py +0 -0
  30. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/pi_setup.py +0 -0
  31. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/setup.cfg +0 -0
  32. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/tests/test_accuracy_fixes.py +0 -0
  33. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/tests/test_code_parser.py +0 -0
  34. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/tests/test_enrich_preserve.py +0 -0
  35. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/tests/test_init.py +0 -0
  36. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/tests/test_lit_css_parser.py +0 -0
  37. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/tests/test_mcp_server.py +0 -0
  38. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.0}/tests/test_php_parser.py +0 -0
  39. {codecompass_mcp-5.1.0 → codecompass_mcp-5.3.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.1.0
3
+ Version: 5.3.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.1.0
3
+ Version: 5.3.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
@@ -29,6 +29,7 @@ tests/test_block_hook.py
29
29
  tests/test_code_parser.py
30
30
  tests/test_enrich_preserve.py
31
31
  tests/test_enricher.py
32
+ tests/test_ingest_progress.py
32
33
  tests/test_init.py
33
34
  tests/test_lit_css_parser.py
34
35
  tests/test_mcp_server.py
@@ -487,6 +487,7 @@ class LocalGraphClient:
487
487
  "caller_type": node.get("entity_type"),
488
488
  "caller_file": call_edge.get("source_file") or node.get("file"),
489
489
  "line": call_edge.get("line"),
490
+ "description": node.get("description"),
490
491
  "receiver": receiver,
491
492
  "receiver_type": receiver_type,
492
493
  "resolved": True,
@@ -520,6 +521,7 @@ class LocalGraphClient:
520
521
  "caller_type": node.get("entity_type"),
521
522
  "caller_file": ce.get("source_file") or node.get("file"),
522
523
  "line": ce.get("line"),
524
+ "description": node.get("description"),
523
525
  "receiver": next((e.get("receiver") for e in rel if e.get("receiver")), None),
524
526
  "receiver_type": None,
525
527
  "resolved": False,
@@ -559,6 +561,7 @@ class LocalGraphClient:
559
561
  "kind": a.get("kind"),
560
562
  "file": a.get("file"),
561
563
  "line": a.get("line"),
564
+ "description": a.get("description"),
562
565
  "matched_field": f,
563
566
  "match": m.group(0),
564
567
  })
@@ -595,6 +598,7 @@ class LocalGraphClient:
595
598
  "dependency": node.get("name"),
596
599
  "dep_type": node.get("entity_type"),
597
600
  "line": node.get("line"),
601
+ "description": node.get("description"),
598
602
  "depth": depth + 1
599
603
  })
600
604
 
@@ -615,7 +619,8 @@ class LocalGraphClient:
615
619
  results.append({
616
620
  "selector": node.get("name"),
617
621
  "source_file": node.get("file"),
618
- "line": edge.get("line")
622
+ "line": edge.get("line"),
623
+ "description": node.get("description"),
619
624
  })
620
625
 
621
626
  return sorted(results, key=lambda x: x["selector"])
@@ -712,6 +717,7 @@ class LocalGraphClient:
712
717
  "callee_type": node.get("entity_type"),
713
718
  "callee_file": node.get("file"),
714
719
  "line": node.get("line"),
720
+ "description": node.get("description"),
715
721
  "depth": depth + 1
716
722
  })
717
723
  visited.add(succ)
@@ -784,6 +790,7 @@ class LocalGraphClient:
784
790
  "entity_type": attr.get("entity_type", ""),
785
791
  "file": attr.get("file", ""),
786
792
  "line": attr.get("line"),
793
+ "description": attr.get("description", ""),
787
794
  }
788
795
  # Exported symbols and name-heuristic entry points are public API:
789
796
  # having no in-repo caller is expected, not evidence of dead code.
@@ -172,7 +172,8 @@ def fetch_flow(start_name: str, repo_path: str, project: str, max_hops: int = DE
172
172
  edges = _order_edges(edges, project, start_name)
173
173
  lean_nodes = [
174
174
  {"id": n["id"], "name": n["name"], "kind": n.get("kind", ""),
175
- "file": n.get("file", ""), "line": n.get("line"), "depth": n["depth"]}
175
+ "file": n.get("file", ""), "line": n.get("line"), "depth": n["depth"],
176
+ "description": n.get("description", "")}
176
177
  for n in sorted(nodes, key=lambda n: n["depth"])
177
178
  ]
178
179
  lean_edges = [
@@ -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) -> str | None:
184
- """Node id of the single in-project entity called `name`, else None.
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 a CALLS edge the agent spotted in source but the parser missed.
253
- Both ends must resolve unambiguously ambiguous names are skipped, never
254
- guessed. Idempotent: an existing CALLS edge is left alone."""
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
- to_id = _resolve_callee(client, project, callee)
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") == "CALLS"
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="CALLS",
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-]) avoids false positives like `git cat-file`.
199
- _BLOCKED_SHELL_RE = re.compile(r"\b(?:grep|rg|cat)\b(?![\w-])")
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
- const BLOCKED_SHELL_RE = /\b(?:grep|rg|cat)\b(?![\w-])/;
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) -> 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
- raw_triples = parse_directory(repo_path, progress=True)
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
- While reading code you may find entities, calls, or important variables the
695
- parser didn't capture. Record them immediately with the MCP tools
696
- `add_entity(name, kind, file, line, description)` and
697
- `add_call(caller, callee, line)`. Both mark entries `agent_inferred` and skip
698
- anything ambiguous rather than guess. Small opportunistic writes keep the
699
- graph accurate between full `enrich` runs.
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
- from fastmcp import FastMCP
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
- return fetch_fn(*args, repo, os.path.basename(repo), **kwargs)
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
- return search_entities(repo, query, limit=limit)
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, dump_triples: str | None = None) -> dict:
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
- ingest_code(repo, normalize=normalize, dump_triples=dump_triples)
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) -> dict:
306
- """Record a CALLS edge you spotted in source that the parser missed
307
- (dynamic dispatch, callbacks, string-based lookup). Both names must
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
- Idempotent: existing edges are left alone.
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.1.0"
3
+ version = "5.3.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 cat-file is not a search -> allowed
60
- r = _run(hook, {"tool_name": "Bash",
61
- "tool_input": {"command": "git cat-file -p HEAD"},
62
- "cwd": str(repo)}, env)
63
- assert r.returncode == 0
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