codereview-loop 0.13.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.
codereview/intake.py ADDED
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env python3
2
+ """codereview intake — issue report in, solution plan out.
3
+
4
+ The front half of the workflow. Everything else in codereview
5
+ starts from a code change that already exists; intake starts from
6
+ the problem. An issue report (Markdown) plus optional context
7
+ files goes to one or more solver models, and each returns a
8
+ solution plan in the shape a builder can execute: what to check
9
+ first, ranked hypotheses — each with the test that settles it — a
10
+ concrete fix plan, prevention, risks and rollback, and closure
11
+ criteria a verifier can run.
12
+
13
+ The primary solver (the first configured seat that answers)
14
+ becomes solution.md; every other solver's plan is kept alongside
15
+ as an alternate for the builder to compare. Intake never writes
16
+ code and never gates anything: the builder reads the plan,
17
+ implements it, and the review loop gates the resulting change
18
+ exactly as before.
19
+
20
+ Report and context are secret-redacted before they leave the
21
+ machine and size-capped; every truncation is recorded in the
22
+ manifest (intake.json) and disclosed inside the prompt itself, so
23
+ a solver never mistakes a cut-off file for a complete one.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import json
30
+ import os
31
+ import time
32
+ from concurrent.futures import ThreadPoolExecutor
33
+ from datetime import datetime, timezone
34
+
35
+ from .core import VERSION, redact_secrets
36
+
37
+ MAX_REPORT_CHARS = 60000
38
+ MAX_CONTEXT_FILE_CHARS = 40000
39
+ MAX_TOTAL_CONTEXT_CHARS = 120000
40
+ MAX_SOLVER_WORKERS = 8
41
+ DEFAULT_SOLVER_TIMEOUT = 300
42
+
43
+ _INSTRUCTIONS = """You are the solver in a software-maintenance workflow. A coding agent (the builder) will execute your plan on the real system, and an independent multi-model review will gate the resulting change. Your plan is the only guidance the builder gets, so it must be executable as written.
44
+
45
+ Write the solution plan in Markdown with exactly these sections:
46
+
47
+ # Solution — <short title naming the problem>
48
+
49
+ ## Diagnosis — what to check first, in order
50
+ ## Hypotheses — ranked most likely first; for each: the evidence for it, and the concrete test or observation that would prove or disprove it
51
+ ## Fix plan — numbered steps the builder can execute in order; name files, functions, and commands when the material supports it
52
+ ## Prevention — the guard, alarm, or test that keeps this from recurring silently
53
+ ## Risks and rollback
54
+ ## Closure criteria — a checklist a verifier can run to confirm the issue is actually closed
55
+
56
+ Rules:
57
+ - Ground every claim in the issue report and context below. Label inferences as inferences; if you are guessing, say so.
58
+ - Do not invent file names, function names, line numbers, or log lines that are not in the material. Where the material is silent, make the missing check a diagnosis step instead of assuming its result.
59
+ - Prefer the smallest fix that addresses the root cause. Where two hypotheses imply different fixes, order the plan so the builder tests before committing to one.
60
+ - If the report shows this issue was already "fixed" before and recurred, treat the recurrence itself as the primary defect: the plan must explain why the earlier fix failed or was incomplete, not just repeat it."""
61
+
62
+
63
+ def _cap(text, limit):
64
+ """(sent_text, truncated) — cut with a disclosed marker."""
65
+ if len(text) <= limit:
66
+ return text, False
67
+ return text[:limit] + "\n\n[truncated by codereview intake: " \
68
+ f"{len(text) - limit} chars omitted]", True
69
+
70
+
71
+ def build_solver_prompt(report, contexts=(), repo_tree=""):
72
+ """Assemble the solver prompt from already-redacted material.
73
+
74
+ Two cap layers, by design: _prepare caps the report and each
75
+ context file individually (and records it in the manifest);
76
+ this builder additionally enforces the total context budget
77
+ across files, disclosing any further cut in the prompt.
78
+
79
+ contexts: [(label, text)] — texts are per-file capped here; the
80
+ running total is capped as well and later files disclose it."""
81
+ parts = [_INSTRUCTIONS, "", "## Issue report", "", report]
82
+ if repo_tree:
83
+ parts += ["", "## Repository layout", "", repo_tree]
84
+ total = 0
85
+ for label, text in contexts:
86
+ sent, truncated = _cap(text, MAX_CONTEXT_FILE_CHARS)
87
+ if total + len(sent) > MAX_TOTAL_CONTEXT_CHARS:
88
+ sent, truncated = _cap(
89
+ text, max(0, MAX_TOTAL_CONTEXT_CHARS - total))
90
+ total += len(sent)
91
+ parts += ["", f"## Context: {label}", "", sent]
92
+ if truncated:
93
+ parts.append(f"[note: context {label} was truncated "
94
+ "to fit the prompt budget]")
95
+ return "\n".join(parts)
96
+
97
+
98
+ def _prepare(report, contexts, repo_tree=""):
99
+ """Redact + cap the raw inputs; return (prompt, meta)."""
100
+ clean_report, rep_trunc = _cap(redact_secrets(report),
101
+ MAX_REPORT_CHARS)
102
+ clean_contexts = []
103
+ ctx_meta = []
104
+ for label, text in contexts:
105
+ clean = redact_secrets(text)
106
+ sent, truncated = _cap(clean, MAX_CONTEXT_FILE_CHARS)
107
+ clean_contexts.append((label, sent))
108
+ ctx_meta.append({"label": label, "chars": len(text),
109
+ "sent_chars": len(sent),
110
+ "truncated": truncated})
111
+ prompt = build_solver_prompt(clean_report, clean_contexts,
112
+ repo_tree=repo_tree)
113
+ meta = {
114
+ "report": {"chars": len(report), "sent_chars": len(clean_report),
115
+ "truncated": rep_trunc,
116
+ "sha256": hashlib.sha256(
117
+ report.encode("utf-8")).hexdigest()},
118
+ "contexts": ctx_meta,
119
+ "prompt_chars": len(prompt),
120
+ }
121
+ return prompt, meta
122
+
123
+
124
+ def run_intake(report, solvers, contexts=(), repo_tree="",
125
+ timeout=DEFAULT_SOLVER_TIMEOUT):
126
+ """Run every solver over the report in parallel.
127
+
128
+ solvers: {seat name: callable(prompt) -> markdown text}; dict
129
+ order is the configuration order and decides the primary.
130
+ A solver that has not answered within `timeout` seconds is
131
+ recorded as failed ("timed out") instead of blocking the run:
132
+ the provider HTTP layer already times out at 240s, and this
133
+ deadline is the outer guard for callables that ignore theirs.
134
+ Returns the intake result dict (see write_intake)."""
135
+ prompt, meta = _prepare(report, contexts, repo_tree=repo_tree)
136
+
137
+ def call(name, fn):
138
+ started = time.monotonic()
139
+ try:
140
+ text = fn(prompt)
141
+ return {"name": name, "ok": True,
142
+ "seconds": round(time.monotonic() - started, 1),
143
+ "chars": len(text or ""), "text": text or "",
144
+ "error": ""}
145
+ except Exception as exc: # noqa: BLE001 - solver failure is data
146
+ return {"name": name, "ok": False,
147
+ "seconds": round(time.monotonic() - started, 1),
148
+ "chars": 0, "text": "",
149
+ "error": str(exc)[:300]}
150
+
151
+ solutions = []
152
+ if solvers:
153
+ workers = max(1, min(len(solvers), MAX_SOLVER_WORKERS))
154
+ pool = ThreadPoolExecutor(max_workers=workers)
155
+ try:
156
+ futures = {name: pool.submit(call, name, fn)
157
+ for name, fn in solvers.items()}
158
+ deadline = time.monotonic() + timeout
159
+ by_name = {}
160
+ for name, fut in futures.items():
161
+ try:
162
+ by_name[name] = fut.result(
163
+ timeout=max(0.0, deadline - time.monotonic()))
164
+ except TimeoutError:
165
+ by_name[name] = {
166
+ "name": name, "ok": False, "seconds": timeout,
167
+ "chars": 0, "text": "",
168
+ "error": f"timed out after {timeout}s"}
169
+ solutions = [by_name[name] for name in solvers]
170
+ finally:
171
+ # Never block the run on a hung solver thread; a stray
172
+ # thread may outlive the call but holds no shared state.
173
+ pool.shutdown(wait=False, cancel_futures=True)
174
+ primary = next((s["name"] for s in solutions if s["ok"]), None)
175
+ return {"tool": "codereview", "version": VERSION, "kind": "intake",
176
+ "created": datetime.now(timezone.utc).isoformat(),
177
+ "ok": primary is not None, "primary": primary,
178
+ "solutions": solutions, **meta}
179
+
180
+
181
+ def write_intake(result, out_dir, report_name=""):
182
+ """Write solution.md (primary), alternates, and intake.json.
183
+
184
+ Returns (solution_path or None, manifest_path)."""
185
+ os.makedirs(out_dir, exist_ok=True)
186
+ manifest = {k: v for k, v in result.items() if k != "solutions"}
187
+ manifest["report"] = {**result["report"], "name": report_name}
188
+ manifest["solvers"] = [
189
+ {k: s[k] for k in ("name", "ok", "seconds", "chars", "error")}
190
+ for s in result["solutions"]]
191
+ solution_path = None
192
+ for s in result["solutions"]:
193
+ if not s["ok"]:
194
+ continue
195
+ header = (f"<!-- codereview intake {result['version']} · "
196
+ f"solver: {s['name']} · report: {report_name} · "
197
+ f"{result['created']} -->\n\n")
198
+ if s["name"] == result["primary"]:
199
+ solution_path = os.path.join(out_dir, "solution.md")
200
+ path = solution_path
201
+ else:
202
+ path = os.path.join(out_dir, f"solution-{s['name']}.md")
203
+ with open(path, "w", encoding="utf-8") as fh:
204
+ fh.write(header + s["text"].strip() + "\n")
205
+ manifest_path = os.path.join(out_dir, "intake.json")
206
+ with open(manifest_path, "w", encoding="utf-8") as fh:
207
+ json.dump(manifest, fh, ensure_ascii=False, indent=1)
208
+ return solution_path, manifest_path
@@ -0,0 +1,455 @@
1
+ """MCP tool layer (Epic L2): the gate, served to other agents.
2
+
3
+ `codereview mcp serve` speaks the Model Context Protocol over
4
+ stdio (transport: codereview.mcp_transport) and exposes two
5
+ tools:
6
+
7
+ - `run_gate` — a diff (patch text, or a git range in a repo)
8
+ plus an optional policy path → the gate's verdict and the
9
+ run record's path;
10
+ - `get_verdict` — a stored record (file or directory) → the
11
+ verdict summary and findings.
12
+
13
+ Serving only, by design: the server is a new front door onto
14
+ the EXISTING gate, not a new gate. A run goes through the
15
+ CLI's own `main()` with the arguments translated, so a verdict
16
+ served here is the CLI's verdict on the same change — same
17
+ panel resolution, same saved-setup and environment discipline,
18
+ same record on disk. The review path itself consumes no MCP
19
+ tools and reviewers stay read-only and tool-less (Epic F2
20
+ unchanged).
21
+
22
+ The server holds no credentials of its own: seats come only
23
+ from the user's saved setup (or $CODEREVIEW_PROVIDERS), exactly
24
+ as for the CLI. With no setup, run_gate answers with the CLI's
25
+ own not-set-up message — there is no built-in panel to fall
26
+ back to, here or anywhere.
27
+
28
+ Fail-closed: a malformed tool call (wrong types, unknown
29
+ arguments, patch+range together) is refused with an error
30
+ result before anything runs; a refused or failed run is an
31
+ `isError` tool result carrying the reason, never an exception
32
+ escaping into the transport loop.
33
+ """
34
+
35
+ import contextlib
36
+ import io
37
+ import json
38
+ import os
39
+ import re
40
+ import tempfile
41
+
42
+ from . import __version__, hardening
43
+ from .mcp_transport import (INVALID_PARAMS, METHOD_NOT_FOUND,
44
+ JsonRpcError, StdioServer)
45
+
46
+ # Protocol versions this subset implements, newest first. The
47
+ # handshake echoes the client's version when it is one of
48
+ # these and answers with the newest otherwise (the MCP
49
+ # negotiation rule).
50
+ PROTOCOL_VERSIONS = ("2025-03-26", "2024-11-05")
51
+
52
+ SERVER_INFO = {"name": "codereview", "version": __version__}
53
+
54
+ TOOL_DEFS = [
55
+ {
56
+ "name": "run_gate",
57
+ "description": (
58
+ "Run the CodeReview gate on a change and return "
59
+ "the verdict plus the run record's path. Give "
60
+ "either `patch` (unified diff text) or `range` "
61
+ "(a git range such as HEAD~1..HEAD, evaluated in "
62
+ "`repo`); with neither, the repo's working-tree "
63
+ "change is reviewed, as with the CLI. Seats come "
64
+ "from the operator's saved CodeReview setup — "
65
+ "with no setup the call fails with the setup "
66
+ "instructions, exactly like the CLI."),
67
+ "inputSchema": {
68
+ "type": "object",
69
+ "properties": {
70
+ "repo": {
71
+ "type": "string",
72
+ "description": "repository path (default: "
73
+ "the server's working "
74
+ "directory)",
75
+ },
76
+ "range": {
77
+ "type": "string",
78
+ "description": "git diff range, e.g. "
79
+ "main...HEAD",
80
+ },
81
+ "patch": {
82
+ "type": "string",
83
+ "description": "unified diff text (mutually "
84
+ "exclusive with range)",
85
+ },
86
+ "policy": {
87
+ "type": "string",
88
+ "description": "project policy file (TOML); "
89
+ "default: .codereview.toml "
90
+ "at the repo root, when "
91
+ "present",
92
+ },
93
+ "trust_repo_policy": {
94
+ "type": "boolean",
95
+ "description": "honour a policy discovered "
96
+ "in the reviewed repository "
97
+ "in full, including its hooks "
98
+ "and gate-weakening keys; "
99
+ "without it a discovered "
100
+ "policy keeps only its "
101
+ "strengthening keys (P3-01 "
102
+ "trust restriction) and the "
103
+ "dropped keys are returned as "
104
+ "policy_ignored_keys",
105
+ },
106
+ "out": {
107
+ "type": "string",
108
+ "description": "output directory for the "
109
+ "run record (default: "
110
+ "<repo>/codereview-out)",
111
+ },
112
+ "context": {
113
+ "type": "string",
114
+ "description": "builder's change "
115
+ "description, shown to the "
116
+ "reviewers",
117
+ },
118
+ },
119
+ "additionalProperties": False,
120
+ },
121
+ },
122
+ {
123
+ "name": "get_verdict",
124
+ "description": (
125
+ "Read a stored CodeReview run record and return "
126
+ "its verdict summary and findings. `record` is a "
127
+ "record JSON file or the directory holding it "
128
+ "(the newest timestamped record in the directory "
129
+ "wins, as with the CLI's record arguments)."),
130
+ "inputSchema": {
131
+ "type": "object",
132
+ "properties": {
133
+ "record": {
134
+ "type": "string",
135
+ "description": "record JSON file or "
136
+ "record directory",
137
+ },
138
+ "repo": {
139
+ "type": "string",
140
+ "description": "repository the record "
141
+ "belongs to (default: the "
142
+ "server's working "
143
+ "directory)",
144
+ },
145
+ },
146
+ "required": ["record"],
147
+ "additionalProperties": False,
148
+ },
149
+ },
150
+ ]
151
+
152
+
153
+ class ToolError(Exception):
154
+ """A tool-level refusal or failure: returned to the client
155
+ as an isError tool result, never raised into the
156
+ transport."""
157
+
158
+
159
+ _FINDING_FIELDS = ("id", "severity", "category", "file", "line",
160
+ "title", "recommendation", "confidence")
161
+
162
+
163
+ def _finding_summary(finding):
164
+ return {key: finding.get(key) for key in _FINDING_FIELDS}
165
+
166
+
167
+ def _verdict_payload(record, record_path):
168
+ """The verdict shape both tools answer with: the gate,
169
+ its reasons, the findings (summarised — the full record
170
+ stays on disk at record_path), and severity counts.
171
+
172
+ Policy visibility (P3-01 follow-ups): the payload also
173
+ carries the record's policy origin, its self-modification
174
+ flag, and — since the trust restriction landed — the keys
175
+ dropped from a repo-discovered policy. The CLI's stderr
176
+ warning is captured and discarded on a successful
177
+ run_gate, so without these fields an MCP client could not
178
+ tell that the verdict was produced under a policy
179
+ discovered in the reviewed repo, which of its keys were
180
+ ignored, or that the change touched the policy file
181
+ itself. The keys are always present (null / [] when the
182
+ run had no policy section) so clients can rely on the
183
+ shape."""
184
+ findings = [_finding_summary(f)
185
+ for f in record.get("findings") or []]
186
+ by_severity = {}
187
+ for finding in findings:
188
+ sev = finding["severity"] or "unknown"
189
+ by_severity[sev] = by_severity.get(sev, 0) + 1
190
+ policy = record.get("policy") or {}
191
+ return {
192
+ "gate": record.get("gate"),
193
+ "gate_reasons": record.get("gate_reasons") or [],
194
+ "mode": record.get("mode"),
195
+ "degraded": bool(record.get("degraded")),
196
+ "policy_origin": policy.get("origin"),
197
+ "policy_ignored_keys": policy.get("ignored_keys") or [],
198
+ "policy_self_modification":
199
+ record.get("policy_self_modification") or [],
200
+ "record_path": record_path,
201
+ "findings": findings,
202
+ "counts": {
203
+ "total": len(findings),
204
+ "blocking": by_severity.get("blocker", 0)
205
+ + by_severity.get("major", 0),
206
+ "by_severity": by_severity,
207
+ },
208
+ }
209
+
210
+
211
+ class GateTools:
212
+ """The two gate tools. The *_override seams are the CLI's
213
+ own (tests and internal runners inject stub seats through
214
+ them); a production server is constructed with none, so
215
+ seats resolve from the saved setup / environment exactly
216
+ as a CLI run's do."""
217
+
218
+ def __init__(self, providers_override=None,
219
+ seat_meta_override=None, fallbacks_override=None):
220
+ self._providers_override = providers_override
221
+ self._seat_meta_override = seat_meta_override
222
+ self._fallbacks_override = fallbacks_override
223
+
224
+ # -- argument discipline -------------------------------------
225
+
226
+ @staticmethod
227
+ def _checked_args(tool, arguments):
228
+ if arguments is None:
229
+ arguments = {}
230
+ if not isinstance(arguments, dict):
231
+ raise ToolError(
232
+ f"{tool}: arguments must be an object")
233
+ schema = next(t["inputSchema"] for t in TOOL_DEFS
234
+ if t["name"] == tool)
235
+ allowed = schema["properties"]
236
+ for key, value in arguments.items():
237
+ if key not in allowed:
238
+ raise ToolError(
239
+ f"{tool}: unknown argument {key!r} "
240
+ f"(allowed: {', '.join(sorted(allowed))})")
241
+ if allowed[key].get("type") == "boolean":
242
+ if not isinstance(value, bool):
243
+ raise ToolError(
244
+ f"{tool}: argument {key!r} must be "
245
+ f"a boolean")
246
+ elif not isinstance(value, str):
247
+ raise ToolError(
248
+ f"{tool}: argument {key!r} must be a string")
249
+ for key in schema.get("required", []):
250
+ if key not in arguments:
251
+ raise ToolError(
252
+ f"{tool}: missing required argument {key!r}")
253
+ return arguments
254
+
255
+ # -- run_gate --------------------------------------------------
256
+
257
+ def run_gate(self, arguments):
258
+ from . import cli, userconfig
259
+ args = self._checked_args("run_gate", arguments)
260
+ if args.get("patch") and args.get("range"):
261
+ raise ToolError(
262
+ "run_gate: give either patch or range, not both")
263
+ # P3-09: same repo validation as the CLI — an
264
+ # explicitly given repo must be an existing directory;
265
+ # "" must not silently become the server's CWD and a
266
+ # nonexistent path must not silently skip the project
267
+ # context in patch mode.
268
+ if "repo" in args and (not args["repo"]
269
+ or not os.path.isdir(args["repo"])):
270
+ raise ToolError(
271
+ f"run_gate: repo {args['repo']!r} is not an "
272
+ f"existing directory")
273
+ repo = args.get("repo") or "."
274
+ out = args.get("out") or os.path.join(
275
+ repo, "codereview-out")
276
+ argv = ["--repo", repo, "--out", out]
277
+ if args.get("range"):
278
+ argv += ["--range", args["range"]]
279
+ if args.get("policy"):
280
+ argv += ["--policy", args["policy"]]
281
+ if args.get("trust_repo_policy"):
282
+ argv += ["--trust-repo-policy"]
283
+ if args.get("context"):
284
+ argv += ["--context", args["context"]]
285
+ patch_file = None
286
+ if args.get("patch") is not None:
287
+ fd, patch_file = tempfile.mkstemp(
288
+ prefix="codereview-mcp-", suffix=".patch")
289
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
290
+ fh.write(args["patch"])
291
+ argv += ["--patch", patch_file]
292
+ stdout, stderr = io.StringIO(), io.StringIO()
293
+ try:
294
+ with contextlib.redirect_stdout(stdout), \
295
+ contextlib.redirect_stderr(stderr):
296
+ rc = cli.main(
297
+ argv,
298
+ providers_override=self._providers_override,
299
+ seat_meta_override=self._seat_meta_override,
300
+ fallbacks_override=self._fallbacks_override)
301
+ except SystemExit as exc:
302
+ raise ToolError(
303
+ "run_gate: the gate refused the request "
304
+ f"(exit {exc.code}): "
305
+ f"{_tail(stderr.getvalue())}") from exc
306
+ except Exception as exc: # noqa: BLE001 — fail closed
307
+ raise ToolError(
308
+ f"run_gate: the gate run failed: {exc}") from exc
309
+ finally:
310
+ if patch_file is not None:
311
+ try:
312
+ os.unlink(patch_file)
313
+ except OSError:
314
+ pass
315
+ out_text, err_text = stdout.getvalue(), stderr.getvalue()
316
+ jpath = _printed_path(out_text, "data")
317
+ if jpath is None:
318
+ if userconfig.NOT_SET_UP.splitlines()[0] in err_text:
319
+ raise ToolError(err_text.strip())
320
+ raise ToolError(
321
+ f"run_gate: the gate run produced no record "
322
+ f"(exit {rc}): {_tail(err_text) or _tail(out_text)}")
323
+ try:
324
+ with open(jpath, encoding="utf-8") as fh:
325
+ record = json.load(fh)
326
+ except Exception as exc: # noqa: BLE001
327
+ raise ToolError(
328
+ f"run_gate: could not read the record the run "
329
+ f"wrote ({jpath}): {exc}") from exc
330
+ payload = _verdict_payload(record, jpath)
331
+ payload["report_path"] = _printed_path(out_text, "report")
332
+ payload["exit_code"] = rc
333
+ return payload
334
+
335
+ # -- get_verdict ------------------------------------------------
336
+
337
+ def get_verdict(self, arguments):
338
+ from . import cli
339
+ args = self._checked_args("get_verdict", arguments)
340
+ try:
341
+ record, _record_dir, jpath = cli._load_record_arg(
342
+ args["record"], args.get("repo") or ".")
343
+ except Exception as exc: # noqa: BLE001
344
+ raise ToolError(
345
+ f"get_verdict: could not read the record: "
346
+ f"{exc}") from exc
347
+ return _verdict_payload(record, jpath)
348
+
349
+
350
+ def _printed_path(text, label):
351
+ """The CLI prints ' report: <path>' / ' data: <path>'
352
+ lines at the end of a run; pull one back out."""
353
+ matches = re.findall(rf"^ {label}:\s+(\S.*)$", text,
354
+ flags=re.MULTILINE)
355
+ return matches[-1].strip() if matches else None
356
+
357
+
358
+ def _tail(text, limit=800):
359
+ text = (text or "").strip()
360
+ return text[-limit:] if len(text) > limit else text
361
+
362
+
363
+ # -- protocol handling --------------------------------------------
364
+
365
+
366
+ def _call_result(payload):
367
+ return {
368
+ "content": [{
369
+ "type": "text",
370
+ "text": json.dumps(payload, ensure_ascii=False,
371
+ indent=2),
372
+ }],
373
+ "structuredContent": payload,
374
+ "isError": False,
375
+ }
376
+
377
+
378
+ def _error_result(message):
379
+ return {
380
+ "content": [{"type": "text", "text": message}],
381
+ "isError": True,
382
+ }
383
+
384
+
385
+ def make_dispatch(tools):
386
+ """Build the transport dispatch callable for a GateTools
387
+ instance: MCP method handling (initialize / ping /
388
+ tools/list / tools/call) on top of the wire layer."""
389
+
390
+ def dispatch(method, params, notification):
391
+ if notification:
392
+ # notifications/initialized, notifications/
393
+ # cancelled, anything else: notifications are
394
+ # fire-and-forget and never answered.
395
+ return None
396
+ if method == "initialize":
397
+ params = params if isinstance(params, dict) else {}
398
+ requested = params.get("protocolVersion")
399
+ version = requested if requested in PROTOCOL_VERSIONS \
400
+ else PROTOCOL_VERSIONS[0]
401
+ return {
402
+ "protocolVersion": version,
403
+ "capabilities": {"tools": {"listChanged": False}},
404
+ "serverInfo": dict(SERVER_INFO),
405
+ "instructions": (
406
+ "CodeReview gate. run_gate runs the "
407
+ "multi-model review gate on a diff and "
408
+ "returns the verdict + record path; "
409
+ "get_verdict reads a stored run record."),
410
+ }
411
+ if method == "ping":
412
+ return {}
413
+ if method == "tools/list":
414
+ return {"tools": TOOL_DEFS}
415
+ if method == "tools/call":
416
+ if not isinstance(params, dict) \
417
+ or not isinstance(params.get("name"), str):
418
+ raise JsonRpcError(
419
+ INVALID_PARAMS,
420
+ "tools/call needs a params object with a "
421
+ "tool name")
422
+ name = params["name"]
423
+ handler = {"run_gate": tools.run_gate,
424
+ "get_verdict": tools.get_verdict}.get(name)
425
+ if handler is None:
426
+ raise JsonRpcError(
427
+ INVALID_PARAMS, f"unknown tool: {name}")
428
+ try:
429
+ payload = handler(params.get("arguments"))
430
+ except ToolError as exc:
431
+ return _error_result(str(exc))
432
+ return _call_result(payload)
433
+ raise JsonRpcError(
434
+ METHOD_NOT_FOUND, f"method not found: {method}")
435
+
436
+ return dispatch
437
+
438
+
439
+ def serve_stdio(tools=None, stdin=None, stdout=None, log=None,
440
+ max_frame_bytes=None):
441
+ """Assemble the server (transport + tools) and serve until
442
+ EOF. Returns the StdioServer so callers (and tests) can
443
+ inspect its error ledger."""
444
+ # P3-04: the server process holds the operator's
445
+ # environment (provider keys) and spawns gate runs; make
446
+ # it non-dumpable before serving.
447
+ hardening.make_non_dumpable()
448
+ tools = tools if tools is not None else GateTools()
449
+ kwargs = {}
450
+ if max_frame_bytes is not None:
451
+ kwargs["max_frame_bytes"] = max_frame_bytes
452
+ server = StdioServer(make_dispatch(tools), stdin=stdin,
453
+ stdout=stdout, log=log, **kwargs)
454
+ server.serve_forever()
455
+ return server