vigil-codeintel 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. vigil_codeintel-0.1.0.dist-info/METADATA +780 -0
  2. vigil_codeintel-0.1.0.dist-info/RECORD +131 -0
  3. vigil_codeintel-0.1.0.dist-info/WHEEL +5 -0
  4. vigil_codeintel-0.1.0.dist-info/entry_points.txt +3 -0
  5. vigil_codeintel-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. vigil_codeintel-0.1.0.dist-info/top_level.txt +3 -0
  7. vigil_forensic/__init__.py +224 -0
  8. vigil_forensic/_git_utils.py +178 -0
  9. vigil_forensic/_shared.py +510 -0
  10. vigil_forensic/_stubs.py +156 -0
  11. vigil_forensic/gate_checks/__init__.py +1 -0
  12. vigil_forensic/gate_checks/_ast_helpers.py +629 -0
  13. vigil_forensic/gate_checks/_deployment_detector.py +573 -0
  14. vigil_forensic/gate_checks/atomic_write_checks.py +1143 -0
  15. vigil_forensic/gate_checks/authority_checks.py +95 -0
  16. vigil_forensic/gate_checks/boundary_breach_checks.py +202 -0
  17. vigil_forensic/gate_checks/broad_except_checks.py +301 -0
  18. vigil_forensic/gate_checks/broad_except_hidden_sentinel_checks.py +365 -0
  19. vigil_forensic/gate_checks/common.py +253 -0
  20. vigil_forensic/gate_checks/config_safety_checks.py +704 -0
  21. vigil_forensic/gate_checks/config_ssot_checks.py +78 -0
  22. vigil_forensic/gate_checks/conflict_checks.py +193 -0
  23. vigil_forensic/gate_checks/context_fallback_checks.py +697 -0
  24. vigil_forensic/gate_checks/context_health_checks.py +289 -0
  25. vigil_forensic/gate_checks/contract_shape_drift_checks.py +459 -0
  26. vigil_forensic/gate_checks/dirty_baseline_check.py +274 -0
  27. vigil_forensic/gate_checks/duplication_checks.py +387 -0
  28. vigil_forensic/gate_checks/embedded_string_checks.py +123 -0
  29. vigil_forensic/gate_checks/empty_output_checks.py +87 -0
  30. vigil_forensic/gate_checks/encoding_checks.py +847 -0
  31. vigil_forensic/gate_checks/export_completeness_checks.py +156 -0
  32. vigil_forensic/gate_checks/fallback_checks.py +41 -0
  33. vigil_forensic/gate_checks/file_proliferation_checks.py +171 -0
  34. vigil_forensic/gate_checks/fix_without_test_checks.py +69 -0
  35. vigil_forensic/gate_checks/forensic_cluster_runners/__init__.py +9 -0
  36. vigil_forensic/gate_checks/forensic_cluster_runners/_helpers.py +71 -0
  37. vigil_forensic/gate_checks/forensic_cluster_runners/advanced_checks.py +322 -0
  38. vigil_forensic/gate_checks/forensic_cluster_runners/core.py +273 -0
  39. vigil_forensic/gate_checks/forensic_cluster_runners/integrity_checks.py +203 -0
  40. vigil_forensic/gate_checks/forensic_cluster_runners/quality_checks.py +666 -0
  41. vigil_forensic/gate_checks/forensic_clusters/__init__.py +193 -0
  42. vigil_forensic/gate_checks/forensic_clusters/allowlist.py +426 -0
  43. vigil_forensic/gate_checks/forensic_clusters/allowlist_writer.py +302 -0
  44. vigil_forensic/gate_checks/forensic_clusters/api_protocol.py +231 -0
  45. vigil_forensic/gate_checks/forensic_clusters/async_quality.py +1156 -0
  46. vigil_forensic/gate_checks/forensic_clusters/code_style.py +808 -0
  47. vigil_forensic/gate_checks/forensic_clusters/core.py +319 -0
  48. vigil_forensic/gate_checks/forensic_clusters/data_quality.py +763 -0
  49. vigil_forensic/gate_checks/forensic_clusters/dead_code.py +480 -0
  50. vigil_forensic/gate_checks/forensic_clusters/edit_mutation.py +842 -0
  51. vigil_forensic/gate_checks/forensic_clusters/exception_boundary.py +240 -0
  52. vigil_forensic/gate_checks/forensic_clusters/legacy_debt.py +556 -0
  53. vigil_forensic/gate_checks/forensic_clusters/static_analysis.py +834 -0
  54. vigil_forensic/gate_checks/forensic_clusters/structural_quality.py +298 -0
  55. vigil_forensic/gate_checks/god_object_zones_checks.py +173 -0
  56. vigil_forensic/gate_checks/hallucination_checks.py +566 -0
  57. vigil_forensic/gate_checks/hunter_artifact_completeness_check.py +139 -0
  58. vigil_forensic/gate_checks/implementation_overfit_checks.py +380 -0
  59. vigil_forensic/gate_checks/import_integrity_checks.py +233 -0
  60. vigil_forensic/gate_checks/imports_in_function_checks.py +283 -0
  61. vigil_forensic/gate_checks/ml_checks.py +318 -0
  62. vigil_forensic/gate_checks/performance_checks.py +106 -0
  63. vigil_forensic/gate_checks/project_specific_runner.py +691 -0
  64. vigil_forensic/gate_checks/provider_capability_checks.py +73 -0
  65. vigil_forensic/gate_checks/refactor_completeness_checks.py +274 -0
  66. vigil_forensic/gate_checks/reliability_checks.py +389 -0
  67. vigil_forensic/gate_checks/reporting_checks.py +55 -0
  68. vigil_forensic/gate_checks/runtime_behavior_checks.py +220 -0
  69. vigil_forensic/gate_checks/security_injection_checks.py +332 -0
  70. vigil_forensic/gate_checks/semantic_intent_checks.py +139 -0
  71. vigil_forensic/gate_checks/size_complexity_checks.py +336 -0
  72. vigil_forensic/gate_checks/stuck_feature_flag_checks.py +354 -0
  73. vigil_forensic/gate_checks/syntax_validity_checks.py +217 -0
  74. vigil_forensic/gate_checks/temporal_freshness_checks.py +79 -0
  75. vigil_forensic/gate_checks/test_quality_checks.py +946 -0
  76. vigil_forensic/gate_checks/testing_checks.py +149 -0
  77. vigil_forensic/gate_checks/toctou_checks.py +367 -0
  78. vigil_forensic/gate_checks/type_checking_checks.py +316 -0
  79. vigil_forensic/gate_models.py +392 -0
  80. vigil_forensic/gate_packs/__init__.py +1 -0
  81. vigil_forensic/gate_packs/universal.py +179 -0
  82. vigil_forensic/gate_profile.json +31 -0
  83. vigil_forensic/gate_registry.py +21 -0
  84. vigil_forensic/language_profiles.py +219 -0
  85. vigil_forensic/meta_findings.py +207 -0
  86. vigil_forensic/self_audit.py +725 -0
  87. vigil_forensic/source_analysis.py +175 -0
  88. vigil_mapper/__init__.py +103 -0
  89. vigil_mapper/_ast_helpers_minimal.py +229 -0
  90. vigil_mapper/_extract_imports_impl.py +123 -0
  91. vigil_mapper/_file_count_guard.py +129 -0
  92. vigil_mapper/_git_utils.py +178 -0
  93. vigil_mapper/_runtime_ast.py +438 -0
  94. vigil_mapper/_runtime_dispatch.py +137 -0
  95. vigil_mapper/_seed_helpers.py +82 -0
  96. vigil_mapper/authority_builder.py +1102 -0
  97. vigil_mapper/cli_entry.py +731 -0
  98. vigil_mapper/conflict_builder.py +818 -0
  99. vigil_mapper/data_contract_builder.py +446 -0
  100. vigil_mapper/findings_builder.py +716 -0
  101. vigil_mapper/fingerprint.py +53 -0
  102. vigil_mapper/hotspot_builder.py +539 -0
  103. vigil_mapper/map_common.py +449 -0
  104. vigil_mapper/map_errors.py +55 -0
  105. vigil_mapper/map_models.py +431 -0
  106. vigil_mapper/map_models_ext.py +206 -0
  107. vigil_mapper/map_models_findings.py +130 -0
  108. vigil_mapper/map_storage.py +455 -0
  109. vigil_mapper/parse_cache.py +795 -0
  110. vigil_mapper/refactor_boundary_builder.py +266 -0
  111. vigil_mapper/runtime_builder.py +527 -0
  112. vigil_mapper/runtime_tracer.py +243 -0
  113. vigil_mapper/runtime_tracer_entry.py +199 -0
  114. vigil_mapper/semantic_diff.py +71 -0
  115. vigil_mapper/source_adapters/__init__.py +109 -0
  116. vigil_mapper/source_adapters/_base.py +264 -0
  117. vigil_mapper/source_adapters/_ir.py +156 -0
  118. vigil_mapper/source_adapters/_lexer.py +309 -0
  119. vigil_mapper/source_adapters/_patterns.py +212 -0
  120. vigil_mapper/source_adapters/_treesitter.py +182 -0
  121. vigil_mapper/source_adapters/go.py +553 -0
  122. vigil_mapper/source_adapters/java.py +541 -0
  123. vigil_mapper/source_adapters/javascript.py +626 -0
  124. vigil_mapper/source_adapters/python.py +325 -0
  125. vigil_mapper/source_adapters/typescript.py +749 -0
  126. vigil_mapper/structural_builder.py +586 -0
  127. vigil_mcp/__init__.py +1 -0
  128. vigil_mcp/_jobs.py +587 -0
  129. vigil_mcp/_paths.py +93 -0
  130. vigil_mcp/forensic_server.py +419 -0
  131. vigil_mcp/map_server.py +452 -0
