cc-plugin-codex 0.1.4__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.
@@ -0,0 +1,1656 @@
1
+ """FastMCP server exposing Claude Code as bounded, read-only critique tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from typing import Annotated, Literal, cast
11
+ from urllib.parse import unquote, urlparse
12
+
13
+ from anyio.to_thread import run_sync
14
+ from fastmcp import Context, FastMCP
15
+ from fastmcp.tools import ToolResult
16
+ from pydantic import Field
17
+
18
+ from cc_plugin_codex import __version__, cli_contract, jobs, preflight
19
+ from cc_plugin_codex.claude import (
20
+ auth_status,
21
+ build_command,
22
+ classify_failure,
23
+ run_claude_async,
24
+ )
25
+ from cc_plugin_codex.config import (
26
+ MAX_BUDGET_USD,
27
+ MAX_TIMEOUT_SECONDS,
28
+ MIN_BUDGET_USD,
29
+ MIN_TIMEOUT_SECONDS,
30
+ VALID_EFFORTS,
31
+ bare_available,
32
+ clamp_budget,
33
+ clamp_timeout,
34
+ defaults,
35
+ max_input_bytes,
36
+ sanitize_effort,
37
+ supported_majors,
38
+ version_supported,
39
+ )
40
+ from cc_plugin_codex.context import (
41
+ MAX_DIFF_BYTES,
42
+ InvalidBaseError,
43
+ InvalidScopeError,
44
+ gather_context,
45
+ )
46
+ from cc_plugin_codex.jobs import JobConfig
47
+ from cc_plugin_codex.normalize import apply_cost_usage, build_prompt, normalize_envelope
48
+ from cc_plugin_codex.schemas import (
49
+ CAPABILITIES_SCHEMA,
50
+ DRY_RUN_SCHEMA,
51
+ FINGERPRINT,
52
+ JOB_LIST_SCHEMA,
53
+ JOB_STARTED_SCHEMA,
54
+ JOB_STATUS_SCHEMA,
55
+ RESULT_SCHEMA,
56
+ STATUS_SCHEMA,
57
+ Access,
58
+ CapabilitiesResult,
59
+ Confidence,
60
+ ConfigMode,
61
+ Detail,
62
+ DryRunResult,
63
+ Effort,
64
+ ErrorCode,
65
+ ErrorInfo,
66
+ ErrorResult,
67
+ JobStarted,
68
+ Meta,
69
+ RawResponse,
70
+ ResolvedDefaults,
71
+ Scope,
72
+ StatusResult,
73
+ SuccessResult,
74
+ ToolCapability,
75
+ Verdict,
76
+ workspace_warning_for,
77
+ )
78
+
79
+ CAPABILITY_SUMMARY = (
80
+ "cc-plugin-codex lets Codex ask Claude Code for bounded, independent critique: "
81
+ "diff reviews, adversarial plan review, and second opinions. It never edits code, "
82
+ "runs shell, or proxies Claude MCP tools. Paid tools send code context to "
83
+ "Anthropic; call claude_status before spending. claude_review_changes blocks; "
84
+ "claude_review_changes_async runs in background with poll/result/cancel; "
85
+ "claude_review_dry_run previews workspace/diff-size/redaction for free. Findings "
86
+ "are advisory claims to verify. Pass workspace_root explicitly: it defaults to "
87
+ "the first MCP root, else the server cwd (likely NOT your repo); when roots "
88
+ "exist it must be inside one. access=toolless is the default; access=readonly "
89
+ "lets Claude read files directly, bypassing server-gathered diff redaction. "
90
+ "Free-form input is capped by CC_PLUGIN_CODEX_MAX_INPUT_BYTES. "
91
+ "Experimental; pin fingerprint from cc_codex_capabilities."
92
+ )
93
+
94
+ PRACTICAL_MIN_BUDGET_HINT = (
95
+ "The configured clamp allows $0.01+, but real paid calls usually need about "
96
+ "$0.10-$0.20 even for small prompts; lower budgets may spend and still return "
97
+ "budget_exceeded."
98
+ )
99
+
100
+ mcp = FastMCP(name="cc-plugin-codex", instructions=CAPABILITY_SUMMARY)
101
+
102
+ # Paid tools read code but are NOT idempotent (each call spends money and re-invokes
103
+ # Claude) and are explicitly non-destructive (no writes/shell). openWorld: they reach
104
+ # an external service (Anthropic).
105
+ _PAID_ANNOTATIONS = {
106
+ "readOnlyHint": True,
107
+ "openWorldHint": True,
108
+ "destructiveHint": False,
109
+ "idempotentHint": False,
110
+ }
111
+ # Free read-only tools are safely repeatable.
112
+ _FREE_READ_ANNOTATIONS = {
113
+ "readOnlyHint": True,
114
+ "openWorldHint": False,
115
+ "destructiveHint": False,
116
+ "idempotentHint": True,
117
+ }
118
+ # Local job lifecycle mutations change only this server's job state.
119
+ _LOCAL_MUTATION_ANNOTATIONS = {
120
+ "readOnlyHint": False,
121
+ "openWorldHint": False,
122
+ "destructiveHint": False,
123
+ "idempotentHint": False,
124
+ }
125
+
126
+
127
+ def _result(payload: dict) -> ToolResult:
128
+ """Wrap a normalized payload as a ToolResult, flagging error envelopes.
129
+
130
+ Keeps the structured ok:true|false contract intact AND sets the native
131
+ is_error flag for ok:false, so clients that branch on is_error (not just the
132
+ `ok` field) detect failures.
133
+ """
134
+ return ToolResult(structured_content=payload, is_error=payload.get("ok") is False)
135
+
136
+
137
+ def _meta(
138
+ cwd: str,
139
+ config_mode: str,
140
+ access: str,
141
+ timeout: int,
142
+ elapsed: int,
143
+ exit_code: int | None,
144
+ scope: str | None = None,
145
+ base: str | None = None,
146
+ truncated: bool = False,
147
+ hint: str | None = None,
148
+ workspace_source: str | None = None,
149
+ requested_budget: float | None = None,
150
+ redacted_paths: list[str] | None = None,
151
+ compat_warnings: list[str] | None = None,
152
+ ) -> Meta:
153
+ return Meta(
154
+ cwd=cwd,
155
+ config_mode=cast("ConfigMode", config_mode),
156
+ access=cast("Access", access),
157
+ scope=scope,
158
+ base=base,
159
+ timeout_seconds=timeout,
160
+ elapsed_ms=elapsed,
161
+ command_exit_code=exit_code,
162
+ truncated=truncated,
163
+ truncation_hint=hint,
164
+ fingerprint=FINGERPRINT,
165
+ workspace_source=workspace_source,
166
+ workspace_warning=workspace_warning_for(workspace_source, cwd),
167
+ requested_max_budget_usd=requested_budget,
168
+ redacted_paths=redacted_paths or [],
169
+ compat_warnings=compat_warnings or [],
170
+ )
171
+
172
+
173
+ def _err(
174
+ code: str,
175
+ message: str,
176
+ repair: str,
177
+ meta: Meta,
178
+ offending: str | None = None,
179
+ retryable: bool = False,
180
+ ) -> dict:
181
+ return ErrorResult(
182
+ error=ErrorInfo(
183
+ code=cast("ErrorCode", code),
184
+ message=message,
185
+ repair=repair,
186
+ offending_param=offending,
187
+ retryable=retryable,
188
+ ),
189
+ meta=meta,
190
+ ).model_dump(mode="json", exclude_none=True)
191
+
192
+
193
+ def _workspace_error(code: str, workspace_root: str | None = None) -> dict:
194
+ meta = _meta("", "inherit", "toolless", 0, 0, None)
195
+ if code == "workspace_outside_roots":
196
+ return _err(
197
+ code,
198
+ f"workspace_root '{workspace_root}' is outside the client's MCP roots.",
199
+ "Pass a workspace_root contained by an MCP root, omit workspace_root to "
200
+ "use the first root, or configure the intended directory as a root.",
201
+ meta,
202
+ offending="workspace_root",
203
+ )
204
+ if workspace_root is None:
205
+ return _err(
206
+ code,
207
+ "The resolved workspace is not an existing absolute directory.",
208
+ "Pass workspace_root as an absolute path to an existing directory, "
209
+ "or configure an MCP root that points at an existing directory.",
210
+ meta,
211
+ )
212
+ return _err(
213
+ code,
214
+ f"workspace_root '{workspace_root}' is not an existing absolute directory.",
215
+ "Pass workspace_root as an absolute path to an existing directory, or "
216
+ "configure an MCP root.",
217
+ meta,
218
+ offending="workspace_root",
219
+ )
220
+
221
+
222
+ async def _file_roots(ctx) -> list[str]:
223
+ """Return filesystem paths from the client's file:// roots.
224
+
225
+ Returns [] if the client provides no roots or does not support the roots
226
+ capability (list_roots raises)."""
227
+ if ctx is None:
228
+ return []
229
+ try:
230
+ roots = await ctx.list_roots()
231
+ except Exception:
232
+ return []
233
+ paths = []
234
+ for root in roots or []:
235
+ uri = str(getattr(root, "uri", ""))
236
+ if uri.startswith("file://"):
237
+ paths.append(unquote(urlparse(uri).path))
238
+ return paths
239
+
240
+
241
+ async def _first_root(ctx) -> str | None:
242
+ roots = await _file_roots(ctx)
243
+ return roots[0] if roots else None
244
+
245
+
246
+ def _contained_by(path: str, root: str) -> bool:
247
+ try:
248
+ return os.path.commonpath(
249
+ [os.path.realpath(path), os.path.realpath(root)]
250
+ ) == os.path.realpath(root)
251
+ except ValueError:
252
+ return False
253
+
254
+
255
+ async def _resolve_workspace(workspace_root, ctx):
256
+ """Resolve the workspace directory.
257
+
258
+ Order: explicit workspace_root arg -> first file:// MCP root -> os.getcwd().
259
+ Returns (path, error_code, source). error_code is None on success; on failure
260
+ path is None and source is None."""
261
+ roots = await _file_roots(ctx)
262
+ if workspace_root:
263
+ path, source = workspace_root, "param"
264
+ else:
265
+ root = roots[0] if roots else None
266
+ if root:
267
+ path, source = root, "roots"
268
+ else:
269
+ path, source = os.getcwd(), "cwd" # noqa: PTH109 — path stays a str (returned as cwd)
270
+ # An explicit workspace_root must be absolute: a relative path would be resolved
271
+ # against the very cwd this resolution exists to stop trusting. Roots (file:// URIs)
272
+ # and os.getcwd() are always absolute already.
273
+ if not os.path.isabs(path) or not os.path.isdir(path): # noqa: PTH117, PTH112 — path is a str by contract
274
+ return None, "invalid_workspace_root", None
275
+ if workspace_root and roots and not any(_contained_by(path, root) for root in roots):
276
+ return None, "workspace_outside_roots", None
277
+ return path, None, source
278
+
279
+
280
+ def _utf8_len(value: str | None) -> int:
281
+ return len((value or "").encode("utf-8", "replace"))
282
+
283
+
284
+ def _validate_input_size(fields: dict[str, str | None], meta: Meta) -> dict | None:
285
+ limit = max_input_bytes()
286
+ total = sum(_utf8_len(value) for value in fields.values())
287
+ if total <= limit:
288
+ return None
289
+ largest = max(fields, key=lambda key: _utf8_len(fields[key]))
290
+ return _err(
291
+ "context_too_large",
292
+ f"User-supplied text is {total} bytes, exceeding the {limit}-byte limit.",
293
+ "Shorten the prompt/evidence/context, split the request, or raise "
294
+ "CC_PLUGIN_CODEX_MAX_INPUT_BYTES if this workspace intentionally allows it.",
295
+ meta,
296
+ offending=largest,
297
+ )
298
+
299
+
300
+ def _empty_diff_result(
301
+ tool: str,
302
+ meta: Meta,
303
+ context_summary,
304
+ verdict: Verdict = "pass",
305
+ confidence: Confidence = "high",
306
+ ) -> dict:
307
+ result = SuccessResult(
308
+ tool=tool,
309
+ summary="No changes in scope; skipped Claude call.",
310
+ verdict=verdict,
311
+ confidence=confidence,
312
+ raw_response=RawResponse(),
313
+ context_summary=context_summary,
314
+ meta=meta,
315
+ )
316
+ return result.model_dump(mode="json", exclude_none=True)
317
+
318
+
319
+ @dataclass
320
+ class Resolved:
321
+ config_mode: str
322
+ access: str
323
+ model: str | None
324
+ budget: float
325
+ timeout: int
326
+ detail: str
327
+ effort: str
328
+
329
+
330
+ def _resolve(
331
+ config_mode,
332
+ access,
333
+ model,
334
+ max_budget_usd,
335
+ timeout_seconds,
336
+ detail,
337
+ cwd,
338
+ scope=None,
339
+ base=None,
340
+ workspace_source=None,
341
+ effort=None,
342
+ ):
343
+ """Resolve env defaults + clamps and validate.
344
+
345
+ Returns (Resolved, None) or (None, error_dict).
346
+ """
347
+ d = defaults()
348
+ cm = config_mode or d.config_mode
349
+ ac = access or d.access
350
+ mdl = model or d.model
351
+ budget = clamp_budget(max_budget_usd if max_budget_usd is not None else d.max_budget_usd)
352
+ timeout = clamp_timeout(timeout_seconds if timeout_seconds is not None else d.timeout_seconds)
353
+ det = detail if detail in ("summary", "full") else "summary"
354
+ eff = effort if effort in VALID_EFFORTS else d.effort
355
+
356
+ # Validate before building Meta (Meta uses Literal types — invalid values
357
+ # would raise Pydantic errors before we can return a structured response).
358
+ if cm not in ("inherit", "scoped", "bare"):
359
+ safe_meta = _meta(
360
+ cwd,
361
+ "inherit",
362
+ ac if ac in ("toolless", "readonly") else "toolless",
363
+ timeout,
364
+ 0,
365
+ None,
366
+ scope,
367
+ base,
368
+ workspace_source=workspace_source,
369
+ requested_budget=budget,
370
+ )
371
+ return None, _err(
372
+ "unsupported_config_mode",
373
+ f"Unknown config_mode '{cm}'.",
374
+ "Use one of: inherit, scoped, bare.",
375
+ safe_meta,
376
+ offending="config_mode",
377
+ )
378
+ if ac not in ("toolless", "readonly"):
379
+ safe_meta = _meta(
380
+ cwd,
381
+ cm,
382
+ "toolless",
383
+ timeout,
384
+ 0,
385
+ None,
386
+ scope,
387
+ base,
388
+ workspace_source=workspace_source,
389
+ requested_budget=budget,
390
+ )
391
+ return None, _err(
392
+ "unsupported_access",
393
+ f"Unknown access '{ac}'.",
394
+ "Use one of: toolless, readonly.",
395
+ safe_meta,
396
+ offending="access",
397
+ )
398
+
399
+ meta = _meta(
400
+ cwd,
401
+ cm,
402
+ ac,
403
+ timeout,
404
+ 0,
405
+ None,
406
+ scope,
407
+ base,
408
+ workspace_source=workspace_source,
409
+ requested_budget=budget,
410
+ )
411
+ if cm == "bare" and not bare_available():
412
+ return None, _err(
413
+ "api_key_missing",
414
+ "config_mode=bare requires ANTHROPIC_API_KEY, which is unset.",
415
+ "Set ANTHROPIC_API_KEY, or use config_mode inherit/scoped.",
416
+ meta,
417
+ offending="config_mode",
418
+ )
419
+ return Resolved(cm, ac, mdl, budget, timeout, det, eff), None
420
+
421
+
422
+ async def _execute(
423
+ tool,
424
+ payload,
425
+ r: Resolved,
426
+ cwd,
427
+ scope=None,
428
+ base=None,
429
+ context_text="",
430
+ context_summary=None,
431
+ workspace_source=None,
432
+ redacted_paths: list[str] | None = None,
433
+ ) -> dict:
434
+ prompt = build_prompt(tool, payload, context_text)
435
+ cmd, dropped = build_command(prompt, r.config_mode, r.access, r.model, r.budget, r.effort)
436
+ run = await run_claude_async(cmd, cwd=cwd, timeout_seconds=r.timeout)
437
+ meta = _meta(
438
+ cwd,
439
+ r.config_mode,
440
+ r.access,
441
+ r.timeout,
442
+ run.elapsed_ms,
443
+ run.exit_code,
444
+ scope,
445
+ base,
446
+ workspace_source=workspace_source,
447
+ requested_budget=r.budget,
448
+ redacted_paths=redacted_paths,
449
+ compat_warnings=dropped,
450
+ )
451
+ if run.exit_code != 0 or run.timed_out:
452
+ # A non-zero exit can still carry a cost-bearing JSON envelope (e.g.
453
+ # budget_exceeded); report what it spent when available.
454
+ try:
455
+ env = json.loads(run.stdout)
456
+ except (json.JSONDecodeError, ValueError, TypeError):
457
+ env = None
458
+ if isinstance(env, dict):
459
+ apply_cost_usage(meta, env)
460
+ info = classify_failure(run)
461
+ return _err(info.code, info.message, info.repair, meta, retryable=info.retryable)
462
+ return normalize_envelope(
463
+ tool, run.stdout, meta, detail=r.detail, context_summary=context_summary
464
+ )
465
+
466
+
467
+ @mcp.tool(
468
+ annotations=_PAID_ANNOTATIONS, title="Ask Claude (second opinion)", output_schema=RESULT_SCHEMA
469
+ )
470
+ async def claude_ask(
471
+ prompt: Annotated[str, Field(description="The question to ask Claude.")],
472
+ context: Annotated[str | None, Field(description="Extra context, passed verbatim.")] = None,
473
+ workspace_root: Annotated[
474
+ str | None,
475
+ Field(
476
+ description="Absolute path to the repo/workspace to operate in. If omitted, "
477
+ "the server uses the client's first MCP root, else its own cwd."
478
+ ),
479
+ ] = None,
480
+ config_mode: Annotated[ConfigMode | None, Field(description="inherit|scoped|bare")] = None,
481
+ access: Annotated[Access | None, Field(description="toolless|readonly")] = None,
482
+ model: Annotated[
483
+ str | None, Field(description="Claude model override; omit for configured default.")
484
+ ] = None,
485
+ effort: Annotated[
486
+ Effort | None,
487
+ Field(
488
+ description="Reasoning effort: low|medium|high|xhigh|max. "
489
+ "Raise for high-stakes reviews; omit to use the server default."
490
+ ),
491
+ ] = None,
492
+ max_budget_usd: Annotated[
493
+ float | None, Field(description="Per-call Claude spend cap; clamped by server limits.")
494
+ ] = None,
495
+ timeout_seconds: Annotated[
496
+ int | None, Field(description="Sync call timeout; omit for configured default.")
497
+ ] = None,
498
+ detail: Annotated[Detail, Field(description="summary|full")] = "summary",
499
+ ctx: Context | None = None,
500
+ ) -> ToolResult:
501
+ """Ask Claude for a free-form second opinion.
502
+
503
+ Use when the task is a question or design choice, not a git diff review or
504
+ adversarial attack. Paid external call; read-only; blocks up to
505
+ timeout_seconds and can be cancelled but not resumed. Free-form input is
506
+ size-capped before spend. Returns structured ok:true findings or ok:false
507
+ repair errors.
508
+ """
509
+ cwd, ws_err, ws_source = await _resolve_workspace(workspace_root, ctx)
510
+ if ws_err:
511
+ return _result(_workspace_error(ws_err, workspace_root))
512
+ r, err = _resolve(
513
+ config_mode,
514
+ access,
515
+ model,
516
+ max_budget_usd,
517
+ timeout_seconds,
518
+ detail,
519
+ cwd,
520
+ workspace_source=ws_source,
521
+ effort=effort,
522
+ )
523
+ if err:
524
+ return _result(err)
525
+ payload = {"prompt": prompt, "context": context}
526
+ meta = _meta(
527
+ cwd,
528
+ r.config_mode,
529
+ r.access,
530
+ r.timeout,
531
+ 0,
532
+ None,
533
+ workspace_source=ws_source,
534
+ requested_budget=r.budget,
535
+ )
536
+ too_large = _validate_input_size(payload, meta)
537
+ if too_large:
538
+ return _result(too_large)
539
+ out = await _execute("claude_ask", payload, r, cwd, workspace_source=ws_source)
540
+ return _result(out)
541
+
542
+
543
+ @mcp.tool(
544
+ annotations=_PAID_ANNOTATIONS, title="Review changes with Claude", output_schema=RESULT_SCHEMA
545
+ )
546
+ async def claude_review_changes(
547
+ scope: Annotated[Scope, Field(description="working_tree|staged|branch")],
548
+ base: Annotated[str, Field(description="Base ref for scope=branch.")] = "main",
549
+ focus: Annotated[str | None, Field(description="e.g. 'security', 'tests'.")] = None,
550
+ workspace_root: Annotated[
551
+ str | None,
552
+ Field(
553
+ description="Absolute path to the repo/workspace to operate in. If omitted, "
554
+ "the server uses the client's first MCP root, else its own cwd."
555
+ ),
556
+ ] = None,
557
+ config_mode: Annotated[ConfigMode | None, Field(description="inherit|scoped|bare")] = None,
558
+ access: Annotated[Access | None, Field(description="toolless|readonly")] = None,
559
+ model: Annotated[
560
+ str | None, Field(description="Claude model override; omit for configured default.")
561
+ ] = None,
562
+ effort: Annotated[
563
+ Effort | None,
564
+ Field(
565
+ description="Reasoning effort: low|medium|high|xhigh|max. "
566
+ "Raise for high-stakes reviews; omit to use the server default."
567
+ ),
568
+ ] = None,
569
+ max_budget_usd: Annotated[
570
+ float | None, Field(description="Per-call Claude spend cap; clamped by server limits.")
571
+ ] = None,
572
+ timeout_seconds: Annotated[
573
+ int | None, Field(description="Sync call timeout; omit for configured default.")
574
+ ] = None,
575
+ detail: Annotated[Detail, Field(description="summary|full")] = "summary",
576
+ ctx: Context | None = None,
577
+ ) -> ToolResult:
578
+ """Review a git diff with Claude and wait for the result.
579
+
580
+ Use for correctness, regression, security, or test-coverage review of
581
+ working_tree, staged, or branch diff. Paid external call; read-only; blocks up
582
+ to timeout_seconds and can be cancelled but not resumed. For long reviews, use
583
+ claude_review_changes_async. Empty diffs return ok:true without calling Claude.
584
+ """
585
+ cwd, ws_err, ws_source = await _resolve_workspace(workspace_root, ctx)
586
+ if ws_err:
587
+ return _result(_workspace_error(ws_err, workspace_root))
588
+ # Validate options BEFORE touching git, so bad config isn't masked by git errors.
589
+ r, err = _resolve(
590
+ config_mode,
591
+ access,
592
+ model,
593
+ max_budget_usd,
594
+ timeout_seconds,
595
+ detail,
596
+ cwd,
597
+ scope=scope,
598
+ base=base,
599
+ workspace_source=ws_source,
600
+ effort=effort,
601
+ )
602
+ if err:
603
+ return _result(err)
604
+ meta = _meta(
605
+ cwd,
606
+ r.config_mode,
607
+ r.access,
608
+ r.timeout,
609
+ 0,
610
+ None,
611
+ scope,
612
+ base,
613
+ workspace_source=ws_source,
614
+ requested_budget=r.budget,
615
+ )
616
+ try:
617
+ ctx_data = await run_sync(lambda: gather_context(cwd, scope=scope, base=base))
618
+ except InvalidBaseError:
619
+ return _result(
620
+ _err(
621
+ "invalid_base",
622
+ f"Invalid base ref '{base}'.",
623
+ "Use an existing git ref matching [A-Za-z0-9._/-]+ that does not start with '-'.",
624
+ meta,
625
+ offending="base",
626
+ )
627
+ )
628
+ except InvalidScopeError:
629
+ return _result(
630
+ _err(
631
+ "invalid_scope",
632
+ f"Invalid scope '{scope}'.",
633
+ "Use working_tree, staged, or branch.",
634
+ meta,
635
+ offending="scope",
636
+ )
637
+ )
638
+ except RuntimeError as e:
639
+ return _result(
640
+ _err(
641
+ "internal_error",
642
+ f"git failed: {e}",
643
+ "Ensure cwd is a git repo and base ref exists.",
644
+ meta,
645
+ )
646
+ )
647
+ if ctx_data.truncated:
648
+ meta = _meta(
649
+ cwd,
650
+ r.config_mode,
651
+ r.access,
652
+ r.timeout,
653
+ 0,
654
+ None,
655
+ scope,
656
+ base,
657
+ truncated=True,
658
+ hint=ctx_data.truncation_hint,
659
+ workspace_source=ws_source,
660
+ requested_budget=r.budget,
661
+ redacted_paths=ctx_data.redacted_paths,
662
+ )
663
+ return _result(
664
+ _err(
665
+ "context_too_large",
666
+ "The diff is too large to review safely.",
667
+ ctx_data.truncation_hint or "Narrow the scope.",
668
+ meta,
669
+ )
670
+ )
671
+ meta = _meta(
672
+ cwd,
673
+ r.config_mode,
674
+ r.access,
675
+ r.timeout,
676
+ 0,
677
+ None,
678
+ scope,
679
+ base,
680
+ workspace_source=ws_source,
681
+ requested_budget=r.budget,
682
+ redacted_paths=ctx_data.redacted_paths,
683
+ )
684
+ if ctx_data.summary.files_changed == 0 and not ctx_data.text.strip():
685
+ return _result(_empty_diff_result("claude_review_changes", meta, ctx_data.summary))
686
+ out = await _execute(
687
+ "claude_review_changes",
688
+ {"scope": scope, "base": base, "focus": focus},
689
+ r,
690
+ cwd,
691
+ scope=scope,
692
+ base=base,
693
+ context_text=ctx_data.text,
694
+ context_summary=ctx_data.summary,
695
+ workspace_source=ws_source,
696
+ redacted_paths=ctx_data.redacted_paths,
697
+ )
698
+ return _result(out)
699
+
700
+
701
+ @mcp.tool(
702
+ annotations=_PAID_ANNOTATIONS,
703
+ title="Adversarial review with Claude",
704
+ output_schema=RESULT_SCHEMA,
705
+ )
706
+ async def claude_adversarial_review(
707
+ target: Annotated[str, Field(description="The plan/claim/decision to attack.")],
708
+ evidence: Annotated[str | None, Field(description="Supporting evidence.")] = None,
709
+ scope: Annotated[
710
+ Scope | None, Field(description="Optionally attach a diff: working_tree|staged|branch")
711
+ ] = None,
712
+ base: Annotated[str, Field(description="Base ref for branch diff when scope=branch.")] = "main",
713
+ workspace_root: Annotated[
714
+ str | None,
715
+ Field(
716
+ description="Absolute path to the repo/workspace to operate in. If omitted, "
717
+ "the server uses the client's first MCP root, else its own cwd."
718
+ ),
719
+ ] = None,
720
+ config_mode: Annotated[ConfigMode | None, Field(description="inherit|scoped|bare")] = None,
721
+ access: Annotated[Access | None, Field(description="toolless|readonly")] = None,
722
+ model: Annotated[
723
+ str | None, Field(description="Claude model override; omit for configured default.")
724
+ ] = None,
725
+ effort: Annotated[
726
+ Effort | None,
727
+ Field(
728
+ description="Reasoning effort: low|medium|high|xhigh|max. "
729
+ "Raise for high-stakes reviews; omit to use the server default."
730
+ ),
731
+ ] = None,
732
+ max_budget_usd: Annotated[
733
+ float | None, Field(description="Per-call Claude spend cap; clamped by server limits.")
734
+ ] = None,
735
+ timeout_seconds: Annotated[
736
+ int | None, Field(description="Sync call timeout; omit for configured default.")
737
+ ] = None,
738
+ detail: Annotated[Detail, Field(description="summary|full")] = "summary",
739
+ ctx: Context | None = None,
740
+ ) -> ToolResult:
741
+ """Have Claude attack a plan, claim, or decision.
742
+
743
+ Use to surface counterarguments and failure modes. Include evidence text, and
744
+ optionally attach a git diff with scope/base. Paid external call; read-only;
745
+ blocks up to timeout_seconds and can be cancelled but not resumed. Free-form
746
+ input is size-capped before spend; an empty attached diff returns ok:true
747
+ without calling Claude.
748
+ """
749
+ cwd, ws_err, ws_source = await _resolve_workspace(workspace_root, ctx)
750
+ if ws_err:
751
+ return _result(_workspace_error(ws_err, workspace_root))
752
+ r, err = _resolve(
753
+ config_mode,
754
+ access,
755
+ model,
756
+ max_budget_usd,
757
+ timeout_seconds,
758
+ detail,
759
+ cwd,
760
+ scope=scope,
761
+ base=base,
762
+ workspace_source=ws_source,
763
+ effort=effort,
764
+ )
765
+ if err:
766
+ return _result(err)
767
+ payload = {"target": target, "evidence": evidence}
768
+ meta = _meta(
769
+ cwd,
770
+ r.config_mode,
771
+ r.access,
772
+ r.timeout,
773
+ 0,
774
+ None,
775
+ scope,
776
+ base,
777
+ workspace_source=ws_source,
778
+ requested_budget=r.budget,
779
+ )
780
+ too_large = _validate_input_size(payload, meta)
781
+ if too_large:
782
+ return _result(too_large)
783
+ context_text = ""
784
+ context_summary = None
785
+ redacted_paths: list[str] = []
786
+ if scope:
787
+ meta = _meta(
788
+ cwd,
789
+ r.config_mode,
790
+ r.access,
791
+ r.timeout,
792
+ 0,
793
+ None,
794
+ scope,
795
+ base,
796
+ workspace_source=ws_source,
797
+ requested_budget=r.budget,
798
+ )
799
+ try:
800
+ ctx_data = await run_sync(lambda: gather_context(cwd, scope=scope, base=base))
801
+ except InvalidBaseError:
802
+ return _result(
803
+ _err(
804
+ "invalid_base",
805
+ f"Invalid base ref '{base}'.",
806
+ "Use an existing git ref matching [A-Za-z0-9._/-]+ that does "
807
+ "not start with '-'.",
808
+ meta,
809
+ offending="base",
810
+ )
811
+ )
812
+ except InvalidScopeError:
813
+ return _result(
814
+ _err(
815
+ "invalid_scope",
816
+ f"Invalid scope '{scope}'.",
817
+ "Use working_tree, staged, or branch (or omit scope).",
818
+ meta,
819
+ offending="scope",
820
+ )
821
+ )
822
+ except RuntimeError as e:
823
+ return _result(
824
+ _err(
825
+ "internal_error",
826
+ f"git failed: {e}",
827
+ "Ensure cwd is a git repo and base ref exists.",
828
+ meta,
829
+ )
830
+ )
831
+ if ctx_data.truncated:
832
+ meta = _meta(
833
+ cwd,
834
+ r.config_mode,
835
+ r.access,
836
+ r.timeout,
837
+ 0,
838
+ None,
839
+ scope,
840
+ base,
841
+ truncated=True,
842
+ hint=ctx_data.truncation_hint,
843
+ workspace_source=ws_source,
844
+ requested_budget=r.budget,
845
+ redacted_paths=ctx_data.redacted_paths,
846
+ )
847
+ return _result(
848
+ _err(
849
+ "context_too_large",
850
+ "The attached diff is too large to review safely.",
851
+ ctx_data.truncation_hint or "Narrow the scope.",
852
+ meta,
853
+ )
854
+ )
855
+ meta = _meta(
856
+ cwd,
857
+ r.config_mode,
858
+ r.access,
859
+ r.timeout,
860
+ 0,
861
+ None,
862
+ scope,
863
+ base,
864
+ workspace_source=ws_source,
865
+ requested_budget=r.budget,
866
+ redacted_paths=ctx_data.redacted_paths,
867
+ )
868
+ if ctx_data.summary.files_changed == 0 and not ctx_data.text.strip():
869
+ return _result(
870
+ _empty_diff_result(
871
+ "claude_adversarial_review",
872
+ meta,
873
+ ctx_data.summary,
874
+ verdict="unknown",
875
+ confidence="low",
876
+ )
877
+ )
878
+ context_text, context_summary = ctx_data.text, ctx_data.summary
879
+ redacted_paths = ctx_data.redacted_paths
880
+ out = await _execute(
881
+ "claude_adversarial_review",
882
+ payload,
883
+ r,
884
+ cwd,
885
+ scope=scope,
886
+ base=base,
887
+ context_text=context_text,
888
+ context_summary=context_summary,
889
+ workspace_source=ws_source,
890
+ redacted_paths=redacted_paths,
891
+ )
892
+ return _result(out)
893
+
894
+
895
+ # Starting a background job commits to spend (the job runs to completion or its
896
+ # best-effort budget stop threshold even if never polled), but returns immediately
897
+ # without blocking.
898
+ _ASYNC_START_ANNOTATIONS = {
899
+ "readOnlyHint": False,
900
+ "openWorldHint": True,
901
+ "destructiveHint": False,
902
+ "idempotentHint": False,
903
+ }
904
+
905
+
906
+ @mcp.tool(
907
+ annotations=_ASYNC_START_ANNOTATIONS,
908
+ title="Review changes with Claude (background)",
909
+ output_schema=JOB_STARTED_SCHEMA,
910
+ )
911
+ async def claude_review_changes_async(
912
+ scope: Annotated[Scope, Field(description="working_tree|staged|branch")],
913
+ base: Annotated[str, Field(description="Base ref for scope=branch.")] = "main",
914
+ focus: Annotated[str | None, Field(description="e.g. 'security', 'tests'.")] = None,
915
+ workspace_root: Annotated[
916
+ str | None,
917
+ Field(
918
+ description="Absolute path to the repo/workspace to operate in. If omitted, "
919
+ "the server uses the client's first MCP root, else its own cwd."
920
+ ),
921
+ ] = None,
922
+ config_mode: Annotated[ConfigMode | None, Field(description="inherit|scoped|bare")] = None,
923
+ access: Annotated[Access | None, Field(description="toolless|readonly")] = None,
924
+ model: Annotated[
925
+ str | None, Field(description="Claude model override; omit for configured default.")
926
+ ] = None,
927
+ effort: Annotated[
928
+ Effort | None, Field(description="Reasoning effort: low|medium|high|xhigh|max.")
929
+ ] = None,
930
+ max_budget_usd: Annotated[
931
+ float | None, Field(description="Per-call Claude spend cap; clamped by server limits.")
932
+ ] = None,
933
+ detail: Annotated[Detail, Field(description="summary|full")] = "summary",
934
+ ctx: Context | None = None,
935
+ ) -> ToolResult:
936
+ """Launch a git diff review in the background and return a job_id.
937
+
938
+ Use when a diff review may outlive the current turn. Paid external call;
939
+ creates local job state and cannot be resumed if cancelled. Poll with
940
+ claude_job_status, read with claude_job_result, delete after reading with
941
+ claude_job_consume_result, or stop with claude_job_cancel. Empty diffs return
942
+ ok:true immediately without starting a job.
943
+ """
944
+ cwd, ws_err, ws_source = await _resolve_workspace(workspace_root, ctx)
945
+ if ws_err:
946
+ return _result(_workspace_error(ws_err, workspace_root))
947
+ r, err = _resolve(
948
+ config_mode,
949
+ access,
950
+ model,
951
+ max_budget_usd,
952
+ None,
953
+ detail,
954
+ cwd,
955
+ scope=scope,
956
+ base=base,
957
+ workspace_source=ws_source,
958
+ effort=effort,
959
+ )
960
+ if err:
961
+ return _result(err)
962
+ # A background job is bounded by its wall-clock deadline, not the synchronous
963
+ # timeout_seconds; report that everywhere so meta stays consistent with the job.
964
+ job_timeout = jobs.max_seconds()
965
+ meta = _meta(
966
+ cwd,
967
+ r.config_mode,
968
+ r.access,
969
+ job_timeout,
970
+ 0,
971
+ None,
972
+ scope,
973
+ base,
974
+ workspace_source=ws_source,
975
+ requested_budget=r.budget,
976
+ )
977
+ try:
978
+ ctx_data = await run_sync(lambda: gather_context(cwd, scope=scope, base=base))
979
+ except InvalidBaseError:
980
+ return _result(
981
+ _err(
982
+ "invalid_base",
983
+ f"Invalid base ref '{base}'.",
984
+ "Use an existing git ref matching [A-Za-z0-9._/-]+ that does not start with '-'.",
985
+ meta,
986
+ offending="base",
987
+ )
988
+ )
989
+ except InvalidScopeError:
990
+ return _result(
991
+ _err(
992
+ "invalid_scope",
993
+ f"Invalid scope '{scope}'.",
994
+ "Use working_tree, staged, or branch.",
995
+ meta,
996
+ offending="scope",
997
+ )
998
+ )
999
+ except RuntimeError as e:
1000
+ return _result(
1001
+ _err(
1002
+ "internal_error",
1003
+ f"git failed: {e}",
1004
+ "Ensure cwd is a git repo and base ref exists.",
1005
+ meta,
1006
+ )
1007
+ )
1008
+ if ctx_data.truncated:
1009
+ meta = _meta(
1010
+ cwd,
1011
+ r.config_mode,
1012
+ r.access,
1013
+ job_timeout,
1014
+ 0,
1015
+ None,
1016
+ scope,
1017
+ base,
1018
+ truncated=True,
1019
+ hint=ctx_data.truncation_hint,
1020
+ workspace_source=ws_source,
1021
+ requested_budget=r.budget,
1022
+ redacted_paths=ctx_data.redacted_paths,
1023
+ )
1024
+ return _result(
1025
+ _err(
1026
+ "context_too_large",
1027
+ "The diff is too large to review safely.",
1028
+ ctx_data.truncation_hint or "Narrow the scope.",
1029
+ meta,
1030
+ )
1031
+ )
1032
+ meta = _meta(
1033
+ cwd,
1034
+ r.config_mode,
1035
+ r.access,
1036
+ job_timeout,
1037
+ 0,
1038
+ None,
1039
+ scope,
1040
+ base,
1041
+ workspace_source=ws_source,
1042
+ requested_budget=r.budget,
1043
+ redacted_paths=ctx_data.redacted_paths,
1044
+ )
1045
+ if ctx_data.summary.files_changed == 0 and not ctx_data.text.strip():
1046
+ return _result(_empty_diff_result("claude_review_changes", meta, ctx_data.summary))
1047
+ prompt = build_prompt(
1048
+ "claude_review_changes", {"scope": scope, "base": base, "focus": focus}, ctx_data.text
1049
+ )
1050
+ cmd, dropped = build_command(prompt, r.config_mode, r.access, r.model, r.budget, r.effort)
1051
+ cfg = JobConfig(
1052
+ kind="claude_review_changes",
1053
+ config_mode=r.config_mode,
1054
+ access=r.access,
1055
+ scope=scope,
1056
+ base=base,
1057
+ detail=r.detail,
1058
+ timeout_seconds=jobs.max_seconds(),
1059
+ workspace_source=ws_source,
1060
+ context_summary=ctx_data.summary,
1061
+ requested_max_budget_usd=r.budget,
1062
+ redacted_paths=ctx_data.redacted_paths,
1063
+ )
1064
+ job_id, started_at = await run_sync(lambda: jobs.start_job(cmd, cwd, cfg))
1065
+ started = JobStarted(
1066
+ job_id=job_id,
1067
+ kind="claude_review_changes",
1068
+ started_at=started_at,
1069
+ deadline_seconds=job_timeout,
1070
+ poll_after_ms=jobs.poll_after_ms(),
1071
+ ttl_seconds=jobs.ttl_seconds(),
1072
+ meta=_meta(
1073
+ cwd,
1074
+ r.config_mode,
1075
+ r.access,
1076
+ job_timeout,
1077
+ 0,
1078
+ None,
1079
+ scope,
1080
+ base,
1081
+ workspace_source=ws_source,
1082
+ requested_budget=r.budget,
1083
+ redacted_paths=ctx_data.redacted_paths,
1084
+ compat_warnings=dropped,
1085
+ ),
1086
+ )
1087
+ return _result(started.model_dump(mode="json", exclude_none=True))
1088
+
1089
+
1090
+ @mcp.tool(
1091
+ annotations=_LOCAL_MUTATION_ANNOTATIONS,
1092
+ title="Background job status",
1093
+ output_schema=JOB_STATUS_SCHEMA,
1094
+ )
1095
+ async def claude_job_status(
1096
+ job_id: Annotated[str, Field(description="A job_id from an *_async tool.")],
1097
+ workspace_root: Annotated[
1098
+ str | None,
1099
+ Field(description="Workspace the job belongs to (defaults like the async tools)."),
1100
+ ] = None,
1101
+ ctx: Context | None = None,
1102
+ ) -> ToolResult:
1103
+ """Check a background review job without fetching the full result.
1104
+
1105
+ Use after claude_review_changes_async. Returns status, elapsed time,
1106
+ result_available, polling hints, and cost when available. If
1107
+ result_available is true, call claude_job_result.
1108
+ """
1109
+ cwd, ws_err, ws_source = await _resolve_workspace(workspace_root, ctx)
1110
+ if ws_err:
1111
+ return _result(_workspace_error(ws_err, workspace_root))
1112
+ data = await run_sync(lambda: jobs.status(cwd, job_id))
1113
+ if data is None:
1114
+ meta = _meta(cwd, "inherit", "toolless", 0, 0, None, workspace_source=ws_source)
1115
+ return _result(
1116
+ _err(
1117
+ "job_not_found",
1118
+ f"No job '{job_id}' in this workspace.",
1119
+ "Check the job_id, or start a new job; records expire after the TTL.",
1120
+ meta,
1121
+ offending="job_id",
1122
+ )
1123
+ )
1124
+ return _result(data)
1125
+
1126
+
1127
+ @mcp.tool(
1128
+ annotations=_LOCAL_MUTATION_ANNOTATIONS,
1129
+ title="Background job result",
1130
+ output_schema=RESULT_SCHEMA,
1131
+ )
1132
+ async def claude_job_result(
1133
+ job_id: Annotated[str, Field(description="A job_id from an *_async tool.")],
1134
+ workspace_root: Annotated[
1135
+ str | None,
1136
+ Field(description="Workspace the job belongs to (defaults like the async tools)."),
1137
+ ] = None,
1138
+ ctx: Context | None = None,
1139
+ ) -> ToolResult:
1140
+ """Fetch a finished background review without deleting the job record.
1141
+
1142
+ Use when claude_job_status reports result_available=true. Returns the same
1143
+ structured review envelope as claude_review_changes, with meta.job_id set. To
1144
+ fetch and delete the stored record, use claude_job_consume_result.
1145
+ """
1146
+ cwd, ws_err, ws_source = await _resolve_workspace(workspace_root, ctx)
1147
+ if ws_err:
1148
+ return _result(_workspace_error(ws_err, workspace_root))
1149
+ payload, found = await run_sync(lambda: jobs.result(cwd, job_id, False))
1150
+ if not found:
1151
+ meta = _meta(cwd, "inherit", "toolless", 0, 0, None, workspace_source=ws_source)
1152
+ return _result(
1153
+ _err(
1154
+ "job_not_found",
1155
+ f"No job '{job_id}' in this workspace.",
1156
+ "Check the job_id, or start a new job; records expire after the TTL.",
1157
+ meta,
1158
+ offending="job_id",
1159
+ )
1160
+ )
1161
+ return _result(payload)
1162
+
1163
+
1164
+ @mcp.tool(
1165
+ annotations=_LOCAL_MUTATION_ANNOTATIONS,
1166
+ title="Consume background job result",
1167
+ output_schema=RESULT_SCHEMA,
1168
+ )
1169
+ async def claude_job_consume_result(
1170
+ job_id: Annotated[str, Field(description="A job_id from an *_async tool.")],
1171
+ workspace_root: Annotated[
1172
+ str | None,
1173
+ Field(description="Workspace the job belongs to (defaults like the async tools)."),
1174
+ ] = None,
1175
+ ctx: Context | None = None,
1176
+ ) -> ToolResult:
1177
+ """Fetch a finished background review and delete the stored job record.
1178
+
1179
+ Use only when you no longer need to poll or re-read the job. Returns the same
1180
+ structured envelope as claude_job_result, then deletes completed job state.
1181
+ Non-done jobs are not deleted.
1182
+ """
1183
+ cwd, ws_err, ws_source = await _resolve_workspace(workspace_root, ctx)
1184
+ if ws_err:
1185
+ return _result(_workspace_error(ws_err, workspace_root))
1186
+ payload, found = await run_sync(lambda: jobs.result(cwd, job_id, True))
1187
+ if not found:
1188
+ meta = _meta(cwd, "inherit", "toolless", 0, 0, None, workspace_source=ws_source)
1189
+ return _result(
1190
+ _err(
1191
+ "job_not_found",
1192
+ f"No job '{job_id}' in this workspace.",
1193
+ "Check the job_id, or start a new job; records expire after the TTL.",
1194
+ meta,
1195
+ offending="job_id",
1196
+ )
1197
+ )
1198
+ return _result(payload)
1199
+
1200
+
1201
+ @mcp.tool(
1202
+ annotations=_LOCAL_MUTATION_ANNOTATIONS,
1203
+ title="Cancel background job",
1204
+ output_schema=JOB_STATUS_SCHEMA,
1205
+ )
1206
+ async def claude_job_cancel(
1207
+ job_id: Annotated[str, Field(description="A job_id from an *_async tool.")],
1208
+ workspace_root: Annotated[
1209
+ str | None,
1210
+ Field(description="Workspace the job belongs to (defaults like the async tools)."),
1211
+ ] = None,
1212
+ ctx: Context | None = None,
1213
+ ) -> ToolResult:
1214
+ """Cancel a running background review job.
1215
+
1216
+ Use to stop a job from claude_review_changes_async. Terminates the Claude
1217
+ process and marks the job cancelled; cancelled jobs cannot be resumed.
1218
+ Already-terminal jobs are returned unchanged.
1219
+ """
1220
+ cwd, ws_err, ws_source = await _resolve_workspace(workspace_root, ctx)
1221
+ if ws_err:
1222
+ return _result(_workspace_error(ws_err, workspace_root))
1223
+ data = await run_sync(lambda: jobs.cancel(cwd, job_id))
1224
+ if data is None:
1225
+ meta = _meta(cwd, "inherit", "toolless", 0, 0, None, workspace_source=ws_source)
1226
+ return _result(
1227
+ _err(
1228
+ "job_not_found",
1229
+ f"No job '{job_id}' in this workspace.",
1230
+ "Check the job_id, or start a new job; records expire after the TTL.",
1231
+ meta,
1232
+ offending="job_id",
1233
+ )
1234
+ )
1235
+ return _result(data)
1236
+
1237
+
1238
+ @mcp.tool(
1239
+ annotations=_FREE_READ_ANNOTATIONS,
1240
+ title="Preview review context (no spend)",
1241
+ output_schema=DRY_RUN_SCHEMA,
1242
+ )
1243
+ async def claude_review_dry_run(
1244
+ scope: Annotated[Scope, Field(description="working_tree|staged|branch")],
1245
+ base: Annotated[str, Field(description="Base ref for scope=branch.")] = "main",
1246
+ workspace_root: Annotated[
1247
+ str | None,
1248
+ Field(
1249
+ description="Absolute path to the repo/workspace. If omitted, the server "
1250
+ "uses the client's first MCP root, else its own cwd."
1251
+ ),
1252
+ ] = None,
1253
+ ctx: Context | None = None,
1254
+ ) -> ToolResult:
1255
+ """Preview what a diff review WOULD send, free and without calling Claude.
1256
+
1257
+ Use before a paid claude_review_changes to confirm the resolved workspace,
1258
+ diff byte size, whether it would be truncated, and how many secret-looking
1259
+ files would be redacted. Read-only; makes no paid call.
1260
+ """
1261
+ cwd, ws_err, ws_source = await _resolve_workspace(workspace_root, ctx)
1262
+ if ws_err:
1263
+ return _result(_workspace_error(ws_err, workspace_root))
1264
+ meta = _meta(cwd, "inherit", "toolless", 0, 0, None, scope, base, workspace_source=ws_source)
1265
+ try:
1266
+ ctx_data = await run_sync(lambda: gather_context(cwd, scope=scope, base=base))
1267
+ except InvalidBaseError:
1268
+ return _result(
1269
+ _err(
1270
+ "invalid_base",
1271
+ f"Invalid base ref '{base}'.",
1272
+ "Use an existing git ref matching [A-Za-z0-9._/-]+ that does not start with '-'.",
1273
+ meta,
1274
+ offending="base",
1275
+ )
1276
+ )
1277
+ except InvalidScopeError:
1278
+ return _result(
1279
+ _err(
1280
+ "invalid_scope",
1281
+ f"Invalid scope '{scope}'.",
1282
+ "Use working_tree, staged, or branch.",
1283
+ meta,
1284
+ offending="scope",
1285
+ )
1286
+ )
1287
+ except RuntimeError as e:
1288
+ return _result(
1289
+ _err(
1290
+ "internal_error",
1291
+ f"git failed: {e}",
1292
+ "Ensure cwd is a git repo and base ref exists.",
1293
+ meta,
1294
+ )
1295
+ )
1296
+ result = DryRunResult(
1297
+ cwd=cwd,
1298
+ workspace_source=ws_source,
1299
+ workspace_warning=workspace_warning_for(ws_source, cwd),
1300
+ scope=scope,
1301
+ base=base,
1302
+ context_summary=ctx_data.summary,
1303
+ diff_bytes=ctx_data.diff_bytes,
1304
+ max_diff_bytes=MAX_DIFF_BYTES,
1305
+ truncated=ctx_data.truncated,
1306
+ truncation_hint=ctx_data.truncation_hint,
1307
+ redacted_paths_count=len(ctx_data.redacted_paths),
1308
+ redacted_paths=ctx_data.redacted_paths,
1309
+ )
1310
+ return _result(result.model_dump(mode="json", exclude_none=True))
1311
+
1312
+
1313
+ @mcp.tool(
1314
+ annotations=_LOCAL_MUTATION_ANNOTATIONS,
1315
+ title="List background jobs",
1316
+ output_schema=JOB_LIST_SCHEMA,
1317
+ )
1318
+ async def claude_job_list(
1319
+ workspace_root: Annotated[
1320
+ str | None,
1321
+ Field(description="Workspace whose jobs to list (defaults like the async tools)."),
1322
+ ] = None,
1323
+ ctx: Context | None = None,
1324
+ ) -> ToolResult:
1325
+ """List the background review jobs known for this workspace, newest first.
1326
+
1327
+ Use to recover job_ids lost across context compaction or interruption. Returns
1328
+ each job's id, kind, status, start time, result_available, expiry, and cost when
1329
+ terminal. Like the other lifecycle tools it refreshes statuses (not read-only).
1330
+ """
1331
+ cwd, ws_err, _ = await _resolve_workspace(workspace_root, ctx)
1332
+ if ws_err:
1333
+ return _result(_workspace_error(ws_err, workspace_root))
1334
+ data = await run_sync(lambda: jobs.list_jobs(cwd))
1335
+ return _result(data)
1336
+
1337
+
1338
+ @mcp.tool(
1339
+ annotations=_FREE_READ_ANNOTATIONS,
1340
+ title="Claude CLI status & defaults",
1341
+ output_schema=STATUS_SCHEMA,
1342
+ )
1343
+ def claude_status() -> ToolResult:
1344
+ """Check Claude CLI readiness and resolved defaults before spending.
1345
+
1346
+ Free and read-only. Use first when unsure whether paid tools can run, or to
1347
+ inspect config_mode/access/model/effort/budget/timeout defaults.
1348
+ """
1349
+ found = shutil.which(cli_contract.CLAUDE_BIN) is not None
1350
+ version = None
1351
+ authenticated: bool | None = None
1352
+ auth_detail: str | None = None
1353
+ supported: bool | None = None
1354
+ version_warning: str | None = None
1355
+ flags_warning: str | None = None
1356
+ if found:
1357
+ try:
1358
+ version = subprocess.run(
1359
+ [cli_contract.CLAUDE_BIN, *cli_contract.VERSION_ARGS],
1360
+ capture_output=True,
1361
+ text=True,
1362
+ timeout=10,
1363
+ check=False,
1364
+ ).stdout.strip()
1365
+ except Exception:
1366
+ version = None
1367
+ supported = version_supported(version)
1368
+ if supported is False:
1369
+ version_warning = (
1370
+ f"installed claude version {version!r} is outside this plugin's "
1371
+ f"tested major(s) {sorted(supported_majors())}; tools may still work — "
1372
+ "file an issue if they do not, or set "
1373
+ f"{cli_contract.SUPPORTED_MAJORS_ENV} to silence this"
1374
+ )
1375
+ # Free auth probe: lets an agent discover a logged-out CLI before
1376
+ # spending money on a paid call that would only then fail auth.
1377
+ authenticated, auth_detail = auth_status()
1378
+ # Free flag-contract probe: warn if a guarantee-bearing flag is missing
1379
+ # from `claude --help` (an early drift signal), without gating execution.
1380
+ missing = preflight.missing_expected_flags(preflight.flag_support())
1381
+ if missing:
1382
+ flags_warning = (
1383
+ "claude --help did not list expected flags: "
1384
+ f"{', '.join(missing)}; the plugin may need an update for your "
1385
+ "claude version"
1386
+ )
1387
+ d = defaults()
1388
+ resolved = ResolvedDefaults(
1389
+ config_mode=cast(
1390
+ "ConfigMode",
1391
+ d.config_mode if d.config_mode in ("inherit", "scoped", "bare") else "inherit",
1392
+ ),
1393
+ access=cast("Access", d.access if d.access in ("toolless", "readonly") else "toolless"),
1394
+ model=d.model,
1395
+ effort=cast("Effort", sanitize_effort(d.effort)),
1396
+ max_budget_usd=clamp_budget(d.max_budget_usd),
1397
+ timeout_seconds=clamp_timeout(d.timeout_seconds),
1398
+ budget_bounds=[MIN_BUDGET_USD, MAX_BUDGET_USD],
1399
+ timeout_bounds=[MIN_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS],
1400
+ practical_min_budget_hint=PRACTICAL_MIN_BUDGET_HINT,
1401
+ )
1402
+ status = StatusResult(
1403
+ claude_found=found,
1404
+ claude_version=version,
1405
+ claude_authenticated=authenticated,
1406
+ auth_detail=auth_detail,
1407
+ version_supported=supported,
1408
+ version_warning=version_warning,
1409
+ flags_warning=flags_warning,
1410
+ # Version is advisory, not gating: a major outside the tested range warns
1411
+ # (version_warning) but does not flip ready, so a claude major bump no
1412
+ # longer self-blocks an authenticated, installed CLI.
1413
+ ready=bool(found and authenticated),
1414
+ config_modes_available={
1415
+ "inherit": True,
1416
+ "scoped": True,
1417
+ "bare": bare_available(),
1418
+ },
1419
+ resolved_defaults=resolved,
1420
+ caveat=(
1421
+ "OAuth-preserving + CLAUDE.md-free is impossible in claude 2.1.x; "
1422
+ "config_mode=bare needs ANTHROPIC_API_KEY."
1423
+ ),
1424
+ )
1425
+ return _result(status.model_dump(mode="json", exclude_none=True))
1426
+
1427
+
1428
+ def _capabilities_payload() -> dict:
1429
+ """Build the capability contract. Shared by cc_codex_capabilities and its
1430
+ claude_capabilities alias so the two tools cannot drift."""
1431
+
1432
+ def tool_detail(
1433
+ name: str,
1434
+ cost: Literal["free", "paid"],
1435
+ use_when: str,
1436
+ returns: str,
1437
+ required: list[str] | None = None,
1438
+ optional: list[str] | None = None,
1439
+ ) -> ToolCapability:
1440
+ return ToolCapability(
1441
+ name=name,
1442
+ cost=cost,
1443
+ use_when=use_when,
1444
+ required_params=required or [],
1445
+ key_optional_params=optional or [],
1446
+ returns=returns,
1447
+ )
1448
+
1449
+ result = CapabilitiesResult(
1450
+ name="cc-plugin-codex",
1451
+ version=__version__,
1452
+ transport="stdio",
1453
+ stability="experimental",
1454
+ paid_tools=[
1455
+ "claude_ask",
1456
+ "claude_review_changes",
1457
+ "claude_adversarial_review",
1458
+ "claude_review_changes_async",
1459
+ ],
1460
+ free_tools=[
1461
+ "claude_status",
1462
+ "cc_codex_capabilities",
1463
+ "claude_capabilities",
1464
+ "claude_review_dry_run",
1465
+ "claude_job_status",
1466
+ "claude_job_result",
1467
+ "claude_job_consume_result",
1468
+ "claude_job_cancel",
1469
+ "claude_job_list",
1470
+ ],
1471
+ tool_details=[
1472
+ tool_detail(
1473
+ "claude_status",
1474
+ "free",
1475
+ "Check CLI readiness, auth, version warnings, defaults, and budget guidance.",
1476
+ "readiness booleans plus resolved defaults and practical budget hint",
1477
+ ),
1478
+ tool_detail(
1479
+ "claude_review_dry_run",
1480
+ "free",
1481
+ "Preview diff workspace, size, truncation, and redaction before paying.",
1482
+ "diff byte count, context summary, truncation state, and redacted paths",
1483
+ required=["scope"],
1484
+ optional=["base", "workspace_root"],
1485
+ ),
1486
+ tool_detail(
1487
+ "claude_ask",
1488
+ "paid",
1489
+ "Ask for a second opinion on a question or design choice.",
1490
+ "structured verdict, findings, questions, assumptions, next steps, cost, and usage",
1491
+ required=["prompt"],
1492
+ optional=[
1493
+ "context",
1494
+ "workspace_root",
1495
+ "effort",
1496
+ "max_budget_usd",
1497
+ "timeout_seconds",
1498
+ ],
1499
+ ),
1500
+ tool_detail(
1501
+ "claude_review_changes",
1502
+ "paid",
1503
+ "Review working_tree, staged, or branch git diff synchronously.",
1504
+ "structured review result; empty diffs return without spending",
1505
+ required=["scope"],
1506
+ optional=[
1507
+ "base",
1508
+ "focus",
1509
+ "workspace_root",
1510
+ "effort",
1511
+ "max_budget_usd",
1512
+ "timeout_seconds",
1513
+ ],
1514
+ ),
1515
+ tool_detail(
1516
+ "claude_adversarial_review",
1517
+ "paid",
1518
+ "Pressure-test a plan, claim, or decision; optionally attach a diff.",
1519
+ "structured counterarguments, risks, questions, assumptions, cost, and usage",
1520
+ required=["target"],
1521
+ optional=[
1522
+ "evidence",
1523
+ "scope",
1524
+ "base",
1525
+ "workspace_root",
1526
+ "effort",
1527
+ "max_budget_usd",
1528
+ "timeout_seconds",
1529
+ ],
1530
+ ),
1531
+ tool_detail(
1532
+ "claude_review_changes_async",
1533
+ "paid",
1534
+ "Start a background diff review for long-running reviews.",
1535
+ "job_id, status, polling hint, deadline, TTL, and resolved meta",
1536
+ required=["scope"],
1537
+ optional=[
1538
+ "base",
1539
+ "focus",
1540
+ "workspace_root",
1541
+ "effort",
1542
+ "max_budget_usd",
1543
+ ],
1544
+ ),
1545
+ tool_detail(
1546
+ "claude_job_status",
1547
+ "free",
1548
+ "Poll a background job without fetching the full result.",
1549
+ "job state, result_available, elapsed time, expiry, cost when terminal",
1550
+ required=["job_id"],
1551
+ optional=["workspace_root"],
1552
+ ),
1553
+ tool_detail(
1554
+ "claude_job_result",
1555
+ "free",
1556
+ "Fetch a finished background job result without deleting it.",
1557
+ "same structured envelope as claude_review_changes, with meta.job_id",
1558
+ required=["job_id"],
1559
+ optional=["workspace_root"],
1560
+ ),
1561
+ tool_detail(
1562
+ "claude_job_consume_result",
1563
+ "free",
1564
+ "Fetch and delete a finished background job record.",
1565
+ "same structured envelope as claude_job_result; removes terminal state",
1566
+ required=["job_id"],
1567
+ optional=["workspace_root"],
1568
+ ),
1569
+ tool_detail(
1570
+ "claude_job_cancel",
1571
+ "free",
1572
+ "Cancel a running background review job.",
1573
+ "job status after cancellation or terminal-state refresh",
1574
+ required=["job_id"],
1575
+ optional=["workspace_root"],
1576
+ ),
1577
+ tool_detail(
1578
+ "claude_job_list",
1579
+ "free",
1580
+ "Recover job IDs or inspect known jobs for a workspace.",
1581
+ "compact job summaries newest first",
1582
+ optional=["workspace_root"],
1583
+ ),
1584
+ ],
1585
+ config_modes=["inherit", "scoped", "bare"],
1586
+ access_modes=["toolless", "readonly"],
1587
+ scope=[
1588
+ "independent code review of a git diff",
1589
+ "adversarial review of a plan/claim",
1590
+ "a free-form independent second opinion",
1591
+ "background diff review with poll/result/cancel for long runs",
1592
+ "a free dry-run preview of workspace, diff size, and redaction before paying",
1593
+ ],
1594
+ negative_scope=[
1595
+ "does NOT edit code or run shell",
1596
+ "does NOT act as a general Claude chat",
1597
+ "does NOT proxy Claude's own MCP tools",
1598
+ "does NOT resume a call once it ends or is cancelled",
1599
+ "does NOT guarantee secret removal; diff redaction is best-effort and "
1600
+ "access=readonly lets Claude read workspace files directly",
1601
+ ],
1602
+ prerequisites=[
1603
+ "the `claude` CLI installed and authenticated",
1604
+ "git, for the diff-bearing tools",
1605
+ "ANTHROPIC_API_KEY only for config_mode=bare",
1606
+ ],
1607
+ deprecation_policy=(
1608
+ "Deprecated tools remain discoverable during their compatibility window "
1609
+ "with replacement guidance; removals/renames and schema/error changes "
1610
+ "bump the fingerprint."
1611
+ ),
1612
+ )
1613
+ return result.model_dump(mode="json", exclude_none=True)
1614
+
1615
+
1616
+ @mcp.tool(
1617
+ annotations=_FREE_READ_ANNOTATIONS,
1618
+ title="cc-plugin-codex capabilities",
1619
+ output_schema=CAPABILITIES_SCHEMA,
1620
+ )
1621
+ def cc_codex_capabilities() -> ToolResult:
1622
+ """Return the compact capability contract for this server.
1623
+
1624
+ Free and read-only. Call first when unsure which tool to use. Includes tool
1625
+ inventory, scope/negative-scope, prerequisites, modes, deprecation policy, and
1626
+ fingerprint. Also available as claude_capabilities.
1627
+ """
1628
+ return _result(_capabilities_payload())
1629
+
1630
+
1631
+ @mcp.tool(
1632
+ annotations=_FREE_READ_ANNOTATIONS,
1633
+ title="Claude review capabilities",
1634
+ output_schema=CAPABILITIES_SCHEMA,
1635
+ )
1636
+ def claude_capabilities() -> ToolResult:
1637
+ """Alias of cc_codex_capabilities: the Claude review/critique capability contract.
1638
+
1639
+ Free and read-only. Discoverable under a claude_* name; returns the identical
1640
+ contract as cc_codex_capabilities.
1641
+ """
1642
+ return _result(_capabilities_payload())
1643
+
1644
+
1645
+ @mcp.resource("cc-plugin-codex://capabilities")
1646
+ def capabilities() -> str:
1647
+ """Server capability summary, negative scope, and prerequisites."""
1648
+ return CAPABILITY_SUMMARY
1649
+
1650
+
1651
+ def main() -> None:
1652
+ mcp.run(transport="stdio")
1653
+
1654
+
1655
+ if __name__ == "__main__": # pragma: no cover - module entrypoint
1656
+ main()