open-code-review-toolkit 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/PKG-INFO +3 -3
  2. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/README.md +2 -2
  3. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/_version.py +2 -2
  4. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/cli.py +18 -1
  5. open_code_review_toolkit-0.3.1/src/ocr_toolkit/mcp_config.py +479 -0
  6. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/workflow.py +1 -27
  7. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/preflight.py +1 -1
  8. open_code_review_toolkit-0.3.1/src/ocr_toolkit/review_runner.py +117 -0
  9. open_code_review_toolkit-0.3.0/src/ocr_toolkit/mcp_config.py +0 -257
  10. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/.gitignore +0 -0
  11. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/LICENSE +0 -0
  12. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/pyproject.toml +0 -0
  13. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/__init__.py +0 -0
  14. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/common/__init__.py +0 -0
  15. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/common/language.py +0 -0
  16. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/common/markdown.py +0 -0
  17. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/common/redaction.py +0 -0
  18. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/config_writer.py +0 -0
  19. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/configure.py +0 -0
  20. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/__init__.py +0 -0
  21. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/__main__.py +0 -0
  22. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/ansible.py +0 -0
  23. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/categorize.py +0 -0
  24. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/instructions.py +0 -0
  25. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/manifests.py +0 -0
  26. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/planner.py +0 -0
  27. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/render.py +0 -0
  28. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/repo.py +0 -0
  29. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/settings.py +0 -0
  30. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/__init__.py +0 -0
  31. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/__main__.py +0 -0
  32. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/comments.py +0 -0
  33. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/formatting.py +0 -0
  34. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/gitlab.py +0 -0
  35. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/markers.py +0 -0
  36. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/payloads.py +0 -0
  37. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/result.py +0 -0
  38. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/settings.py +0 -0
  39. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/snapshot.py +0 -0
  40. {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-code-review-toolkit
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Unofficial GitLab CI integration layer for Open Code Review
5
5
  Project-URL: Homepage, https://github.com/xeonvs/open-code-review-toolkit
6
6
  Project-URL: Repository, https://github.com/xeonvs/open-code-review-toolkit
@@ -243,7 +243,7 @@ ocr --version
243
243
  ocr-ci --help
244
244
  ```
245
245
 
246
- The current compatibility target is OCR `1.7.17`. CI should pin the release and verify its published checksum before execution.
246
+ The current compatibility target is OCR `1.8.0`. CI should pin the release and verify its published checksum before execution.
247
247
  The [versioned compatibility policy](docs/compatibility.md) records tested assets and evidence and describes the conservative Dependabot-like qualification workflow for later upstream releases.
248
248
  Review output defaults to English. Set `OCR_REVIEW_LANGUAGE=Russian` to use Russian consistently in both OCR configuration and generated review context.
249
249
 
@@ -277,7 +277,7 @@ ocr-ci preflight
277
277
  ocr-ci configure
278
278
  ocr-ci mcp-config
279
279
  ocr-ci context --output .review-context/dependencies.md
280
- # run: ocr review ... --format json
280
+ ocr-ci review --result /tmp/ocr-result.json --stderr /tmp/ocr-stderr.log -- ... --format json
281
281
  ocr-ci post --result /tmp/ocr-result.json --stderr /tmp/ocr-stderr.log
282
282
  ```
283
283
 
@@ -15,7 +15,7 @@ ocr --version
15
15
  ocr-ci --help
16
16
  ```
17
17
 
18
- The current compatibility target is OCR `1.7.17`. CI should pin the release and verify its published checksum before execution.
18
+ The current compatibility target is OCR `1.8.0`. CI should pin the release and verify its published checksum before execution.
19
19
  The [versioned compatibility policy](docs/compatibility.md) records tested assets and evidence and describes the conservative Dependabot-like qualification workflow for later upstream releases.
20
20
  Review output defaults to English. Set `OCR_REVIEW_LANGUAGE=Russian` to use Russian consistently in both OCR configuration and generated review context.
21
21
 
@@ -49,7 +49,7 @@ ocr-ci preflight
49
49
  ocr-ci configure
50
50
  ocr-ci mcp-config
51
51
  ocr-ci context --output .review-context/dependencies.md
52
- # run: ocr review ... --format json
52
+ ocr-ci review --result /tmp/ocr-result.json --stderr /tmp/ocr-stderr.log -- ... --format json
53
53
  ocr-ci post --result /tmp/ocr-result.json --stderr /tmp/ocr-stderr.log
54
54
  ```
55
55
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.3.0'
22
- __version_tuple__ = version_tuple = (0, 3, 0)
21
+ __version__ = version = '0.3.1'
22
+ __version_tuple__ = version_tuple = (0, 3, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -3,9 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import sys
6
7
  from collections.abc import Sequence
8
+ from pathlib import Path
7
9
 
8
- from ocr_toolkit import configure, mcp_config, preflight
10
+ from ocr_toolkit import configure, mcp_config, preflight, review_runner
9
11
  from ocr_toolkit.context import render as context_render
10
12
  from ocr_toolkit.posting.workflow import main as posting_main
11
13
 
@@ -21,6 +23,14 @@ def build_parser() -> argparse.ArgumentParser:
21
23
  subparsers.add_parser("preflight", help="Validate OCR, GitLab, and LLM access.")
22
24
  subparsers.add_parser("configure", help="Write the OCR runtime configuration.")
23
25
  subparsers.add_parser("mcp-config", help="Write OCR MCP server configuration.")
26
+ review_parser = subparsers.add_parser(
27
+ "review", help="Run OCR with private artifacts and safe failure diagnostics."
28
+ )
29
+ review_parser.add_argument("--result", required=True, help="OCR JSON output path.")
30
+ review_parser.add_argument("--stderr", required=True, help="Full OCR stderr artifact path.")
31
+ review_parser.add_argument(
32
+ "ocr_args", nargs=argparse.REMAINDER, help="OCR review arguments after --."
33
+ )
24
34
 
25
35
  context_parser = subparsers.add_parser(
26
36
  "context", help="Generate bounded repository review context."
@@ -57,6 +67,13 @@ def main(argv: Sequence[str] | None = None) -> int:
57
67
  return configure.main()
58
68
  if args.command == "mcp-config":
59
69
  return mcp_config.configure_mcp_servers()
70
+ if args.command == "review":
71
+ ocr_args = args.ocr_args[1:] if args.ocr_args[:1] == ["--"] else args.ocr_args
72
+ try:
73
+ return review_runner.run_review(Path(args.result), Path(args.stderr), ocr_args)
74
+ except review_runner.ReviewRunnerError as exc:
75
+ print(f"Cannot run Open Code Review: {exc}", file=sys.stderr)
76
+ return 2
60
77
  if args.command == "context":
61
78
  return context_render.main(["--output", args.output])
62
79
  if args.command == "post":
@@ -0,0 +1,479 @@
1
+ """Configure OCR MCP servers from CI environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import sys
9
+ import urllib.parse
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from ocr_toolkit.common.redaction import redact_sensitive
14
+ from ocr_toolkit.config_writer import OCRConfigError, read_ocr_config, update_ocr_config
15
+
16
+ MAX_MCP_CONFIG_BYTES = 64_000
17
+ MAX_MCP_SERVERS = 16
18
+ MAX_MCP_ARGS = 64
19
+ MAX_MCP_ENV = 64
20
+ MAX_MCP_HEADERS = 32
21
+ MAX_MCP_TOOLS = 128
22
+ MAX_MCP_URL_CHARS = 2_048
23
+ MAX_MCP_HEADER_VALUE_CHARS = 2_048
24
+ MAX_MCP_SETUP_CHARS = 4_096
25
+ MAX_MCP_STRING_CHARS = 4_096
26
+ MCP_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
27
+ MCP_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
28
+ MCP_HEADER_NAME_RE = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
29
+ SENSITIVE_HEADER_NAME_RE = re.compile(
30
+ r"(?:^|[-_])(?:authorization|authentication|auth|cookie|api[-_]?key|"
31
+ r"subscription[-_]?key|key|token|secret|credential)(?:$|[-_])",
32
+ flags=re.IGNORECASE,
33
+ )
34
+ TRUE_VALUES = {"1", "true", "yes", "on"}
35
+ FALSE_VALUES = {"0", "false", "no", "off", ""}
36
+
37
+
38
+ class MCPConfigError(Exception):
39
+ """The MCP CI configuration is invalid."""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class MCPServerConfig:
44
+ """Validated OCR MCP server configuration for one transport."""
45
+
46
+ name: str
47
+ transport: str
48
+ command: str | None
49
+ url: str | None
50
+ args: list[str]
51
+ tools: list[str]
52
+ setup: str
53
+ env: list[str]
54
+ headers: dict[str, str]
55
+ secret_values: list[str]
56
+
57
+
58
+ def _string_list(value: Any, field: str, *, limit: int) -> list[str]:
59
+ """Validate one bounded JSON string list and preserve its order."""
60
+
61
+ if not isinstance(value, list):
62
+ raise MCPConfigError(f"{field} must be a JSON array of strings")
63
+ if len(value) > limit:
64
+ raise MCPConfigError(f"{field} exceeds maximum item count {limit}")
65
+ result: list[str] = []
66
+ for index, item in enumerate(value):
67
+ if not isinstance(item, str):
68
+ raise MCPConfigError(f"{field}[{index}] must be a string")
69
+ if len(item) > MAX_MCP_STRING_CHARS:
70
+ raise MCPConfigError(f"{field}[{index}] exceeds maximum length {MAX_MCP_STRING_CHARS}")
71
+ result.append(item)
72
+ return result
73
+
74
+
75
+ def _env_assignments(value: Any, env_from: Any, server_name: str) -> tuple[list[str], list[str]]:
76
+ """Build stdio environment assignments and collect values for redaction."""
77
+
78
+ assignments: list[str] = []
79
+ secret_values: list[str] = []
80
+
81
+ if value is None:
82
+ value = {}
83
+ if env_from is None:
84
+ env_from = {}
85
+ if not isinstance(value, dict):
86
+ raise MCPConfigError(f"servers.{server_name}.env must be an object")
87
+ if not isinstance(env_from, dict):
88
+ raise MCPConfigError(f"servers.{server_name}.env_from must be an object")
89
+ if len(value) + len(env_from) > MAX_MCP_ENV:
90
+ raise MCPConfigError(
91
+ f"servers.{server_name}.env/env_from exceed maximum item count {MAX_MCP_ENV}"
92
+ )
93
+
94
+ for key, item in value.items():
95
+ if not isinstance(key, str) or not MCP_ENV_NAME_RE.fullmatch(key):
96
+ raise MCPConfigError(f"servers.{server_name}.env contains invalid env name")
97
+ if not isinstance(item, str):
98
+ raise MCPConfigError(f"servers.{server_name}.env.{key} must be a string")
99
+ if len(key) + len(item) + 1 > MAX_MCP_STRING_CHARS:
100
+ raise MCPConfigError(
101
+ f"servers.{server_name}.env.{key} exceeds maximum length {MAX_MCP_STRING_CHARS}"
102
+ )
103
+ assignments.append(f"{key}={item}")
104
+
105
+ for key, source_name in env_from.items():
106
+ if not isinstance(key, str) or not MCP_ENV_NAME_RE.fullmatch(key):
107
+ raise MCPConfigError(f"servers.{server_name}.env_from contains invalid env name")
108
+ if key in value:
109
+ raise MCPConfigError(
110
+ f"servers.{server_name}.env_from.{key} duplicates servers.{server_name}.env.{key}"
111
+ )
112
+ if not isinstance(source_name, str) or not MCP_ENV_NAME_RE.fullmatch(source_name):
113
+ raise MCPConfigError(f"servers.{server_name}.env_from.{key} must name a CI variable")
114
+ if source_name not in os.environ:
115
+ raise MCPConfigError(
116
+ f"servers.{server_name}.env_from.{key} references missing CI variable {source_name}"
117
+ )
118
+ secret_value = os.environ[source_name]
119
+ assignments.append(f"{key}={secret_value}")
120
+ if secret_value:
121
+ secret_values.append(secret_value)
122
+
123
+ return assignments, secret_values
124
+
125
+
126
+ def _validate_header_name(name: Any, field: str) -> str:
127
+ """Return an HTTP token header name or reject the input."""
128
+
129
+ if not isinstance(name, str) or not MCP_HEADER_NAME_RE.fullmatch(name):
130
+ raise MCPConfigError(f"{field} contains an invalid HTTP header name")
131
+ return name
132
+
133
+
134
+ def _headers(value: Any, env_from: Any, server_name: str) -> tuple[dict[str, str], list[str]]:
135
+ """Project bounded literal and environment-backed headers into OCR syntax."""
136
+
137
+ if value is None:
138
+ value = {}
139
+ if env_from is None:
140
+ env_from = {}
141
+ if not isinstance(value, dict):
142
+ raise MCPConfigError(f"servers.{server_name}.headers must be an object")
143
+ if not isinstance(env_from, dict):
144
+ raise MCPConfigError(f"servers.{server_name}.headers_from must be an object")
145
+ if len(value) + len(env_from) > MAX_MCP_HEADERS:
146
+ raise MCPConfigError(
147
+ f"servers.{server_name}.headers/headers_from exceed maximum item count "
148
+ f"{MAX_MCP_HEADERS}"
149
+ )
150
+
151
+ result: dict[str, str] = {}
152
+ normalized_names: set[str] = set()
153
+ secret_values: list[str] = []
154
+ for raw_name, item in value.items():
155
+ name = _validate_header_name(raw_name, f"servers.{server_name}.headers")
156
+ normalized = name.casefold()
157
+ if normalized in normalized_names:
158
+ raise MCPConfigError(f"servers.{server_name} contains duplicate HTTP header {name!r}")
159
+ normalized_names.add(normalized)
160
+ # Literal credentials would be copied into both CI input and OCR's on-disk
161
+ # config. Sensitive header families must cross this boundary only through
162
+ # an environment-variable reference.
163
+ if SENSITIVE_HEADER_NAME_RE.search(name):
164
+ raise MCPConfigError(
165
+ f"servers.{server_name}.headers.{name} is sensitive; use headers_from"
166
+ )
167
+ if not isinstance(item, str):
168
+ raise MCPConfigError(f"servers.{server_name}.headers.{name} must be a string")
169
+ if not item or len(item) > MAX_MCP_HEADER_VALUE_CHARS:
170
+ raise MCPConfigError(
171
+ f"servers.{server_name}.headers.{name} must be non-empty and at most "
172
+ f"{MAX_MCP_HEADER_VALUE_CHARS} characters"
173
+ )
174
+ if any(ord(char) < 0x20 or ord(char) == 0x7F for char in item):
175
+ raise MCPConfigError(
176
+ f"servers.{server_name}.headers.{name} contains invalid control characters"
177
+ )
178
+ result[name] = item
179
+
180
+ for raw_name, source_name in env_from.items():
181
+ name = _validate_header_name(raw_name, f"servers.{server_name}.headers_from")
182
+ normalized = name.casefold()
183
+ if normalized in normalized_names:
184
+ raise MCPConfigError(f"servers.{server_name} contains duplicate HTTP header {name!r}")
185
+ normalized_names.add(normalized)
186
+ if not isinstance(source_name, str) or not MCP_ENV_NAME_RE.fullmatch(source_name):
187
+ raise MCPConfigError(
188
+ f"servers.{server_name}.headers_from.{name} must name a CI variable"
189
+ )
190
+ secret_value = os.environ.get(source_name)
191
+ if not secret_value:
192
+ raise MCPConfigError(
193
+ f"servers.{server_name}.headers_from.{name} references missing or empty CI "
194
+ f"variable {source_name}"
195
+ )
196
+ if len(secret_value) > MAX_MCP_HEADER_VALUE_CHARS:
197
+ raise MCPConfigError(
198
+ f"servers.{server_name}.headers_from.{name} references a CI variable "
199
+ f"exceeding {MAX_MCP_HEADER_VALUE_CHARS} characters"
200
+ )
201
+ if any(ord(char) < 0x20 or ord(char) == 0x7F for char in secret_value):
202
+ raise MCPConfigError(
203
+ f"servers.{server_name}.headers_from.{name} references a CI variable "
204
+ "containing invalid HTTP control characters"
205
+ )
206
+ # OCR 1.8 expands $VARNAME when it creates the remote client. Keeping the
207
+ # reference avoids serializing the secret into config.json. Read the value
208
+ # once so missing credentials fail early and downstream errors can redact it.
209
+ result[name] = f"${source_name}"
210
+ secret_values.append(secret_value)
211
+
212
+ return result, secret_values
213
+
214
+
215
+ def _remote_url(value: Any, server_name: str) -> str:
216
+ """Validate a bounded HTTPS Streamable HTTP endpoint for OCR."""
217
+
218
+ if not isinstance(value, str) or not value or len(value) > MAX_MCP_URL_CHARS:
219
+ raise MCPConfigError(
220
+ f"servers.{server_name}.url must be a non-empty HTTPS URL of at most "
221
+ f"{MAX_MCP_URL_CHARS} characters"
222
+ )
223
+ if any(ord(char) < 0x20 or ord(char) == 0x7F for char in value):
224
+ raise MCPConfigError(f"servers.{server_name}.url contains invalid control characters")
225
+ try:
226
+ parsed = urllib.parse.urlsplit(value)
227
+ port = parsed.port
228
+ except ValueError as exc:
229
+ raise MCPConfigError(f"servers.{server_name}.url is not a valid HTTPS URL") from exc
230
+ # OCR accepts plain HTTP, but the toolkit's CI contract is stricter because
231
+ # credentials can traverse this endpoint. Userinfo and fragments are rejected
232
+ # because they add leak-prone ambiguity; queries are accepted but never logged.
233
+ if (
234
+ parsed.scheme != "https"
235
+ or not parsed.hostname
236
+ or parsed.username is not None
237
+ or parsed.password is not None
238
+ or parsed.fragment
239
+ or (port is not None and not 1 <= port <= 65_535)
240
+ ):
241
+ raise MCPConfigError(
242
+ f"servers.{server_name}.url must be absolute HTTPS without userinfo or fragment"
243
+ )
244
+ return value
245
+
246
+
247
+ def parse_mcp_servers(raw: str | None = None) -> list[MCPServerConfig]:
248
+ """Return bounded stdio and remote configs from OCR_MCP_SERVERS_JSON."""
249
+
250
+ raw = os.environ.get("OCR_MCP_SERVERS_JSON", "") if raw is None else raw
251
+ raw = raw.strip()
252
+ if not raw:
253
+ return []
254
+ if len(raw.encode("utf-8")) > MAX_MCP_CONFIG_BYTES:
255
+ raise MCPConfigError(f"OCR_MCP_SERVERS_JSON exceeds {MAX_MCP_CONFIG_BYTES} bytes")
256
+
257
+ try:
258
+ payload = json.loads(raw)
259
+ except json.JSONDecodeError as exc:
260
+ raise MCPConfigError(
261
+ "OCR_MCP_SERVERS_JSON is not valid JSON; check CI variable formatting"
262
+ ) from exc
263
+ except RecursionError as exc:
264
+ raise MCPConfigError("OCR_MCP_SERVERS_JSON is too deeply nested") from exc
265
+
266
+ if not isinstance(payload, dict):
267
+ raise MCPConfigError("OCR_MCP_SERVERS_JSON top-level value must be an object")
268
+ if "servers" in payload:
269
+ if len(payload) != 1:
270
+ raise MCPConfigError(
271
+ "OCR_MCP_SERVERS_JSON cannot mix the servers envelope with named servers"
272
+ )
273
+ servers_value = payload["servers"]
274
+ if not isinstance(servers_value, list):
275
+ raise MCPConfigError("OCR_MCP_SERVERS_JSON.servers must be an array")
276
+ else:
277
+ servers_value = []
278
+ for name, value in payload.items():
279
+ if not isinstance(value, dict):
280
+ raise MCPConfigError(f"OCR MCP server {name!r} configuration must be an object")
281
+ if "name" in value and value["name"] != name:
282
+ raise MCPConfigError(f"OCR MCP server {name!r} must not declare a different name")
283
+ servers_value.append({**value, "name": name})
284
+ if len(servers_value) > MAX_MCP_SERVERS:
285
+ raise MCPConfigError(f"OCR_MCP_SERVERS_JSON has more than {MAX_MCP_SERVERS} servers")
286
+
287
+ seen: set[str] = set()
288
+ servers: list[MCPServerConfig] = []
289
+ for index, server in enumerate(servers_value):
290
+ if not isinstance(server, dict):
291
+ raise MCPConfigError(f"servers[{index}] must be an object")
292
+ enabled = server.get("enabled", True)
293
+ if not isinstance(enabled, bool):
294
+ raise MCPConfigError(f"servers[{index}].enabled must be a boolean")
295
+ if enabled is False:
296
+ continue
297
+
298
+ name = server.get("name")
299
+ if not isinstance(name, str) or not MCP_NAME_RE.fullmatch(name):
300
+ raise MCPConfigError(f"servers[{index}].name is invalid")
301
+ if name in seen:
302
+ raise MCPConfigError(f"duplicate MCP server name {name!r}")
303
+ seen.add(name)
304
+ transport = server.get("type", "stdio")
305
+ if transport not in {"stdio", "remote"}:
306
+ raise MCPConfigError(f"servers.{name}.type must be 'stdio' or 'remote'")
307
+ common_fields = {"name", "type", "enabled", "tools", "setup"}
308
+ transport_fields = (
309
+ {"command", "args", "env", "env_from"}
310
+ if transport == "stdio"
311
+ else {"url", "headers", "headers_from"}
312
+ )
313
+ unknown_fields = set(server) - common_fields - transport_fields
314
+ if unknown_fields:
315
+ fields = ", ".join(sorted(unknown_fields))
316
+ raise MCPConfigError(f"servers.{name} has unsupported fields for {transport}: {fields}")
317
+
318
+ tools = _string_list(server.get("tools", []), f"servers.{name}.tools", limit=MAX_MCP_TOOLS)
319
+ setup = server.get("setup", "")
320
+ if not isinstance(setup, str):
321
+ raise MCPConfigError(f"servers.{name}.setup must be a string")
322
+ if len(setup) > MAX_MCP_SETUP_CHARS:
323
+ raise MCPConfigError(
324
+ f"servers.{name}.setup exceeds maximum length {MAX_MCP_SETUP_CHARS}"
325
+ )
326
+
327
+ command: str | None = None
328
+ url: str | None = None
329
+ args: list[str] = []
330
+ env: list[str] = []
331
+ headers: dict[str, str] = {}
332
+ secret_values: list[str] = []
333
+ if transport == "stdio":
334
+ command_value = server.get("command")
335
+ if not isinstance(command_value, str) or not command_value.strip():
336
+ raise MCPConfigError(f"servers.{name}.command must be a non-empty string")
337
+ command = command_value.strip()
338
+ if len(command) > MAX_MCP_STRING_CHARS:
339
+ raise MCPConfigError(
340
+ f"servers.{name}.command exceeds maximum length {MAX_MCP_STRING_CHARS}"
341
+ )
342
+ args = _string_list(server.get("args", []), f"servers.{name}.args", limit=MAX_MCP_ARGS)
343
+ env, secret_values = _env_assignments(server.get("env"), server.get("env_from"), name)
344
+ else:
345
+ url = _remote_url(server.get("url"), name)
346
+ headers, secret_values = _headers(
347
+ server.get("headers"), server.get("headers_from"), name
348
+ )
349
+ servers.append(
350
+ MCPServerConfig(
351
+ name=name,
352
+ transport=transport,
353
+ command=command,
354
+ url=url,
355
+ args=args,
356
+ tools=tools,
357
+ setup=setup,
358
+ env=env,
359
+ headers=headers,
360
+ secret_values=secret_values,
361
+ )
362
+ )
363
+
364
+ return servers
365
+
366
+
367
+ def _replace_configured_servers() -> bool:
368
+ """Return whether generated servers replace, rather than merge with, OCR state."""
369
+
370
+ value = os.environ.get("OCR_MCP_REPLACE", "false").strip().lower()
371
+ if value in TRUE_VALUES:
372
+ return True
373
+ if value in FALSE_VALUES:
374
+ return False
375
+ raise MCPConfigError("OCR_MCP_REPLACE must be a boolean (true/false, 1/0, yes/no, or on/off)")
376
+
377
+
378
+ def _redact_extra_values(text: str, values: list[str]) -> str:
379
+ """Redact raw and URL-encoded secret variants from an error string."""
380
+
381
+ redacted = text
382
+ for value in values:
383
+ if value and len(value) >= 4:
384
+ for variant in {
385
+ value,
386
+ urllib.parse.quote(value, safe=""),
387
+ urllib.parse.quote_plus(value, safe=""),
388
+ }:
389
+ if variant:
390
+ redacted = redacted.replace(variant, "***")
391
+ return redacted
392
+
393
+
394
+ def configure_mcp_servers() -> int:
395
+ """Configure OCR MCP servers from CI and return a process exit code."""
396
+
397
+ try:
398
+ servers = parse_mcp_servers()
399
+ replace = _replace_configured_servers()
400
+ except MCPConfigError as exc:
401
+ print(f"Invalid OCR MCP configuration: {redact_sensitive(str(exc))}", file=sys.stderr)
402
+ return 1
403
+
404
+ if not servers:
405
+ try:
406
+ if replace:
407
+ update_ocr_config({"mcp_servers": {}})
408
+ except OCRConfigError as exc:
409
+ print(
410
+ f"Failed to clear OCR MCP servers: {redact_sensitive(str(exc))}",
411
+ file=sys.stderr,
412
+ )
413
+ return 1
414
+ if replace:
415
+ print("OCR MCP servers: none configured; existing servers cleared")
416
+ else:
417
+ print("OCR MCP servers: none configured; existing servers preserved")
418
+ return 0
419
+
420
+ mcp_servers: dict[str, Any] = {}
421
+ secret_values: list[str] = []
422
+ for server in servers:
423
+ if server.transport == "stdio":
424
+ mcp_servers[server.name] = {
425
+ "type": "stdio",
426
+ "command": server.command,
427
+ "args": server.args,
428
+ "env": server.env,
429
+ "tools": server.tools,
430
+ "setup": server.setup,
431
+ }
432
+ else:
433
+ mcp_servers[server.name] = {
434
+ "type": "remote",
435
+ "url": server.url,
436
+ "headers": server.headers,
437
+ "tools": server.tools,
438
+ "setup": server.setup,
439
+ }
440
+ secret_values.extend(server.secret_values)
441
+ for assignment in server.env:
442
+ _env_name, _sep, env_value = assignment.partition("=")
443
+ if env_value:
444
+ secret_values.append(env_value)
445
+
446
+ try:
447
+ if not replace:
448
+ # update_ocr_config replaces the complete mcp_servers object. Preserve
449
+ # unrelated entries here, before its atomic private-permission write.
450
+ existing = read_ocr_config().get("mcp_servers", {})
451
+ if existing is None:
452
+ existing = {}
453
+ if not isinstance(existing, dict):
454
+ raise OCRConfigError("OCR config mcp_servers value is not an object")
455
+ mcp_servers = {**existing, **mcp_servers}
456
+ update_ocr_config({"mcp_servers": mcp_servers})
457
+ except OCRConfigError as exc:
458
+ print(
459
+ "Failed to configure OCR MCP servers: "
460
+ f"{redact_sensitive(_redact_extra_values(str(exc), secret_values))}",
461
+ file=sys.stderr,
462
+ )
463
+ return 1
464
+
465
+ for server in servers:
466
+ if server.transport == "stdio":
467
+ detail = f"args={len(server.args)} env={len(server.env)}"
468
+ else:
469
+ detail = f"headers={len(server.headers)}"
470
+ print(
471
+ f"OCR MCP server configured: {server.name} type={server.transport} "
472
+ f"{detail} tools={len(server.tools)}"
473
+ )
474
+
475
+ return 0
476
+
477
+
478
+ if __name__ == "__main__":
479
+ raise SystemExit(configure_mcp_servers())
@@ -62,6 +62,7 @@ from ocr_toolkit.posting.settings import (
62
62
  post_mode,
63
63
  strict_posting,
64
64
  )
65
+ from ocr_toolkit.review_runner import read_stderr_excerpt
65
66
 
66
67
 
67
68
  def finalize_posting(config: GitLabConfig, draft_note_ids: list[int]) -> bool:
@@ -709,33 +710,6 @@ def post_llm_provider_failure(
709
710
  return 1
710
711
 
711
712
 
712
- STDERR_PROBE_BYTES = 64 * 1024
713
-
714
-
715
- def read_stderr_excerpt(stderr_path: Path, max_chars: int = 4000) -> str:
716
- """Read a bounded, redacted excerpt of an OCR stderr log.
717
-
718
- Reads at most STDERR_PROBE_BYTES so a multi-megabyte stderr does
719
- not stress the runner memory. Redaction runs on the full probe
720
- BEFORE truncation so a secret bisected by the char limit is still
721
- matched against its full environment-value form.
722
- """
723
-
724
- if not stderr_path.exists():
725
- return ""
726
-
727
- try:
728
- with stderr_path.open("rb") as handle:
729
- chunk = handle.read(STDERR_PROBE_BYTES)
730
- except OSError:
731
- return ""
732
-
733
- text = chunk.decode("utf-8", errors="replace").strip()
734
- if not text:
735
- return ""
736
- return redact_sensitive(text)[:max_chars]
737
-
738
-
739
713
  def post_parse_error(config: GitLabConfig, stderr_path: Path) -> int:
740
714
  """Post a safe error note when OCR output is not valid JSON.
741
715
 
@@ -24,7 +24,7 @@ DEFAULT_REQUEST_HEADERS = {
24
24
  "Accept": "application/json",
25
25
  "User-Agent": "open-code-review-ci-preflight/1.0",
26
26
  }
27
- EXPECTED_OCR_VERSION = "1.7.17"
27
+ EXPECTED_OCR_VERSION = "1.8.0"
28
28
 
29
29
 
30
30
  class PreflightError(Exception):
@@ -0,0 +1,117 @@
1
+ """Run Open Code Review with private artifacts and safe CI diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ import subprocess
8
+ import sys
9
+ from contextlib import ExitStack
10
+ from io import BufferedWriter
11
+ from pathlib import Path
12
+
13
+ from ocr_toolkit.common.redaction import redact_sensitive
14
+
15
+ STDERR_PROBE_BYTES = 64 * 1024
16
+ DEFAULT_DIAGNOSTIC_CHARS = 4_000
17
+
18
+
19
+ class ReviewRunnerError(Exception):
20
+ """The local OCR review process could not be started safely."""
21
+
22
+
23
+ def _open_private_artifact(path: Path, label: str) -> BufferedWriter:
24
+ """Open one regular artifact without following a final-component symlink."""
25
+
26
+ flags = os.O_CREAT | os.O_WRONLY
27
+ if hasattr(os, "O_NOFOLLOW"):
28
+ # The pre-check gives a clear error; O_NOFOLLOW closes the replacement race
29
+ # between that check and open on platforms that expose the flag.
30
+ flags |= os.O_NOFOLLOW
31
+ if hasattr(os, "O_NONBLOCK"):
32
+ # Opening a pre-created FIFO for writing could otherwise block the CI job
33
+ # before the descriptor can be rejected as a non-artifact sink.
34
+ flags |= os.O_NONBLOCK
35
+ descriptor = -1
36
+ try:
37
+ descriptor = os.open(path, flags, 0o600)
38
+ file_stat = os.fstat(descriptor)
39
+ if not stat.S_ISREG(file_stat.st_mode):
40
+ raise ReviewRunnerError(f"private {label} artifact must be a regular file: {path}")
41
+ if file_stat.st_nlink > 1:
42
+ raise ReviewRunnerError(f"private {label} artifact must not have hard links: {path}")
43
+ os.fchmod(descriptor, 0o600)
44
+ # Delay truncation until the descriptor has passed type and link checks.
45
+ os.ftruncate(descriptor, 0)
46
+ except ReviewRunnerError:
47
+ if descriptor >= 0:
48
+ os.close(descriptor)
49
+ raise
50
+ except OSError as exc:
51
+ if descriptor >= 0:
52
+ os.close(descriptor)
53
+ raise ReviewRunnerError(f"could not open private {label} artifact: {exc}") from exc
54
+ return open(descriptor, "wb", closefd=True)
55
+
56
+
57
+ def read_stderr_excerpt(stderr_path: Path, max_chars: int = DEFAULT_DIAGNOSTIC_CHARS) -> str:
58
+ """Read a bounded, redacted excerpt of an OCR stderr artifact."""
59
+
60
+ if not stderr_path.exists():
61
+ return ""
62
+ try:
63
+ with stderr_path.open("rb") as handle:
64
+ chunk = handle.read(STDERR_PROBE_BYTES)
65
+ except OSError:
66
+ return ""
67
+
68
+ text = chunk.decode("utf-8", errors="replace").strip()
69
+ if not text:
70
+ return ""
71
+ # Redact before truncation so a secret crossing the display boundary is
72
+ # matched against its complete environment value or credential pattern.
73
+ return redact_sensitive(text)[:max_chars]
74
+
75
+
76
+ def run_review(result_path: Path, stderr_path: Path, ocr_args: list[str]) -> int:
77
+ """Execute `ocr review`, retain private artifacts, and report safe failures."""
78
+
79
+ if not ocr_args:
80
+ raise ReviewRunnerError("at least one OCR review argument is required after --")
81
+ if result_path.absolute() == stderr_path.absolute():
82
+ raise ReviewRunnerError("result and stderr paths must be different")
83
+ for path, label in ((result_path, "result"), (stderr_path, "stderr")):
84
+ if path.is_symlink():
85
+ raise ReviewRunnerError(f"{label} path must not be a symlink: {path}")
86
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
87
+
88
+ # Use explicit argv and file descriptors: OCR-controlled output never crosses
89
+ # a shell, while umask 077 keeps both artifacts private on shared runners.
90
+ previous_umask = os.umask(0o077)
91
+ try:
92
+ with ExitStack() as stack:
93
+ result_file = stack.enter_context(_open_private_artifact(result_path, "result"))
94
+ stderr_file = stack.enter_context(_open_private_artifact(stderr_path, "stderr"))
95
+ if os.path.samestat(os.fstat(result_file.fileno()), os.fstat(stderr_file.fileno())):
96
+ raise ReviewRunnerError("result and stderr paths must be different")
97
+ completed = subprocess.run(
98
+ ["ocr", "review", *ocr_args],
99
+ check=False,
100
+ stdin=subprocess.DEVNULL,
101
+ stdout=result_file,
102
+ stderr=stderr_file,
103
+ )
104
+ except OSError as exc:
105
+ raise ReviewRunnerError(f"could not execute OCR: {exc}") from exc
106
+ finally:
107
+ os.umask(previous_umask)
108
+
109
+ if completed.returncode != 0:
110
+ print(f"Open Code Review exited with code {completed.returncode}.", file=sys.stderr)
111
+ excerpt = read_stderr_excerpt(stderr_path)
112
+ if excerpt:
113
+ print("Safe OCR stderr excerpt:", file=sys.stderr)
114
+ print(excerpt, file=sys.stderr)
115
+ else:
116
+ print("OCR did not provide a readable stderr diagnostic.", file=sys.stderr)
117
+ return completed.returncode
@@ -1,257 +0,0 @@
1
- """Configure OCR MCP servers from CI environment variables."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import os
7
- import re
8
- import sys
9
- import urllib.parse
10
- from dataclasses import dataclass
11
- from typing import Any
12
-
13
- from ocr_toolkit.common.redaction import redact_sensitive
14
- from ocr_toolkit.config_writer import OCRConfigError, update_ocr_config
15
-
16
- MAX_MCP_CONFIG_BYTES = 64_000
17
- MAX_MCP_SERVERS = 16
18
- MAX_MCP_ARGS = 64
19
- MAX_MCP_ENV = 64
20
- MAX_MCP_TOOLS = 128
21
- MCP_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
22
- MCP_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
23
-
24
-
25
- class MCPConfigError(Exception):
26
- """The MCP CI configuration is invalid."""
27
-
28
-
29
- @dataclass(frozen=True)
30
- class MCPServerConfig:
31
- """Validated OCR MCP server configuration for one stdio bridge."""
32
-
33
- name: str
34
- command: str
35
- args: list[str]
36
- tools: list[str]
37
- setup: str
38
- env: list[str]
39
- secret_values: list[str]
40
-
41
-
42
- def _string_list(value: Any, field: str, *, limit: int) -> list[str]:
43
- if not isinstance(value, list):
44
- raise MCPConfigError(f"{field} must be a JSON array of strings")
45
- if len(value) > limit:
46
- raise MCPConfigError(f"{field} exceeds maximum item count {limit}")
47
- result: list[str] = []
48
- for index, item in enumerate(value):
49
- if not isinstance(item, str):
50
- raise MCPConfigError(f"{field}[{index}] must be a string")
51
- result.append(item)
52
- return result
53
-
54
-
55
- def _env_assignments(value: Any, env_from: Any, server_name: str) -> tuple[list[str], list[str]]:
56
- assignments: list[str] = []
57
- secret_values: list[str] = []
58
-
59
- if value is None:
60
- value = {}
61
- if env_from is None:
62
- env_from = {}
63
- if not isinstance(value, dict):
64
- raise MCPConfigError(f"servers.{server_name}.env must be an object")
65
- if not isinstance(env_from, dict):
66
- raise MCPConfigError(f"servers.{server_name}.env_from must be an object")
67
- if len(value) + len(env_from) > MAX_MCP_ENV:
68
- raise MCPConfigError(
69
- f"servers.{server_name}.env/env_from exceed maximum item count {MAX_MCP_ENV}"
70
- )
71
-
72
- for key, item in value.items():
73
- if not isinstance(key, str) or not MCP_ENV_NAME_RE.fullmatch(key):
74
- raise MCPConfigError(f"servers.{server_name}.env contains invalid env name")
75
- if not isinstance(item, str):
76
- raise MCPConfigError(f"servers.{server_name}.env.{key} must be a string")
77
- assignments.append(f"{key}={item}")
78
-
79
- for key, source_name in env_from.items():
80
- if not isinstance(key, str) or not MCP_ENV_NAME_RE.fullmatch(key):
81
- raise MCPConfigError(f"servers.{server_name}.env_from contains invalid env name")
82
- if key in value:
83
- raise MCPConfigError(
84
- f"servers.{server_name}.env_from.{key} duplicates servers.{server_name}.env.{key}"
85
- )
86
- if not isinstance(source_name, str) or not MCP_ENV_NAME_RE.fullmatch(source_name):
87
- raise MCPConfigError(f"servers.{server_name}.env_from.{key} must name a CI variable")
88
- if source_name not in os.environ:
89
- raise MCPConfigError(
90
- f"servers.{server_name}.env_from.{key} references missing CI variable {source_name}"
91
- )
92
- secret_value = os.environ[source_name]
93
- assignments.append(f"{key}={secret_value}")
94
- if secret_value:
95
- secret_values.append(secret_value)
96
-
97
- return assignments, secret_values
98
-
99
-
100
- def parse_mcp_servers(raw: str | None = None) -> list[MCPServerConfig]:
101
- """Return validated MCP server configs from OCR_MCP_SERVERS_JSON."""
102
-
103
- raw = os.environ.get("OCR_MCP_SERVERS_JSON", "") if raw is None else raw
104
- raw = raw.strip()
105
- if not raw:
106
- return []
107
- if len(raw.encode("utf-8")) > MAX_MCP_CONFIG_BYTES:
108
- raise MCPConfigError(f"OCR_MCP_SERVERS_JSON exceeds {MAX_MCP_CONFIG_BYTES} bytes")
109
-
110
- try:
111
- payload = json.loads(raw)
112
- except json.JSONDecodeError as exc:
113
- raise MCPConfigError(
114
- "OCR_MCP_SERVERS_JSON is not valid JSON; check CI variable formatting"
115
- ) from exc
116
- except RecursionError as exc:
117
- raise MCPConfigError("OCR_MCP_SERVERS_JSON is too deeply nested") from exc
118
-
119
- if not isinstance(payload, dict):
120
- raise MCPConfigError("OCR_MCP_SERVERS_JSON top-level value must be an object")
121
- if "servers" in payload:
122
- if len(payload) != 1:
123
- raise MCPConfigError(
124
- "OCR_MCP_SERVERS_JSON cannot mix the servers envelope with named servers"
125
- )
126
- servers_value = payload["servers"]
127
- if not isinstance(servers_value, list):
128
- raise MCPConfigError("OCR_MCP_SERVERS_JSON.servers must be an array")
129
- else:
130
- servers_value = []
131
- for name, value in payload.items():
132
- if not isinstance(value, dict):
133
- raise MCPConfigError(f"OCR MCP server {name!r} configuration must be an object")
134
- if "name" in value and value["name"] != name:
135
- raise MCPConfigError(f"OCR MCP server {name!r} must not declare a different name")
136
- servers_value.append({**value, "name": name})
137
- if len(servers_value) > MAX_MCP_SERVERS:
138
- raise MCPConfigError(f"OCR_MCP_SERVERS_JSON has more than {MAX_MCP_SERVERS} servers")
139
-
140
- seen: set[str] = set()
141
- servers: list[MCPServerConfig] = []
142
- for index, server in enumerate(servers_value):
143
- if not isinstance(server, dict):
144
- raise MCPConfigError(f"servers[{index}] must be an object")
145
- enabled = server.get("enabled", True)
146
- if not isinstance(enabled, bool):
147
- raise MCPConfigError(f"servers[{index}].enabled must be a boolean")
148
- if enabled is False:
149
- continue
150
-
151
- name = server.get("name")
152
- if not isinstance(name, str) or not MCP_NAME_RE.fullmatch(name):
153
- raise MCPConfigError(f"servers[{index}].name is invalid")
154
- if name in seen:
155
- raise MCPConfigError(f"duplicate MCP server name {name!r}")
156
- seen.add(name)
157
-
158
- command = server.get("command")
159
- if not isinstance(command, str) or not command.strip():
160
- raise MCPConfigError(f"servers.{name}.command must be a non-empty string")
161
- command = command.strip()
162
-
163
- args = _string_list(server.get("args", []), f"servers.{name}.args", limit=MAX_MCP_ARGS)
164
- tools = _string_list(server.get("tools", []), f"servers.{name}.tools", limit=MAX_MCP_TOOLS)
165
- setup = server.get("setup", "")
166
- if not isinstance(setup, str):
167
- raise MCPConfigError(f"servers.{name}.setup must be a string")
168
-
169
- env, secret_values = _env_assignments(server.get("env"), server.get("env_from"), name)
170
- servers.append(
171
- MCPServerConfig(
172
- name=name,
173
- command=command,
174
- args=args,
175
- tools=tools,
176
- setup=setup,
177
- env=env,
178
- secret_values=secret_values,
179
- )
180
- )
181
-
182
- return servers
183
-
184
-
185
- def _redact_extra_values(text: str, values: list[str]) -> str:
186
- redacted = text
187
- for value in values:
188
- if value and len(value) >= 4:
189
- for variant in {
190
- value,
191
- urllib.parse.quote(value, safe=""),
192
- urllib.parse.quote_plus(value, safe=""),
193
- }:
194
- if variant:
195
- redacted = redacted.replace(variant, "***")
196
- return redacted
197
-
198
-
199
- def configure_mcp_servers() -> int:
200
- """Configure OCR MCP servers from CI and return a process exit code."""
201
-
202
- try:
203
- servers = parse_mcp_servers()
204
- except MCPConfigError as exc:
205
- print(f"Invalid OCR_MCP_SERVERS_JSON: {redact_sensitive(str(exc))}", file=sys.stderr)
206
- return 1
207
-
208
- if not servers:
209
- try:
210
- update_ocr_config({"mcp_servers": {}})
211
- except OCRConfigError as exc:
212
- print(
213
- f"Failed to clear OCR MCP servers: {redact_sensitive(str(exc))}",
214
- file=sys.stderr,
215
- )
216
- return 1
217
- print("OCR MCP servers: none configured")
218
- return 0
219
-
220
- mcp_servers: dict[str, Any] = {}
221
- secret_values: list[str] = []
222
- for server in servers:
223
- mcp_servers[server.name] = {
224
- "command": server.command,
225
- "args": server.args,
226
- "env": server.env,
227
- "tools": server.tools,
228
- "setup": server.setup,
229
- }
230
- secret_values.extend(server.secret_values)
231
- for assignment in server.env:
232
- _env_name, _sep, env_value = assignment.partition("=")
233
- if env_value:
234
- secret_values.append(env_value)
235
-
236
- try:
237
- update_ocr_config({"mcp_servers": mcp_servers})
238
- except OCRConfigError as exc:
239
- print(
240
- "Failed to configure OCR MCP servers: "
241
- f"{redact_sensitive(_redact_extra_values(str(exc), secret_values))}",
242
- file=sys.stderr,
243
- )
244
- return 1
245
-
246
- for server in servers:
247
- print(
248
- "OCR MCP server configured: "
249
- f"{server.name} command={server.command!r} "
250
- f"args={len(server.args)} env={len(server.env)} tools={len(server.tools)}"
251
- )
252
-
253
- return 0
254
-
255
-
256
- if __name__ == "__main__":
257
- raise SystemExit(configure_mcp_servers())