deadpush 0.2.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.
deadpush/mcp_server.py ADDED
@@ -0,0 +1,1061 @@
1
+ """
2
+ Model Context Protocol (MCP) server for deadpush guardrails.
3
+
4
+ Any MCP-compatible agent (Cursor, Claude Desktop, Claude Code, etc.) can
5
+ connect and call deadpush's capabilities as native tools.
6
+
7
+ All tools return structured JSON so agents can parse results programmatically.
8
+ Transport: stdio (newline-delimited JSON-RPC 2.0)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import difflib
14
+ import json
15
+ import re
16
+ import sys
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+ from typing import Any, Callable
20
+
21
+ from .intercept import InterceptDaemon, GuardrailResult, Violation
22
+ from .intercept import _run_guardrails, _check_sensitive_write, _check_destructive_changes, STAGING_DIR, FEEDBACK_DIR
23
+ from .config import load_config
24
+ from .rules import RuntimeConfig
25
+
26
+
27
+ MCP_PROTOCOL_VERSION = "2024-11-05"
28
+
29
+
30
+ def _ok(data: Any = None, summary: str = "") -> dict[str, Any]:
31
+ return {
32
+ "content": [{"type": "text", "text": json.dumps({
33
+ "success": True,
34
+ "summary": summary,
35
+ "data": data,
36
+ }, indent=2, default=str)}],
37
+ }
38
+
39
+
40
+ def _err(message: str) -> dict[str, Any]:
41
+ return {
42
+ "isError": True,
43
+ "content": [{"type": "text", "text": json.dumps({
44
+ "success": False,
45
+ "error": message,
46
+ "data": None,
47
+ }, indent=2)}],
48
+ }
49
+
50
+
51
+ def _text(text: str) -> dict[str, Any]:
52
+ return {
53
+ "content": [{"type": "text", "text": text}],
54
+ }
55
+
56
+
57
+ class McpServer:
58
+ """MCP server exposing all deadpush capabilities as agent-native tools."""
59
+
60
+ def __init__(self, repo_root: str | Path | None = None):
61
+ self.repo_root = Path(repo_root).resolve() if repo_root else Path.cwd().resolve()
62
+ self.config = load_config(explicit_root=self.repo_root)
63
+ self.runtime = RuntimeConfig(self.repo_root)
64
+ self.daemon = InterceptDaemon(self.repo_root, self.config)
65
+ self.daemon.runtime = self.runtime
66
+ self._stdio_broken = False
67
+
68
+ # -----------------------------------------------------------------------
69
+ # Feedback helpers
70
+ # -----------------------------------------------------------------------
71
+ def _count_unacknowledged_feedback(self) -> int:
72
+ feedback_dir = self.repo_root / FEEDBACK_DIR
73
+ count = 0
74
+ if feedback_dir.exists():
75
+ for f in feedback_dir.glob("*.json"):
76
+ try:
77
+ data = json.loads(f.read_text(encoding="utf-8"))
78
+ if not data.get("acknowledged", False):
79
+ count += 1
80
+ except Exception:
81
+ pass
82
+ return count
83
+
84
+ def _safe_call(self, handler: Callable[[dict[str, Any]], dict[str, Any]], args: dict[str, Any]) -> dict[str, Any]:
85
+ try:
86
+ return handler(args)
87
+ except Exception as e:
88
+ return _err(str(e))
89
+
90
+ def _send_error(self, msg_id: Any, code: int, message: str):
91
+ if self._stdio_broken:
92
+ return
93
+ response = {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
94
+ try:
95
+ sys.stdout.write(json.dumps(response) + "\n")
96
+ sys.stdout.flush()
97
+ except (BrokenPipeError, OSError):
98
+ self._stdio_broken = True
99
+
100
+ # -----------------------------------------------------------------------
101
+ # Tool definitions
102
+ # -----------------------------------------------------------------------
103
+ def _tools_list(self) -> list[dict[str, Any]]:
104
+ return [
105
+ # --- Write / Check ---
106
+ {
107
+ "name": "write_file",
108
+ "description": "Write a file through deadpush guardrails. Checks security, prompt injection, secrets, layer violations. Safe files are written; dangerous files are blocked with feedback. Returns structured JSON with violations if any.",
109
+ "inputSchema": {
110
+ "type": "object",
111
+ "properties": {
112
+ "path": {"type": "string", "description": "Relative path (e.g. src/api.py)"},
113
+ "content": {"type": "string", "description": "File content"},
114
+ },
115
+ "required": ["path", "content"],
116
+ },
117
+ },
118
+ {
119
+ "name": "check_file",
120
+ "description": "Preview whether file content would pass guardrails. Returns violations without writing anything.",
121
+ "inputSchema": {
122
+ "type": "object",
123
+ "properties": {
124
+ "path": {"type": "string"},
125
+ "content": {"type": "string"},
126
+ },
127
+ "required": ["path", "content"],
128
+ },
129
+ },
130
+ # --- Scan ---
131
+ {
132
+ "name": "scan",
133
+ "description": "Run full deadpush analysis. Returns dead symbols, debris, test issues, stale docs, layer violations, security boundaries, and complexity alerts as structured JSON.",
134
+ "inputSchema": {
135
+ "type": "object",
136
+ "properties": {},
137
+ },
138
+ },
139
+ {
140
+ "name": "get_dead_symbols",
141
+ "description": "Get all unreachable/dead code symbols detected by reachability analysis.",
142
+ "inputSchema": {"type": "object", "properties": {}},
143
+ },
144
+ {
145
+ "name": "get_debris",
146
+ "description": "Get all debris items (AI artifacts, temp files, context dumps, chat exports).",
147
+ "inputSchema": {"type": "object", "properties": {}},
148
+ },
149
+ {
150
+ "name": "get_test_issues",
151
+ "description": "Get test quality issues (no-assertion tests, tautologies, empty tests).",
152
+ "inputSchema": {"type": "object", "properties": {}},
153
+ },
154
+ {
155
+ "name": "get_stale_docs",
156
+ "description": "Get stale/mismatched documentation (docstring params that don't match signatures).",
157
+ "inputSchema": {"type": "object", "properties": {}},
158
+ },
159
+ {
160
+ "name": "get_layer_violations",
161
+ "description": "Get architecture layer import violations.",
162
+ "inputSchema": {"type": "object", "properties": {}},
163
+ },
164
+ {
165
+ "name": "get_security_boundaries",
166
+ "description": "Get untested security-sensitive operations (eval, subprocess, crypto, SQL, etc.).",
167
+ "inputSchema": {"type": "object", "properties": {
168
+ "min_severity": {"type": "string", "description": "Minimum severity: low, medium, high, critical"},
169
+ }},
170
+ },
171
+ {
172
+ "name": "get_complexity_alerts",
173
+ "description": "Get files with significant complexity increases from baseline.",
174
+ "inputSchema": {"type": "object", "properties": {
175
+ "min_pct": {"type": "number", "description": "Minimum percentage increase to report (default 20)"},
176
+ }},
177
+ },
178
+ # --- Clean ---
179
+ {
180
+ "name": "clean",
181
+ "description": "Clean dead code and debris. By default uses safe mode (archives with explanations). Returns list of items cleaned.",
182
+ "inputSchema": {
183
+ "type": "object",
184
+ "properties": {
185
+ "mode": {"type": "string", "description": "cleanup mode: safe (archive, default), dry_run (preview), force (delete)"},
186
+ },
187
+ },
188
+ },
189
+ # --- Quarantine ---
190
+ {
191
+ "name": "quarantine_list",
192
+ "description": "List quarantined files with reasons and original paths.",
193
+ "inputSchema": {
194
+ "type": "object",
195
+ "properties": {
196
+ "limit": {"type": "number", "description": "Max entries (default 20)"},
197
+ },
198
+ },
199
+ },
200
+ {
201
+ "name": "quarantine_restore",
202
+ "description": "Restore a quarantined file to its original location.",
203
+ "inputSchema": {
204
+ "type": "object",
205
+ "properties": {
206
+ "name": {"type": "string", "description": "Quarantined filename (from quarantine_list)"},
207
+ },
208
+ "required": ["name"],
209
+ },
210
+ },
211
+ # --- Feedback ---
212
+ {
213
+ "name": "get_feedback",
214
+ "description": "Read recent guardrail feedback entries. Shows what was blocked and why.",
215
+ "inputSchema": {
216
+ "type": "object",
217
+ "properties": {
218
+ "limit": {"type": "number", "description": "Max entries (default 5)"},
219
+ },
220
+ },
221
+ },
222
+ {
223
+ "name": "get_recent_feedback",
224
+ "description": "Read unacknowledged guardrail feedback entries. Filtered to show only feedback the agent has not yet acknowledged.",
225
+ "inputSchema": {
226
+ "type": "object",
227
+ "properties": {
228
+ "limit": {"type": "number", "description": "Max entries (default 10)"},
229
+ },
230
+ },
231
+ },
232
+ {
233
+ "name": "acknowledge_feedback",
234
+ "description": "Mark a feedback entry as acknowledged. The agent calls this after reading and addressing the feedback. Use the safe_name from get_recent_feedback (e.g. 'src__bad.py').",
235
+ "inputSchema": {
236
+ "type": "object",
237
+ "properties": {
238
+ "name": {"type": "string", "description": "Feedback filename (safe_name, e.g. src__bad.py)"},
239
+ },
240
+ "required": ["name"],
241
+ },
242
+ },
243
+ {
244
+ "name": "retry_write",
245
+ "description": "Submit corrected content for a previously blocked file. Runs guardrails on the new content. If it passes, writes to the real path and acknowledges the previous feedback. If it still fails, quarantines and writes new feedback.",
246
+ "inputSchema": {
247
+ "type": "object",
248
+ "properties": {
249
+ "path": {"type": "string", "description": "Relative path (e.g. src/api.py)"},
250
+ "content": {"type": "string", "description": "Corrected file content"},
251
+ },
252
+ "required": ["path", "content"],
253
+ },
254
+ },
255
+ # --- Status ---
256
+ {
257
+ "name": "get_status",
258
+ "description": "Get current guardrail configuration, available tools, and directory paths.",
259
+ "inputSchema": {"type": "object", "properties": {}},
260
+ },
261
+ {
262
+ "name": "get_safety_score",
263
+ "description": "Get latest Safety Score from the background AI Agent Guardian.",
264
+ "inputSchema": {"type": "object", "properties": {}},
265
+ },
266
+ # --- Configuration tools (agent self-service) ---
267
+ {
268
+ "name": "get_runtime_config",
269
+ "description": "View the current runtime configuration: allowed patterns, ignored paths, guardrail levels, and all settings.",
270
+ "inputSchema": {"type": "object", "properties": {}},
271
+ },
272
+ {
273
+ "name": "add_allowed_pattern",
274
+ "description": "Add a regex pattern to the allowlist. When a guardrail match falls under an allowed pattern, it is skipped. Use this to whitelist known-safe code patterns.",
275
+ "inputSchema": {
276
+ "type": "object",
277
+ "properties": {
278
+ "pattern": {"type": "string", "description": "Regex pattern to allow (e.g. r'safe_eval_data')"},
279
+ "description": {"type": "string", "description": "Why this pattern is safe (optional)"},
280
+ },
281
+ "required": ["pattern"],
282
+ },
283
+ },
284
+ {
285
+ "name": "remove_allowed_pattern",
286
+ "description": "Remove a pattern from the allowlist by its regex string.",
287
+ "inputSchema": {
288
+ "type": "object",
289
+ "properties": {
290
+ "pattern": {"type": "string", "description": "Regex pattern to remove"},
291
+ },
292
+ "required": ["pattern"],
293
+ },
294
+ },
295
+ {
296
+ "name": "ignore_path",
297
+ "description": "Add a file path to the ignore list. Guardrails will skip this file entirely.",
298
+ "inputSchema": {
299
+ "type": "object",
300
+ "properties": {
301
+ "path": {"type": "string", "description": "Relative path to ignore (e.g. tests/fixtures/generated.py)"},
302
+ },
303
+ "required": ["path"],
304
+ },
305
+ },
306
+ {
307
+ "name": "set_guardrail_level",
308
+ "description": "Change the severity level for a guardrail category. Valid levels: off, warn, block.",
309
+ "inputSchema": {
310
+ "type": "object",
311
+ "properties": {
312
+ "category": {"type": "string", "description": "Guardrail category: prompt_injection, secret, security, layer, debris"},
313
+ "level": {"type": "string", "description": "Level: off (disable), warn (report only), block (prevent write)"},
314
+ },
315
+ "required": ["category", "level"],
316
+ },
317
+ },
318
+ {
319
+ "name": "reset_runtime_config",
320
+ "description": "Reset all runtime config to defaults. Clears all allowed patterns, ignored paths, and guardrail level overrides.",
321
+ "inputSchema": {"type": "object", "properties": {}},
322
+ },
323
+ # --- Diff / Sensitive write tools ---
324
+ {
325
+ "name": "get_write_diff",
326
+ "description": "Preview the diff and guardrail violations for a proposed write. Returns unified diff + would_block + violations. No file is written.",
327
+ "inputSchema": {
328
+ "type": "object",
329
+ "properties": {
330
+ "path": {"type": "string", "description": "Relative path (e.g. src/api.py)"},
331
+ "content": {"type": "string", "description": "Proposed file content"},
332
+ },
333
+ "required": ["path", "content"],
334
+ },
335
+ },
336
+ {
337
+ "name": "allow_sensitive_write",
338
+ "description": "Explicitly opt in to writing a sensitive config file (CI/CD, deployment, Docker, etc.). Adds the path to the runtime allowlist so the next write passes the sensitive file guardrail.",
339
+ "inputSchema": {
340
+ "type": "object",
341
+ "properties": {
342
+ "path": {"type": "string", "description": "Relative path to allow (e.g. .github/workflows/deploy.yml)"},
343
+ },
344
+ "required": ["path"],
345
+ },
346
+ },
347
+ # --- Agent-as-Adjudicator ---
348
+ {
349
+ "name": "adjudicate_finding",
350
+ "description": "Adjudicate a guardrail finding. Presents the finding with structured uncertainty for the agent to adjudicate. Returns a scoring rubric. Call learn_false_positive if the agent determines the finding is a false positive.",
351
+ "inputSchema": {
352
+ "type": "object",
353
+ "properties": {
354
+ "category": {"type": "string", "description": "Category: security, secret, prompt_injection, layer, debris, sensitive, destructive, dependency"},
355
+ "description": {"type": "string", "description": "The full violation description text"},
356
+ "file_path": {"type": "string", "description": "Relative path of the flagged file"},
357
+ "line": {"type": "number", "description": "Line number of the violation"},
358
+ "severity": {"type": "string", "description": "Severity: low, medium, high, critical"},
359
+ "uncertainty": {"type": "string", "description": "Why this flag might be wrong (contextual notes)"},
360
+ },
361
+ "required": ["category", "description", "file_path"],
362
+ },
363
+ },
364
+ {
365
+ "name": "learn_false_positive",
366
+ "description": "Teach deadpush that a pattern is a false positive. After verifying a finding manually, call this to persist the pattern so it is auto-suppressed in future guardrail checks.",
367
+ "inputSchema": {
368
+ "type": "object",
369
+ "properties": {
370
+ "category": {"type": "string", "description": "Guardrail category"},
371
+ "pattern": {"type": "string", "description": "The violation description text (or pattern) to suppress"},
372
+ "reason": {"type": "string", "description": "Why this is a false positive (for future reference)"},
373
+ },
374
+ "required": ["category", "pattern", "reason"],
375
+ },
376
+ },
377
+ # --- Test Verification ---
378
+ {
379
+ "name": "verify_write",
380
+ "description": "Write a file through guardrails AND run the relevant test file. If tests pass, the file is written. If tests fail, the file is NOT written and the agent receives structured test output. Use this when you want to verify your change doesn't break existing tests.",
381
+ "inputSchema": {
382
+ "type": "object",
383
+ "properties": {
384
+ "path": {"type": "string", "description": "Relative path (e.g. src/api.py)"},
385
+ "content": {"type": "string", "description": "File content"},
386
+ },
387
+ "required": ["path", "content"],
388
+ },
389
+ },
390
+ {
391
+ "name": "get_test_results",
392
+ "description": "Get recent test verification results. Returns structured test output from the last N verify_write calls.",
393
+ "inputSchema": {
394
+ "type": "object",
395
+ "properties": {
396
+ "limit": {"type": "number", "description": "Max entries (default 10)"},
397
+ },
398
+ },
399
+ },
400
+ ]
401
+
402
+ # -----------------------------------------------------------------------
403
+ # Tool handlers — all return structured JSON
404
+ # -----------------------------------------------------------------------
405
+ def _run_analysis(self) -> dict[str, Any]:
406
+ """Run full analysis and return structured results."""
407
+ from .cli import _run_full_analysis
408
+ from concurrent.futures import ThreadPoolExecutor
409
+ with ThreadPoolExecutor(max_workers=1) as pool:
410
+ future = pool.submit(_run_full_analysis, self.config)
411
+ try:
412
+ return future.result(timeout=60)
413
+ except TimeoutError:
414
+ return _err("Analysis timed out after 60s")
415
+ except Exception as e:
416
+ return _err(f"Analysis failed: {e}")
417
+
418
+ def _tool_write_file(self, args: dict[str, Any]) -> dict[str, Any]:
419
+ path = args.get("path", "")
420
+ content = args.get("content", "")
421
+ if not path:
422
+ return _err("path is required")
423
+ result = self.daemon.write_file(path, content)
424
+ if result.allowed:
425
+ return _ok({"path": path, "status": "allowed", "violations": []}, "File approved.")
426
+ return _ok({
427
+ "path": path,
428
+ "status": "blocked",
429
+ "violations": [{"category": v.category, "description": v.description, "line": v.line, "severity": v.severity} for v in result.violations],
430
+ }, f"File blocked: {len(result.violations)} violation(s).")
431
+
432
+ def _tool_verify_write(self, args: dict[str, Any]) -> dict[str, Any]:
433
+ path = args.get("path", "")
434
+ content = args.get("content", "")
435
+ if not path:
436
+ return _err("path is required")
437
+ if not content:
438
+ return _err("content is required")
439
+
440
+ # Step 1: Run guardrails
441
+ result = self.daemon.write_file(path, content)
442
+ if not result.allowed:
443
+ return _ok({
444
+ "path": path,
445
+ "status": "blocked_by_guardrails",
446
+ "violations": [{"category": v.category, "description": v.description, "line": v.line, "severity": v.severity} for v in result.violations],
447
+ "test_result": None,
448
+ }, f"File blocked by guardrails: {len(result.violations)} violation(s).")
449
+
450
+ # Step 2: Run test verification
451
+ from .verifier import TestVerifier
452
+ verifier = TestVerifier(self.config)
453
+ verification = verifier.verify_write(path, content)
454
+
455
+ if not verification["verifiable"]:
456
+ # No test file found — file is already written, just report it
457
+ return _ok({
458
+ "path": path,
459
+ "status": "allowed",
460
+ "violations": [],
461
+ "test_result": None,
462
+ "note": verification["reason"],
463
+ }, "File written (no test file found for verification).")
464
+
465
+ test_result = verification["test_result"]
466
+ if test_result["passed"]:
467
+ return _ok({
468
+ "path": path,
469
+ "status": "allowed",
470
+ "violations": [],
471
+ "test_result": test_result,
472
+ }, f"Tests passed ({test_result['test_file']}). File written.")
473
+
474
+ # Tests failed — quarantine the written file + restore from git
475
+ self._quarantine_and_restore(path, result)
476
+ return _ok({
477
+ "path": path,
478
+ "status": "test_failure",
479
+ "violations": [{"category": "test_failure", "description": f"Tests failed: {test_result['test_file']}", "line": 0, "severity": "high"}],
480
+ "test_result": test_result,
481
+ }, f"Tests FAILED ({test_result['test_file']}). File quarantined and restored.")
482
+
483
+ def _quarantine_and_restore(self, rel_path: str, guardrail_result: GuardrailResult):
484
+ """Quarantine a written file and restore it from git."""
485
+ from .intercept import QUARANTINE_DIR, FEEDBACK_DIR, _write_feedback
486
+ dest = self.repo_root / rel_path
487
+ if not dest.exists():
488
+ return
489
+
490
+ # Move to quarantine
491
+ quarantine_dir = self.repo_root / QUARANTINE_DIR
492
+ quarantine_dir.mkdir(parents=True, exist_ok=True)
493
+ safe_name = rel_path.replace("/", "__").replace("\\", "__")
494
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
495
+ quarantined = quarantine_dir / f"{timestamp}__{safe_name}"
496
+ try:
497
+ import shutil
498
+ shutil.move(str(dest), str(quarantined))
499
+ except Exception:
500
+ return
501
+
502
+ # Write feedback
503
+ feedback_result = GuardrailResult()
504
+ for v in guardrail_result.violations:
505
+ feedback_result.reject(v)
506
+ feedback_result.reject(Violation("test_failure", f"Tests failed, file quarantined to {quarantined.name}", 0, "high"))
507
+ _write_feedback(self.repo_root / FEEDBACK_DIR, rel_path, feedback_result)
508
+
509
+ # Restore from git
510
+ try:
511
+ import subprocess
512
+ git_show = subprocess.run(
513
+ ["git", "show", f"HEAD:{rel_path}"],
514
+ capture_output=True, text=True,
515
+ cwd=str(self.repo_root),
516
+ )
517
+ if git_show.returncode == 0 and git_show.stdout:
518
+ dest.write_text(git_show.stdout, encoding="utf-8")
519
+ except Exception:
520
+ pass
521
+
522
+ def _tool_get_test_results(self, args: dict[str, Any]) -> dict[str, Any]:
523
+ try:
524
+ limit = int(args.get("limit", 10))
525
+ except (ValueError, TypeError):
526
+ return _err("limit must be a number")
527
+ from .verifier import load_recent_results
528
+ entries = load_recent_results(self.config, limit=limit)
529
+ return _ok({"count": len(entries), "results": entries}, f"{len(entries)} test result(s).")
530
+
531
+ def _tool_check_file(self, args: dict[str, Any]) -> dict[str, Any]:
532
+ path = args.get("path", "")
533
+ content = args.get("content", "")
534
+ if not path:
535
+ return _err("path is required")
536
+ staging_dir = self.daemon.staging_dir
537
+ try:
538
+ staging_path = (staging_dir / path).resolve()
539
+ staging_path.parent.mkdir(parents=True, exist_ok=True)
540
+ staging_path.write_text(content, encoding="utf-8")
541
+ result = _run_guardrails(staging_path, staging_dir, self.config, self.runtime)
542
+ staging_path.unlink(missing_ok=True)
543
+ except Exception:
544
+ staging_path.unlink(missing_ok=True)
545
+ return _err("Could not process file")
546
+ violations = [{"category": v.category, "description": v.description, "line": v.line, "severity": v.severity} for v in result.violations]
547
+ return _ok({"path": path, "would_block": len(violations) > 0, "violations": violations},
548
+ f"{'Would be blocked' if violations else 'Would be approved'} ({len(violations)} violation(s)).")
549
+
550
+ def _tool_scan(self, args: dict[str, Any]) -> dict[str, Any]:
551
+ result = self._run_analysis()
552
+ return _ok({
553
+ "files_scanned": len(result.get("files", [])),
554
+ "dead_symbols_count": len(result.get("dead_symbols", [])),
555
+ "debris_count": len(result.get("debris", [])),
556
+ "test_issues_count": len(result.get("test_issues", [])),
557
+ "stale_docs_count": len(result.get("stale_docs", [])),
558
+ "layer_violations_count": len(result.get("layer_violations", [])),
559
+ "complexity_alerts_count": len(result.get("complexity_alerts", [])),
560
+ "security_untested_count": len(getattr(result.get("security_report"), "untested", [])),
561
+ }, "Scan complete.")
562
+
563
+ def _tool_get_dead_symbols(self, args: dict[str, Any]) -> dict[str, Any]:
564
+ result = self._run_analysis()
565
+ dead = result.get("dead_symbols", [])
566
+ symbols = []
567
+ for d in dead:
568
+ s = d.symbol if hasattr(d, "symbol") else d
569
+ symbols.append({
570
+ "name": getattr(s, "name", str(s)),
571
+ "file": getattr(s, "path", getattr(d, "path", "")),
572
+ "confidence": getattr(d, "confidence", 1.0),
573
+ "reason": getattr(d, "reason", ""),
574
+ })
575
+ return _ok({"count": len(symbols), "symbols": symbols}, f"{len(symbols)} dead symbols found.")
576
+
577
+ def _tool_get_debris(self, args: dict[str, Any]) -> dict[str, Any]:
578
+ result = self._run_analysis()
579
+ items = [{"file": d.path, "category": d.category, "description": d.description, "block_push": getattr(d, "block_push", False)}
580
+ for d in result.get("debris", [])]
581
+ return _ok({"count": len(items), "items": items}, f"{len(items)} debris items found.")
582
+
583
+ def _tool_get_test_issues(self, args: dict[str, Any]) -> dict[str, Any]:
584
+ result = self._run_analysis()
585
+ issues = [{"file": t.file, "line": t.line, "issue_type": t.issue_type, "description": t.description}
586
+ for t in result.get("test_issues", [])]
587
+ return _ok({"count": len(issues), "issues": issues}, f"{len(issues)} test quality issues found.")
588
+
589
+ def _tool_get_stale_docs(self, args: dict[str, Any]) -> dict[str, Any]:
590
+ result = self._run_analysis()
591
+ docs = [{"file": d.file, "line": d.line, "issue_type": d.issue_type, "description": d.description}
592
+ for d in result.get("stale_docs", [])]
593
+ return _ok({"count": len(docs), "issues": docs}, f"{len(docs)} stale documentation issues found.")
594
+
595
+ def _tool_get_layer_violations(self, args: dict[str, Any]) -> dict[str, Any]:
596
+ result = self._run_analysis()
597
+ violations = [{"file": v.file, "line": v.line, "description": v.description} for v in result.get("layer_violations", [])]
598
+ return _ok({"count": len(violations), "violations": violations}, f"{len(violations)} layer violations found.")
599
+
600
+ def _tool_get_security_boundaries(self, args: dict[str, Any]) -> dict[str, Any]:
601
+ result = self._run_analysis()
602
+ sec = result.get("security_report")
603
+ if not sec:
604
+ return _ok({"count": 0, "boundaries": []}, "No security data.")
605
+ untested = [{"file": s.file, "line": s.line, "category": s.category, "description": s.description}
606
+ for s in sec.untested]
607
+ tested = [{"file": s.file, "line": s.line, "category": s.category, "description": s.description}
608
+ for s in sec.tested]
609
+ return _ok({"count_untested": len(untested), "count_tested": len(tested), "untested": untested, "tested": tested},
610
+ f"{len(untested)} untested security boundaries.")
611
+
612
+ def _tool_get_complexity_alerts(self, args: dict[str, Any]) -> dict[str, Any]:
613
+ try:
614
+ min_pct = float(args.get("min_pct", 20))
615
+ except (ValueError, TypeError):
616
+ return _err("min_pct must be a number")
617
+ result = self._run_analysis()
618
+ alerts = [a for a in result.get("complexity_alerts", []) if a.get("pct_increase", 0) >= min_pct]
619
+ return _ok({"count": len(alerts), "alerts": alerts}, f"{len(alerts)} complexity alerts.")
620
+
621
+ def _tool_clean(self, args: dict[str, Any]) -> dict[str, Any]:
622
+ mode = args.get("mode", "safe")
623
+ result = self._run_analysis()
624
+ debris = result.get("debris", [])
625
+ dead = result.get("dead_symbols", [])
626
+ all_issues = debris + [d for d in dead]
627
+ if not all_issues:
628
+ return _ok({"cleaned": 0, "items": []}, "Nothing to clean.")
629
+
630
+ if mode == "dry_run":
631
+ return _ok({"would_clean": len(all_issues), "would_archive": len(all_issues) if mode != "force" else 0}, f"Would clean {len(all_issues)} items.")
632
+
633
+ import shutil
634
+ from datetime import datetime
635
+ archive_dir = self.repo_root / ".deadpush-archive" / datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
636
+ archive_dir.mkdir(parents=True, exist_ok=True)
637
+ moved = []
638
+ for item in all_issues:
639
+ item_path = getattr(item, "path", None) or getattr(getattr(item, "symbol", None), "path", None)
640
+ if not item_path:
641
+ continue
642
+ path = Path(item_path)
643
+ if path.exists():
644
+ dest = archive_dir / path.name
645
+ shutil.move(str(path), str(dest))
646
+ moved.append(str(path))
647
+ return _ok({"cleaned": len(moved), "archive_dir": str(archive_dir), "items": moved},
648
+ f"Cleaned {len(moved)} items to {archive_dir}.")
649
+
650
+ def _tool_quarantine_list(self, args: dict[str, Any]) -> dict[str, Any]:
651
+ try:
652
+ limit = int(args.get("limit", 20))
653
+ except (ValueError, TypeError):
654
+ return _err("limit must be a number")
655
+ try:
656
+ from .guard import QuarantineManager
657
+ qm = QuarantineManager(self.repo_root)
658
+ entries = qm.list_quarantined()[:limit]
659
+ return _ok({"count": len(entries), "entries": entries}, f"{len(entries)} quarantined files.")
660
+ except Exception as e:
661
+ return _err(str(e))
662
+
663
+ def _tool_quarantine_restore(self, args: dict[str, Any]) -> dict[str, Any]:
664
+ name = args.get("name", "")
665
+ if not name:
666
+ return _err("name is required")
667
+ try:
668
+ from .guard import QuarantineManager
669
+ qm = QuarantineManager(self.repo_root)
670
+ restored = qm.restore(name)
671
+ if restored:
672
+ return _ok({"restored": str(restored)}, f"Restored to {restored}.")
673
+ return _err(f"Could not restore '{name}'. Check quarantine_list for valid names.")
674
+ except Exception as e:
675
+ return _err(str(e))
676
+
677
+ def _tool_get_feedback(self, args: dict[str, Any]) -> dict[str, Any]:
678
+ try:
679
+ limit = int(args.get("limit", 5))
680
+ except (ValueError, TypeError):
681
+ return _err("limit must be a number")
682
+ feedback_dir = self.repo_root / FEEDBACK_DIR
683
+ entries = []
684
+ if feedback_dir.exists():
685
+ files = sorted(feedback_dir.glob("*.json"), reverse=True)[:limit]
686
+ for f in files:
687
+ try:
688
+ entries.append(json.loads(f.read_text(encoding="utf-8")))
689
+ except Exception:
690
+ pass
691
+ return _ok({"count": len(entries), "entries": entries}, f"{len(entries)} feedback entries.")
692
+
693
+ def _tool_get_recent_feedback(self, args: dict[str, Any]) -> dict[str, Any]:
694
+ try:
695
+ limit = int(args.get("limit", 10))
696
+ except (ValueError, TypeError):
697
+ return _err("limit must be a number")
698
+ feedback_dir = self.repo_root / FEEDBACK_DIR
699
+ entries = []
700
+ if feedback_dir.exists():
701
+ for f in sorted(feedback_dir.glob("*.json"), reverse=True):
702
+ try:
703
+ data = json.loads(f.read_text(encoding="utf-8"))
704
+ if not data.get("acknowledged", False):
705
+ entries.append(data)
706
+ if len(entries) >= limit:
707
+ break
708
+ except Exception:
709
+ pass
710
+ return _ok({"count": len(entries), "entries": entries}, f"{len(entries)} unacknowledged feedback entries.")
711
+
712
+ def _tool_acknowledge_feedback(self, args: dict[str, Any]) -> dict[str, Any]:
713
+ name = args.get("name", "")
714
+ if not name:
715
+ return _err("name is required")
716
+ if not isinstance(name, str):
717
+ return _err("name must be a string")
718
+ feedback_dir = self.repo_root / FEEDBACK_DIR
719
+ path = feedback_dir / name
720
+ if not path.exists():
721
+ path = feedback_dir / f"{name}.json"
722
+ if not path.exists():
723
+ return _err(f"Feedback entry '{name}' not found.")
724
+ try:
725
+ data = json.loads(path.read_text(encoding="utf-8"))
726
+ data["acknowledged"] = True
727
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
728
+ return _ok({"name": name, "file": data.get("file", "")}, f"Feedback '{name}' acknowledged.")
729
+ except Exception as e:
730
+ return _err(f"Could not acknowledge feedback: {e}")
731
+
732
+ def _tool_retry_write(self, args: dict[str, Any]) -> dict[str, Any]:
733
+ path = args.get("path", "")
734
+ content = args.get("content", "")
735
+ if not path:
736
+ return _err("path is required")
737
+ if not content:
738
+ return _err("content is required")
739
+ # Write to staging through the daemon's pipeline
740
+ result = self.daemon.write_file(path, content)
741
+ # Acknowledge any previous feedback for this file
742
+ safe_name = path.replace("/", "__").replace("\\", "__")
743
+ feedback_dir = self.repo_root / FEEDBACK_DIR
744
+ prev_path = feedback_dir / f"{safe_name}.json"
745
+ if prev_path.exists():
746
+ try:
747
+ prev = json.loads(prev_path.read_text(encoding="utf-8"))
748
+ prev["acknowledged"] = True
749
+ prev_path.write_text(json.dumps(prev, indent=2), encoding="utf-8")
750
+ except Exception:
751
+ pass
752
+ if result.allowed:
753
+ return _ok({
754
+ "path": path,
755
+ "status": "allowed",
756
+ "violations": [],
757
+ }, "Retry approved. File written successfully.")
758
+ return _ok({
759
+ "path": path,
760
+ "status": "blocked",
761
+ "violations": [{"category": v.category, "description": v.description, "line": v.line, "severity": v.severity} for v in result.violations],
762
+ }, f"Retry blocked: {len(result.violations)} violation(s) still present.")
763
+
764
+ def _tool_get_status(self, args: dict[str, Any]) -> dict[str, Any]:
765
+ agent_md = self.repo_root / "AGENT.md"
766
+ return _ok({
767
+ "repo_root": str(self.repo_root),
768
+ "staging_dir": str(self.repo_root / STAGING_DIR),
769
+ "feedback_dir": str(self.repo_root / FEEDBACK_DIR),
770
+ "agent_onboarding": str(agent_md) if agent_md.exists() else None,
771
+ "tools": [t["name"] for t in self._tools_list()],
772
+ }, "Server running.")
773
+
774
+ def _tool_get_safety_score(self, args: dict[str, Any]) -> dict[str, Any]:
775
+ pid_dir = Path.home() / ".deadpush"
776
+ log = pid_dir / "guardian.log"
777
+ score = "No background guardian running (start with deadpush protect --daemon)"
778
+ if log.exists():
779
+ try:
780
+ lines = log.read_text(errors="ignore").strip().splitlines()[-20:]
781
+ for ln in reversed(lines):
782
+ if "Safety" in ln or "Score:" in ln or "Status:" in ln:
783
+ score = ln.strip()
784
+ break
785
+ except Exception:
786
+ pass
787
+ return _ok({"safety_score": score}, "Safety score retrieved.")
788
+
789
+ def _tool_get_runtime_config(self, args: dict[str, Any]) -> dict[str, Any]:
790
+ return _ok(self.runtime.to_dict(), "Runtime configuration.")
791
+
792
+ def _tool_add_allowed_pattern(self, args: dict[str, Any]) -> dict[str, Any]:
793
+ pattern = args.get("pattern", "")
794
+ desc = args.get("description", "")
795
+ if not pattern:
796
+ return _err("pattern is required")
797
+ try:
798
+ self.runtime.add_allowed_pattern(pattern, desc)
799
+ return _ok({"pattern": pattern}, f"Pattern added: {pattern}")
800
+ except re.error as e:
801
+ return _err(f"Invalid regex: {e}")
802
+
803
+ def _tool_remove_allowed_pattern(self, args: dict[str, Any]) -> dict[str, Any]:
804
+ pattern = args.get("pattern", "")
805
+ if not pattern:
806
+ return _err("pattern is required")
807
+ if self.runtime.remove_allowed_pattern(pattern):
808
+ return _ok({"pattern": pattern}, f"Pattern removed: {pattern}")
809
+ return _err(f"Pattern not found: {pattern}")
810
+
811
+ def _tool_ignore_path(self, args: dict[str, Any]) -> dict[str, Any]:
812
+ path = args.get("path", "")
813
+ if not path:
814
+ return _err("path is required")
815
+ self.runtime.ignore_path(path)
816
+ return _ok({"path": path}, f"Ignored path: {path}")
817
+
818
+ def _tool_set_guardrail_level(self, args: dict[str, Any]) -> dict[str, Any]:
819
+ category = args.get("category", "")
820
+ level = args.get("level", "")
821
+ if not category or not level:
822
+ return _err("category and level are required")
823
+ try:
824
+ self.runtime.set_guardrail_level(category, level)
825
+ return _ok({"category": category, "level": level}, f"Guardrail '{category}' set to '{level}'.")
826
+ except ValueError as e:
827
+ return _err(str(e))
828
+
829
+ def _tool_reset_runtime_config(self, args: dict[str, Any]) -> dict[str, Any]:
830
+ self.runtime.reset()
831
+ return _ok({}, "Runtime config reset to defaults.")
832
+
833
+ def _tool_get_write_diff(self, args: dict[str, Any]) -> dict[str, Any]:
834
+ path = args.get("path", "")
835
+ content = args.get("content", "")
836
+ if not path:
837
+ return _err("path is required")
838
+ staging_dir = self.daemon.staging_dir
839
+ staging_path = (staging_dir / path).resolve()
840
+ staging_path.parent.mkdir(parents=True, exist_ok=True)
841
+ try:
842
+ staging_path.write_text(content, encoding="utf-8")
843
+ result = _run_guardrails(staging_path, staging_dir, self.config, self.runtime)
844
+ staging_path.unlink(missing_ok=True)
845
+ except Exception:
846
+ staging_path.unlink(missing_ok=True)
847
+ return _err("Could not process file")
848
+
849
+ # Compute diff against existing file
850
+ dest = (self.repo_root / path).resolve()
851
+ diff_text = ""
852
+ if dest.exists():
853
+ try:
854
+ old = dest.read_text(encoding="utf-8", errors="ignore")
855
+ diff = difflib.unified_diff(
856
+ old.splitlines(keepends=True),
857
+ content.splitlines(keepends=True),
858
+ fromfile=str(dest),
859
+ tofile=str(dest),
860
+ )
861
+ diff_text = "".join(diff)
862
+ except Exception:
863
+ diff_text = "(could not read existing file)"
864
+
865
+ violations = [{"category": v.category, "description": v.description, "line": v.line, "severity": v.severity} for v in result.violations]
866
+ return _ok({
867
+ "path": path,
868
+ "would_block": not result.allowed,
869
+ "file_exists": dest.exists(),
870
+ "violations": violations,
871
+ "diff": diff_text,
872
+ }, f"{'Would be blocked' if not result.allowed else 'Would be approved'} ({len(violations)} violation(s)).")
873
+
874
+ def _tool_allow_sensitive_write(self, args: dict[str, Any]) -> dict[str, Any]:
875
+ path = args.get("path", "")
876
+ if not path:
877
+ return _err("path is required")
878
+ import re
879
+ self.runtime.add_allowed_pattern(re.escape(path) + "\\Z", f"Sensitive write bypass for {path}")
880
+ return _ok({"path": path}, f"Sensitive write for '{path}' allowed. Added to allowlist.")
881
+
882
+ def _tool_adjudicate_finding(self, args: dict[str, Any]) -> dict[str, Any]:
883
+ category = args.get("category", "")
884
+ description = args.get("description", "")
885
+ file_path = args.get("file_path", "")
886
+ line = args.get("line", 0)
887
+ severity = args.get("severity", "")
888
+ uncertainty = args.get("uncertainty", "")
889
+
890
+ if not category or not description or not file_path:
891
+ return _err("category, description, and file_path are required")
892
+
893
+ return _ok({
894
+ "finding": {
895
+ "category": category,
896
+ "description": description,
897
+ "file_path": file_path,
898
+ "line": line,
899
+ "severity": severity,
900
+ "uncertainty": uncertainty,
901
+ },
902
+ "adjudication_prompt": (
903
+ f"Review this {category} finding in {file_path}:{line}.\n"
904
+ f" Description: {description}\n"
905
+ f" Severity: {severity}\n"
906
+ f" Uncertainty: {uncertainty or 'None provided'}\n\n"
907
+ "Is this a TRUE POSITIVE (actual issue) or FALSE POSITIVE (safe code)?\n"
908
+ "- If TRUE POSITIVE: fix the issue and retry.\n"
909
+ "- If FALSE POSITIVE: call learn_false_positive with category, the description pattern, and your reason."
910
+ ),
911
+ "scoring": {
912
+ "certainty_levels": {
913
+ "certain": "No doubt — pattern is definitely a violation",
914
+ "likely": "Probably a violation but edge case possible",
915
+ "ambiguous": "Could go either way — context needed",
916
+ "likely_fp": "Probably a false positive — low risk pattern",
917
+ "certain_fp": "Definitely not a violation — safe code pattern",
918
+ }
919
+ }
920
+ }, f"Finding presented for adjudication ({category}: {description[:60]}).")
921
+
922
+ def _tool_learn_false_positive(self, args: dict[str, Any]) -> dict[str, Any]:
923
+ category = args.get("category", "")
924
+ pattern = args.get("pattern", "")
925
+ reason = args.get("reason", "")
926
+ if not category or not pattern or not reason:
927
+ return _err("category, pattern, and reason are required")
928
+ from .intercept import _learn_false_positive
929
+ _learn_false_positive(category, pattern, reason, self.repo_root)
930
+ return _ok({
931
+ "category": category,
932
+ "pattern": pattern,
933
+ "reason": reason,
934
+ }, f"Learned false positive pattern for '{category}': {pattern[:60]}")
935
+
936
+ # -----------------------------------------------------------------------
937
+ # MCP lifecycle
938
+ # -----------------------------------------------------------------------
939
+ def _inject_feedback_summary(self, response: dict[str, Any]) -> dict[str, Any]:
940
+ try:
941
+ content = response.get("content", [])
942
+ for c in content:
943
+ if c.get("type") == "text":
944
+ parsed = json.loads(c["text"])
945
+ parsed["feedback_summary"] = {
946
+ "unacknowledged": self._count_unacknowledged_feedback()
947
+ }
948
+ c["text"] = json.dumps(parsed, indent=2, default=str)
949
+ except Exception as e:
950
+ print(f"[deadpush] _inject_feedback_summary failed: {e}", file=sys.stderr, flush=True)
951
+ return response
952
+
953
+ def _handle_request(self, method: str, params: dict[str, Any] | None) -> dict[str, Any] | None:
954
+ if method == "tools/list":
955
+ return {"tools": self._tools_list()}
956
+ elif method == "tools/call":
957
+ name = (params or {}).get("name", "")
958
+ arguments = (params or {}).get("arguments", {})
959
+ tool_map: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
960
+ "write_file": self._tool_write_file,
961
+ "check_file": self._tool_check_file,
962
+ "scan": self._tool_scan,
963
+ "get_dead_symbols": self._tool_get_dead_symbols,
964
+ "get_debris": self._tool_get_debris,
965
+ "get_test_issues": self._tool_get_test_issues,
966
+ "get_stale_docs": self._tool_get_stale_docs,
967
+ "get_layer_violations": self._tool_get_layer_violations,
968
+ "get_security_boundaries": self._tool_get_security_boundaries,
969
+ "get_complexity_alerts": self._tool_get_complexity_alerts,
970
+ "clean": self._tool_clean,
971
+ "quarantine_list": self._tool_quarantine_list,
972
+ "quarantine_restore": self._tool_quarantine_restore,
973
+ "get_feedback": self._tool_get_feedback,
974
+ "get_recent_feedback": self._tool_get_recent_feedback,
975
+ "acknowledge_feedback": self._tool_acknowledge_feedback,
976
+ "retry_write": self._tool_retry_write,
977
+ "get_status": self._tool_get_status,
978
+ "get_safety_score": self._tool_get_safety_score,
979
+ "get_runtime_config": self._tool_get_runtime_config,
980
+ "add_allowed_pattern": self._tool_add_allowed_pattern,
981
+ "remove_allowed_pattern": self._tool_remove_allowed_pattern,
982
+ "ignore_path": self._tool_ignore_path,
983
+ "set_guardrail_level": self._tool_set_guardrail_level,
984
+ "reset_runtime_config": self._tool_reset_runtime_config,
985
+ "get_write_diff": self._tool_get_write_diff,
986
+ "allow_sensitive_write": self._tool_allow_sensitive_write,
987
+ "adjudicate_finding": self._tool_adjudicate_finding,
988
+ "learn_false_positive": self._tool_learn_false_positive,
989
+ "verify_write": self._tool_verify_write,
990
+ "get_test_results": self._tool_get_test_results,
991
+ }
992
+ handler = tool_map.get(name)
993
+ if not handler:
994
+ return _err(f"Unknown tool: {name}")
995
+ return self._safe_call(handler, arguments)
996
+ return None
997
+
998
+ def run(self):
999
+ """Read JSON-RPC requests from stdin and respond on stdout."""
1000
+ self.daemon.start(http=False)
1001
+
1002
+ for line in sys.stdin:
1003
+ if self._stdio_broken:
1004
+ break
1005
+ line = line.strip()
1006
+ if not line:
1007
+ continue
1008
+ try:
1009
+ msg = json.loads(line)
1010
+ except json.JSONDecodeError:
1011
+ self._send_error(None, -32700, "Parse error")
1012
+ continue
1013
+
1014
+ msg_id = msg.get("id")
1015
+ method = msg.get("method")
1016
+ params = msg.get("params")
1017
+
1018
+ if method == "initialize":
1019
+ self._send(msg_id, {
1020
+ "protocolVersion": MCP_PROTOCOL_VERSION,
1021
+ "capabilities": {"tools": {}},
1022
+ "serverInfo": {"name": "deadpush", "version": "0.2.0"},
1023
+ })
1024
+ continue
1025
+
1026
+ if method in ("notifications/initialized", "notifications/cancelled"):
1027
+ continue
1028
+
1029
+ if method == "shutdown":
1030
+ self._send(msg_id, None)
1031
+ break
1032
+
1033
+ if method:
1034
+ result = self._handle_request(method, params)
1035
+ if result is not None:
1036
+ result = self._inject_feedback_summary(result)
1037
+ self._send(msg_id, result)
1038
+ else:
1039
+ self._send_error(msg_id, -32601, f"Method not found: {method}")
1040
+
1041
+ def _send(self, msg_id: Any, result: Any):
1042
+ if self._stdio_broken:
1043
+ return
1044
+ response = {"jsonrpc": "2.0", "id": msg_id, "result": result}
1045
+ try:
1046
+ sys.stdout.write(json.dumps(response) + "\n")
1047
+ sys.stdout.flush()
1048
+ except (BrokenPipeError, OSError):
1049
+ self._stdio_broken = True
1050
+
1051
+
1052
+ def run_mcp():
1053
+ """Entry point for the MCP server (deadpush mcp)."""
1054
+ config = load_config()
1055
+ server = McpServer(config.repo_root)
1056
+ try:
1057
+ server.run()
1058
+ except KeyboardInterrupt:
1059
+ pass
1060
+ finally:
1061
+ server.daemon.stop()