@@ -0,0 +1,419 @@
1
+ """FastMCP stdio server: forensic-audit
2
+
3
+ Wraps vigil_forensic.run_forensic_audit behind a background-job poll API.
4
+ Resource constraints:
5
+ - At most 2 concurrent jobs (enforced by _jobs.JobRegistry).
6
+ - One thread per job (no pool).
7
+ - run_forensic_audit already enforces workers=1 internally (verified in source).
8
+ - Output truncated/paginated to OUTPUT_CHAR_LIMIT chars (~25 k tokens budget).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from collections import Counter
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from mcp.server.fastmcp import FastMCP
18
+
19
+ from vigil_mcp import _jobs
20
+ from vigil_mcp import _paths
21
+ from vigil_forensic import run_forensic_audit
22
+
23
+ _INSTRUCTIONS = """\
24
+ forensic-audit - static code-quality forensic auditor. Finds real bugs, swallowed
25
+ exceptions, security issues, oversized/over-nested code, and cross-file duplication
26
+ across Python/Go/Java/JS/TS. Pure static analysis (tree-sitter/AST) - it never runs
27
+ the project or its tests.
28
+
29
+ WHEN TO USE: when the user asks to audit a project, review code quality, or find
30
+ problems/bugs/smells in a codebase or a set of changes - before committing or merging.
31
+ Not for running tests (it doesn't execute code).
32
+
33
+ WORKFLOW (background job + poll - do not expect an instant answer):
34
+ 1. start_forensic_audit(path="") -> leave path empty to auto-detect the project
35
+ root from the current directory; returns {job_id, resolved_path}.
36
+ 2. get_forensic_status(job_id) -> poll until status == "done" (usually seconds).
37
+ 3. get_forensic_results(job_id) -> returns a COMPACT SUMMARY by default
38
+ (counts by severity + by check_id + top findings). Read this FIRST; it is sized
39
+ to fit the context budget (~3k tokens), so prefer it over the full list.
40
+ 4. Only if needed: get_forensic_results(job_id, view="full", severity="HIGH")
41
+ or check_id="..." to drill into specific findings (paginated).
42
+
43
+ INTERPRETING: exit_code 0 = clean, 1 = high/critical findings exist, 2 = error.
44
+ Triage HIGH first. On clean third-party code most findings are size.* (large files)
45
+ and broad_except (real `except: pass` swallows).
46
+
47
+ HUGE REPOS (anti-hang): if the collected file count exceeds max_files (default 800),
48
+ the audit is SKIPPED and the result has meta.skipped_reason="too_many_files" with
49
+ top_subdirs + a suggestion - scan a submodule (start_forensic_audit(path='<dir>/<subdir>'))
50
+ or pass a larger max_files to force a full scan.
51
+
52
+ TUNING (enable / disable checks):
53
+ - DISABLE noisy gates for a project: create <project>/.cortex/disabled_gates.json
54
+ = ["gate_id", ...]; those gates never run (reported in meta.gates_skipped).
55
+ - RUN ONLY specific gates: pass gates="gate_id1,gate_id2" (comma-separated) -
56
+ everything else is skipped. Empty = run all applicable gates.
57
+ - ENABLE an opt-in heuristic gate (e.g. god_object_zones, OFF by default because
58
+ noisy): name it in gates=, e.g. gates="god_object_zones".
59
+ - RAISE the severity floor: severity="HIGH" keeps only HIGH/CRITICAL findings.
60
+ """
61
+
62
+ mcp = FastMCP("forensic-audit", instructions=_INSTRUCTIONS)
63
+
64
+ # ~80 k chars keeps well under the 25 k token MCP output limit.
65
+ OUTPUT_CHAR_LIMIT = 80_000
66
+
67
+ # Severity ordering, highest first, used to pick the "top" findings bucket.
68
+ _SEVERITY_ORDER = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO")
69
+
70
+ # Caps for the compact summary view (keep JSON well under the MCP budget).
71
+ _TOP_FINDINGS_CAP = 20
72
+ _BY_CHECK_ID_CAP = 25
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Summary builder (Feature 1 - summary-first forensic results)
77
+ # ---------------------------------------------------------------------------
78
+
79
+ def _finding_location(f: dict) -> tuple[Any, Any]:
80
+ """Best-effort (file, line) for a finding across both schemas.
81
+
82
+ Synthetic/test findings carry flat ``file``/``line`` keys; real
83
+ vigil_forensic findings instead carry an ``evidence`` list of
84
+ ``{"kind": "file", "path": ..., "detail": "line:N"}``. Prefer the flat
85
+ keys, fall back to the first file-evidence entry.
86
+ """
87
+ file = f.get("file")
88
+ line = f.get("line")
89
+ if file is None or line is None:
90
+ for ev in f.get("evidence") or []:
91
+ if not isinstance(ev, dict):
92
+ continue
93
+ if file is None and ev.get("path"):
94
+ file = ev.get("path")
95
+ if line is None:
96
+ detail = str(ev.get("detail", ""))
97
+ if detail.startswith("line:"):
98
+ suffix = detail.split("line:", 1)[1].strip()
99
+ line = int(suffix) if suffix.isdigit() else suffix or None
100
+ if file is not None and line is not None:
101
+ break
102
+ return file, line
103
+
104
+
105
+ def _compact_finding(f: dict) -> dict:
106
+ """Project a finding down to the compact fields shown in the summary.
107
+
108
+ Works for both the flat test schema (``file``/``line``/``message``) and
109
+ the real forensic schema (``evidence``/``summary``/``title``).
110
+ """
111
+ file, line = _finding_location(f)
112
+ message = f.get("message") or f.get("summary") or f.get("title")
113
+ return {
114
+ "check_id": f.get("check_id"),
115
+ "severity": f.get("severity"),
116
+ "file": file,
117
+ "line": line,
118
+ "message": message,
119
+ }
120
+
121
+
122
+ def _build_forensic_summary(result: dict) -> dict:
123
+ """Build a compact, context-budget-friendly summary of an audit result.
124
+
125
+ Instead of every finding, returns total counts, a per-severity breakdown,
126
+ a per-check_id breakdown (top ``_BY_CHECK_ID_CAP`` by count) and the top
127
+ ``_TOP_FINDINGS_CAP`` findings drawn from the highest severity present.
128
+
129
+ Args:
130
+ result: The raw ``run_forensic_audit`` result dict (``findings``,
131
+ ``exit_code``, ``meta``, ``errors``).
132
+
133
+ Returns:
134
+ A dict with keys: ``total``, ``exit_code``, ``by_severity``,
135
+ ``by_check_id``, ``top_findings``, ``meta``, ``errors``, ``hint``.
136
+ """
137
+ findings = result.get("findings") or []
138
+ total = len(findings)
139
+
140
+ # by_severity: lowercase keys so counts are stable regardless of input case.
141
+ sev_counter: Counter[str] = Counter()
142
+ for f in findings:
143
+ sev = str(f.get("severity", "")).upper()
144
+ sev_counter[sev] += 1
145
+ by_severity = {
146
+ "high": sev_counter.get("HIGH", 0) + sev_counter.get("CRITICAL", 0),
147
+ "medium": sev_counter.get("MEDIUM", 0),
148
+ "low": sev_counter.get("LOW", 0) + sev_counter.get("INFO", 0),
149
+ }
150
+
151
+ # by_check_id: top N check ids by count.
152
+ check_counter: Counter[str] = Counter(
153
+ str(f.get("check_id", "unknown")) for f in findings
154
+ )
155
+ by_check_id = dict(check_counter.most_common(_BY_CHECK_ID_CAP))
156
+
157
+ # top_findings: drawn from the highest severity actually present.
158
+ top_severity: str | None = None
159
+ for sev in _SEVERITY_ORDER:
160
+ if sev_counter.get(sev, 0) > 0:
161
+ top_severity = sev
162
+ break
163
+ top_findings: list[dict] = []
164
+ if top_severity is not None:
165
+ for f in findings:
166
+ if str(f.get("severity", "")).upper() == top_severity:
167
+ top_findings.append(_compact_finding(f))
168
+ if len(top_findings) >= _TOP_FINDINGS_CAP:
169
+ break
170
+
171
+ hint = (
172
+ "Compact summary. For the full finding list call get_forensic_results "
173
+ "with view='full' (supports severity= and check_id= filters + paging)."
174
+ )
175
+
176
+ return {
177
+ "total": total,
178
+ "exit_code": result.get("exit_code", 2),
179
+ "by_severity": by_severity,
180
+ "by_check_id": by_check_id,
181
+ "top_findings": top_findings,
182
+ # `findings` mirrors top_findings (the compact subset actually shown in
183
+ # the summary). Present so summary payloads still expose a findings
184
+ # list; for the complete, unbounded list use view='full'.
185
+ "findings": top_findings,
186
+ "meta": result.get("meta") or {},
187
+ "errors": result.get("errors") or [],
188
+ "hint": hint,
189
+ }
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Serialisation / truncation helpers
194
+ # ---------------------------------------------------------------------------
195
+
196
+ def _paginate_json(data: Any, page: int, page_size_chars: int) -> dict:
197
+ """Serialise *data* to JSON and return a page slice with metadata."""
198
+ full_json = json.dumps(data, default=str, indent=2)
199
+ total = len(full_json)
200
+ start_char = page * page_size_chars
201
+ end_char = start_char + page_size_chars
202
+ return {
203
+ "payload": full_json[start_char:end_char],
204
+ "truncated": end_char < total,
205
+ "total_chars": total,
206
+ "page": page,
207
+ "total_pages": (total + page_size_chars - 1) // page_size_chars,
208
+ }
209
+
210
+
211
+ def _cap_findings(result: dict, max_findings: int = 200) -> dict:
212
+ """Cap the findings list to avoid unbounded blobs.
213
+
214
+ Adds "findings_truncated" and "total_findings_before_cap" to meta when
215
+ the list was cut.
216
+ """
217
+ findings = result.get("findings", [])
218
+ if len(findings) <= max_findings:
219
+ return result
220
+ result = dict(result)
221
+ result["findings"] = findings[:max_findings]
222
+ meta = dict(result.get("meta") or {})
223
+ meta["findings_truncated"] = True
224
+ meta["total_findings_before_cap"] = len(findings)
225
+ result["meta"] = meta
226
+ return result
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # MCP tools
231
+ # ---------------------------------------------------------------------------
232
+
233
+ @mcp.tool()
234
+ def start_forensic_audit(
235
+ path: str = "",
236
+ gates: str = "",
237
+ severity: str = "LOW",
238
+ all_languages: bool = True,
239
+ max_files: int = 800,
240
+ ) -> dict:
241
+ """Start a background forensic audit job for the given project path.
242
+
243
+ Args:
244
+ path: Absolute path to the project root directory. When
245
+ empty/omitted the project root is auto-detected by
246
+ walking up from the current working directory looking
247
+ for a ``.git`` / ``pyproject.toml`` / ``package.json``
248
+ marker (falling back to cwd). The chosen directory is
249
+ returned as ``resolved_path``.
250
+ gates: Comma-separated list of gate check_ids to run.
251
+ Empty string means run all applicable gates.
252
+ severity: Minimum severity to include: LOW | MEDIUM | HIGH | CRITICAL.
253
+ all_languages: Reserved; currently always True.
254
+ max_files: Anti-hang ceiling on the collected source-file count
255
+ (default 800). Above it the audit is SKIPPED (gates do
256
+ NOT run) and get_forensic_results reports
257
+ meta.skipped_reason="too_many_files" with top_subdirs +
258
+ a suggestion to scan a submodule; raise it to force a full
259
+ scan of a huge repo.
260
+
261
+ Returns:
262
+ {"job_id": str | None, "status": "running" | "busy",
263
+ "resolved_path": str, ...}
264
+ When status is "busy", retry later - the server is at max concurrent jobs.
265
+
266
+ Resource note:
267
+ run_forensic_audit always uses workers=1 internally. This server
268
+ enforces an additional cap of 2 concurrent jobs.
269
+ """
270
+ # Auto-target: resolve only when no explicit path was given.
271
+ if path:
272
+ resolved_path = path
273
+ else:
274
+ resolved_path = _paths._resolve_project_root(None)
275
+
276
+ gates_list = [g.strip() for g in gates.split(",") if g.strip()] if gates else None
277
+
278
+ def _run() -> dict:
279
+ # run_forensic_audit uses workers=1 internally (verified in source).
280
+ # No additional workers parameter is accepted.
281
+ return run_forensic_audit(
282
+ Path(resolved_path),
283
+ gates=gates_list,
284
+ severity=severity,
285
+ all_languages=all_languages,
286
+ max_files=max_files,
287
+ )
288
+
289
+ # project_dir enables disk-backed persistence so results survive a server
290
+ # restart; get_forensic_status/results then resolve by job_id from disk.
291
+ started = _jobs.start(_run, project_dir=resolved_path)
292
+ started["resolved_path"] = resolved_path
293
+ return started
294
+
295
+
296
+ @mcp.tool()
297
+ def get_forensic_status(job_id: str) -> dict:
298
+ """Poll the status of a forensic audit job.
299
+
300
+ Args:
301
+ job_id: Job ID returned by start_forensic_audit.
302
+
303
+ Returns:
304
+ {"job_id": str, "status": "running" | "done" | "error" | "cancelled" | "not_found"}
305
+ """
306
+ return _jobs.status(job_id)
307
+
308
+
309
+ @mcp.tool()
310
+ def get_forensic_results(
311
+ job_id: str,
312
+ view: str = "summary",
313
+ severity: str = "",
314
+ check_id: str = "",
315
+ page: int = 0,
316
+ page_size_chars: int = OUTPUT_CHAR_LIMIT,
317
+ max_findings: int = 200,
318
+ ) -> dict:
319
+ """Retrieve results of a completed forensic audit.
320
+
321
+ Two views:
322
+ * ``view='summary'`` (default) - a compact summary (total counts,
323
+ by_severity, by_check_id, top HIGH findings) that fits comfortably
324
+ in the MCP context budget. Use this first.
325
+ * ``view='full'`` - the full findings list, capped and paginated.
326
+ Supports ``severity=`` and ``check_id=`` filters to drill in.
327
+
328
+ Args:
329
+ job_id: Job ID returned by start_forensic_audit.
330
+ view: "summary" (default) or "full".
331
+ severity: (full view) keep only findings of this severity, e.g.
332
+ "HIGH". Empty = no filter.
333
+ check_id: (full view) keep only findings with this check_id.
334
+ Empty = no filter.
335
+ page: Zero-based page index (full view).
336
+ page_size_chars: Max chars per page (default 80 000 ≈ 25 k tokens).
337
+ max_findings: Cap on the findings list before pagination (default 200).
338
+
339
+ Returns:
340
+ dict with keys:
341
+ "job_id", "status", "view",
342
+ "exit_code" (0=clean, 1=high/critical findings, 2=error),
343
+ "payload" (JSON string - summary dict or full result),
344
+ "truncated" (bool), "total_chars", "page", "total_pages".
345
+ """
346
+ r = _jobs.result(job_id)
347
+ status = r.get("status")
348
+
349
+ if status in ("running", "not_found"):
350
+ return {"job_id": job_id, "status": status, "payload": None,
351
+ "truncated": False, "total_chars": 0}
352
+
353
+ if status == "cancelled":
354
+ return {"job_id": job_id, "status": "cancelled", "payload": None,
355
+ "truncated": False, "total_chars": 0}
356
+
357
+ if status == "error":
358
+ return {"job_id": job_id, "status": "error",
359
+ "error": r.get("error"), "payload": None,
360
+ "truncated": False, "total_chars": 0}
361
+
362
+ # status == "done"
363
+ audit_result = r.get("result") or {}
364
+ if not isinstance(audit_result, dict):
365
+ audit_result = {}
366
+ exit_code = audit_result.get("exit_code", 2)
367
+
368
+ if view == "full":
369
+ # Apply optional severity / check_id filters before capping.
370
+ findings = audit_result.get("findings") or []
371
+ if severity:
372
+ sev_u = severity.upper()
373
+ findings = [f for f in findings if str(f.get("severity", "")).upper() == sev_u]
374
+ if check_id:
375
+ findings = [f for f in findings if f.get("check_id") == check_id]
376
+ filtered = dict(audit_result)
377
+ filtered["findings"] = findings
378
+
379
+ capped = _cap_findings(filtered, max_findings=max_findings)
380
+ page_data = _paginate_json(capped, page=page, page_size_chars=page_size_chars)
381
+ return {
382
+ "job_id": job_id,
383
+ "status": "done",
384
+ "view": "full",
385
+ "exit_code": exit_code,
386
+ **page_data,
387
+ }
388
+
389
+ # Default: compact summary view.
390
+ summary = _build_forensic_summary(audit_result)
391
+ page_data = _paginate_json(summary, page=page, page_size_chars=page_size_chars)
392
+ return {
393
+ "job_id": job_id,
394
+ "status": "done",
395
+ "view": "summary",
396
+ "exit_code": exit_code,
397
+ **page_data,
398
+ }
399
+
400
+
401
+ @mcp.tool()
402
+ def cancel_forensic_audit(job_id: str) -> dict:
403
+ """Cancel a running forensic audit job.
404
+
405
+ Args:
406
+ job_id: Job ID returned by start_forensic_audit.
407
+
408
+ Returns:
409
+ {"job_id": str, "cancelled": bool, ...}
410
+ """
411
+ return _jobs.cancel(job_id)
412
+
413
+
414
+ def main() -> None:
415
+ mcp.run()
416
+
417
+
418
+ if __name__ == "__main__":
419
+ main()