open-code-review-toolkit 0.6.2__tar.gz → 0.6.3__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 (94) hide show
  1. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/PKG-INFO +3 -8
  2. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/README.md +2 -7
  3. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/_version.py +2 -2
  4. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/review_context.py +31 -0
  5. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/mcp_config.py +128 -23
  6. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/ocr_result.py +5 -3
  7. open_code_review_toolkit-0.6.3/src/ocr_toolkit/posting/approval.py +266 -0
  8. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/posting/formatting.py +5 -1
  9. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/posting/gitlab.py +226 -40
  10. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/posting/gitlab_approval.py +41 -4
  11. open_code_review_toolkit-0.6.3/src/ocr_toolkit/posting/gitlab_identity.py +14 -0
  12. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/posting/markers.py +28 -5
  13. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/posting/payloads.py +15 -6
  14. open_code_review_toolkit-0.6.3/src/ocr_toolkit/posting/reconciliation.py +120 -0
  15. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/posting/snapshot.py +62 -83
  16. open_code_review_toolkit-0.6.3/src/ocr_toolkit/posting/transaction.py +75 -0
  17. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/posting/workflow.py +82 -48
  18. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/preflight.py +1 -1
  19. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/providers/gitlab.py +28 -12
  20. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/review_runner.py +108 -49
  21. open_code_review_toolkit-0.6.2/src/ocr_toolkit/posting/approval.py +0 -174
  22. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/.gitignore +0 -0
  23. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/LICENSE +0 -0
  24. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/pyproject.toml +0 -0
  25. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/__init__.py +0 -0
  26. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/cli.py +0 -0
  27. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/common/__init__.py +0 -0
  28. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/common/filesystem.py +0 -0
  29. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/common/git.py +0 -0
  30. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/common/language.py +0 -0
  31. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/common/markdown.py +0 -0
  32. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/common/redaction.py +0 -0
  33. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/config_writer.py +0 -0
  34. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/configure.py +0 -0
  35. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/__init__.py +0 -0
  36. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/__main__.py +0 -0
  37. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/artifacts.py +0 -0
  38. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/categorize.py +0 -0
  39. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/collect.py +0 -0
  40. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/collectors/__init__.py +0 -0
  41. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/collectors/graphs.py +0 -0
  42. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/collectors/orchestration.py +0 -0
  43. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/collectors/projections.py +0 -0
  44. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/collectors/registry.py +0 -0
  45. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/collectors/sources.py +0 -0
  46. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/coverage.py +0 -0
  47. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/ecosystems/__init__.py +0 -0
  48. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/ecosystems/ansible/__init__.py +0 -0
  49. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/ecosystems/ansible/requirements.py +0 -0
  50. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/ecosystems/ansible/topology.py +0 -0
  51. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/ecosystems/contracts.py +0 -0
  52. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/ecosystems/go.py +0 -0
  53. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/ecosystems/javascript.py +0 -0
  54. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/ecosystems/php.py +0 -0
  55. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/ecosystems/python.py +0 -0
  56. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/frameworks/__init__.py +0 -0
  57. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/frameworks/contracts.py +0 -0
  58. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/frameworks/detection.py +0 -0
  59. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/frameworks/providers/__init__.py +0 -0
  60. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/frameworks/providers/frontend.py +0 -0
  61. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/frameworks/providers/go.py +0 -0
  62. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/frameworks/providers/php.py +0 -0
  63. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/frameworks/providers/python.py +0 -0
  64. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/frameworks/registry.py +0 -0
  65. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/frameworks/schema.py +0 -0
  66. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/frameworks/templates.py +0 -0
  67. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/infrastructure.py +0 -0
  68. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/invocation.py +0 -0
  69. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/mcp.py +0 -0
  70. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/model.py +0 -0
  71. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/policy/__init__.py +0 -0
  72. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/policy/contracts.py +0 -0
  73. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/policy/decisions.py +0 -0
  74. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/policy/guidance.py +0 -0
  75. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/policy/registry.py +0 -0
  76. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/policy/schema.py +0 -0
  77. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/policy/scopes.py +0 -0
  78. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/project.py +0 -0
  79. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/repository.py +0 -0
  80. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/store/__init__.py +0 -0
  81. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/store/atomic.py +0 -0
  82. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/store/contracts.py +0 -0
  83. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/store/core.py +0 -0
  84. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/store/readback.py +0 -0
  85. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/evidence/store/values.py +0 -0
  86. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/posting/__init__.py +0 -0
  87. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/posting/__main__.py +0 -0
  88. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/posting/comments.py +0 -0
  89. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/posting/result.py +0 -0
  90. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/posting/settings.py +0 -0
  91. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/posting/suggestions.py +0 -0
  92. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/providers/__init__.py +0 -0
  93. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/py.typed +0 -0
  94. {open_code_review_toolkit-0.6.2 → open_code_review_toolkit-0.6.3}/src/ocr_toolkit/result_contract.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-code-review-toolkit
3
- Version: 0.6.2
3
+ Version: 0.6.3
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
@@ -258,18 +258,13 @@ On a successful rerun, the toolkit replaces untouched OCR-only notes instead of
258
258
 
259
259
  Suppression uses both the GitLab diff position and a stable finding fingerprint, so ordinary line shifts do not normally bring the same bug back. A materially changed finding can still receive a new discussion. See [GitLab review operations](docs/operations.md) for the complete lifecycle, posting modes, permissions, failure behavior, and Mermaid state diagram.
260
260
 
261
- After every current review note publishes, the GitLab adapter can add a
262
- conservative approval bound to the exact reviewed source SHA. This write is
263
- enabled by default; set `OCR_AUTO_APPROVE=false` before upgrading when the bot
264
- must remain comment-only. GitLab approval rules and protected-branch policy
265
- remain authoritative. The toolkit only adds an eligible approval; it never
266
- removes an existing approval when a later review is ineligible or disabled.
261
+ After every current review note publishes, the GitLab adapter can add a conservative approval bound to receipt v3's exact reviewed source SHA and merge-request author. This write is enabled by default; set `OCR_AUTO_APPROVE=false` when the bot must remain comment-only. Complete optional MR metadata remains eligible, while degraded metadata, legacy receipts, any external MCP, author movement, or bot self-authorship prevents an approval write. GitLab approval rules and protected-branch policy remain authoritative. The toolkit only adds an eligible approval; it never removes an existing approval when a later review is ineligible or disabled.
267
262
 
268
263
  Accepted tradeoffs can be recorded in `.opencodereview/accepted-decisions.md`; the evidence collector supplies only applicable target-ref decisions and never lets a source change self-authorize its review. Root and nested target `AGENTS.md`/`CLAUDE.md` guidance is similarly exposed through the existing evidence MCP with deterministic scope and precedence, while any guidance touched by the merge request is excluded. See [Accepted project decisions](docs/configuration.md#accepted-project-decisions) and [Target project guidance](docs/configuration.md#target-project-guidance) for formats and trust boundaries.
269
264
 
270
265
  ## Project architecture
271
266
 
272
- The shipped Repository Evidence Engine reads immutable base/head Git objects, stores bounded typed facts and deltas, creates the compact bootstrap used by OCR, and exposes detailed facts, scoped completeness, and base/head changes through the mandatory built-in read-only MCP server. Reviewed external stdio or native HTTPS MCP servers compose alongside it without replacing the built-in evidence boundary.
267
+ The shipped Repository Evidence Engine reads immutable base/head Git objects, stores bounded typed facts and deltas, creates the compact bootstrap used by OCR, and exposes detailed facts, scoped completeness, and base/head changes through the mandatory built-in read-only MCP server. GitLab MR reviews allow operator-configured external MCP only over remote HTTPS; developer-local execution may retain explicit stdio peers. Direct external MCP remains privileged operator configuration and comment-only; planned M5 external records use the separate toolkit-owned broker boundary.
273
268
 
274
269
  - [Toolkit strategy](docs/engineering/toolkit_strategy.md) - durable product boundaries, architecture, invariants, and non-goals.
275
270
  - [Roadmap](ROADMAP.md) - milestone status, dependencies, outcomes, and completion signals.
@@ -31,18 +31,13 @@ On a successful rerun, the toolkit replaces untouched OCR-only notes instead of
31
31
 
32
32
  Suppression uses both the GitLab diff position and a stable finding fingerprint, so ordinary line shifts do not normally bring the same bug back. A materially changed finding can still receive a new discussion. See [GitLab review operations](docs/operations.md) for the complete lifecycle, posting modes, permissions, failure behavior, and Mermaid state diagram.
33
33
 
34
- After every current review note publishes, the GitLab adapter can add a
35
- conservative approval bound to the exact reviewed source SHA. This write is
36
- enabled by default; set `OCR_AUTO_APPROVE=false` before upgrading when the bot
37
- must remain comment-only. GitLab approval rules and protected-branch policy
38
- remain authoritative. The toolkit only adds an eligible approval; it never
39
- removes an existing approval when a later review is ineligible or disabled.
34
+ After every current review note publishes, the GitLab adapter can add a conservative approval bound to receipt v3's exact reviewed source SHA and merge-request author. This write is enabled by default; set `OCR_AUTO_APPROVE=false` when the bot must remain comment-only. Complete optional MR metadata remains eligible, while degraded metadata, legacy receipts, any external MCP, author movement, or bot self-authorship prevents an approval write. GitLab approval rules and protected-branch policy remain authoritative. The toolkit only adds an eligible approval; it never removes an existing approval when a later review is ineligible or disabled.
40
35
 
41
36
  Accepted tradeoffs can be recorded in `.opencodereview/accepted-decisions.md`; the evidence collector supplies only applicable target-ref decisions and never lets a source change self-authorize its review. Root and nested target `AGENTS.md`/`CLAUDE.md` guidance is similarly exposed through the existing evidence MCP with deterministic scope and precedence, while any guidance touched by the merge request is excluded. See [Accepted project decisions](docs/configuration.md#accepted-project-decisions) and [Target project guidance](docs/configuration.md#target-project-guidance) for formats and trust boundaries.
42
37
 
43
38
  ## Project architecture
44
39
 
45
- The shipped Repository Evidence Engine reads immutable base/head Git objects, stores bounded typed facts and deltas, creates the compact bootstrap used by OCR, and exposes detailed facts, scoped completeness, and base/head changes through the mandatory built-in read-only MCP server. Reviewed external stdio or native HTTPS MCP servers compose alongside it without replacing the built-in evidence boundary.
40
+ The shipped Repository Evidence Engine reads immutable base/head Git objects, stores bounded typed facts and deltas, creates the compact bootstrap used by OCR, and exposes detailed facts, scoped completeness, and base/head changes through the mandatory built-in read-only MCP server. GitLab MR reviews allow operator-configured external MCP only over remote HTTPS; developer-local execution may retain explicit stdio peers. Direct external MCP remains privileged operator configuration and comment-only; planned M5 external records use the separate toolkit-owned broker boundary.
46
41
 
47
42
  - [Toolkit strategy](docs/engineering/toolkit_strategy.md) - durable product boundaries, architecture, invariants, and non-goals.
48
43
  - [Roadmap](ROADMAP.md) - milestone status, dependencies, outcomes, and completion signals.
@@ -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.6.2'
22
- __version_tuple__ = version_tuple = (0, 6, 2)
21
+ __version__ = version = '0.6.3'
22
+ __version_tuple__ = version_tuple = (0, 6, 3)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -38,6 +38,22 @@ LABEL_STATUSES = frozenset(
38
38
  }
39
39
  )
40
40
  MAX_LABELS = 32
41
+ CONTEXT_MODES = frozenset({"off", "metadata", "enriched"})
42
+
43
+
44
+ class ReviewContextModeError(ValueError):
45
+ """Report an unsupported or unavailable review-context mode safely."""
46
+
47
+
48
+ def parse_review_context_mode(raw: str | None) -> str:
49
+ """Return the closed context mode without echoing untrusted configuration."""
50
+
51
+ mode = (raw or "").strip().lower() or "off"
52
+ if mode not in CONTEXT_MODES:
53
+ raise ReviewContextModeError("OCR_REVIEW_CONTEXT_MODE is invalid")
54
+ if mode == "enriched":
55
+ raise ReviewContextModeError("OCR_REVIEW_CONTEXT_MODE=enriched is not available")
56
+ return mode or "off"
41
57
 
42
58
 
43
59
  def context_provenance(provider: str) -> str:
@@ -84,6 +100,21 @@ class MergeRequestContext:
84
100
  labels = self.fields.get("labels")
85
101
  return bool(isinstance(labels, Mapping) and labels.get("values"))
86
102
 
103
+ @property
104
+ def state(self) -> str:
105
+ """Return whether every selected metadata field is completely represented."""
106
+
107
+ complete_text = {"absent", "admitted"}
108
+ complete_labels = {"absent", "admitted"}
109
+ for name in ("title", "description", "source_branch"):
110
+ field = self.fields.get(name)
111
+ if not isinstance(field, Mapping) or field.get("status") not in complete_text:
112
+ return "degraded"
113
+ labels = self.fields.get("labels")
114
+ if not isinstance(labels, Mapping) or labels.get("status") not in complete_labels:
115
+ return "degraded"
116
+ return "complete"
117
+
87
118
  def evidence_value(self) -> dict[str, object]:
88
119
  """Return the closed persisted descriptor value."""
89
120
 
@@ -35,6 +35,7 @@ SENSITIVE_HEADER_NAME_RE = re.compile(
35
35
  TRUE_VALUES = {"1", "true", "yes", "on"}
36
36
  FALSE_VALUES = {"0", "false", "no", "off", ""}
37
37
  BUILTIN_EVIDENCE_SERVER = "ocr_toolkit_evidence"
38
+ MCP_PROFILES = frozenset({"local", "gitlab_mr"})
38
39
 
39
40
 
40
41
  class MCPConfigError(Exception):
@@ -64,6 +65,7 @@ class MCPCapability:
64
65
  server: str
65
66
  tools: tuple[str, ...]
66
67
  builtin: bool = False
68
+ transport: str = "stdio"
67
69
 
68
70
 
69
71
  @dataclass(frozen=True, slots=True)
@@ -93,6 +95,17 @@ def _string_list(value: Any, field: str, *, limit: int) -> list[str]:
93
95
  return result
94
96
 
95
97
 
98
+ def _tool_names(value: Any, field: str) -> list[str]:
99
+ """Validate one explicit OCR-compatible tool-name allowlist."""
100
+
101
+ tools = _string_list(value, field, limit=MAX_MCP_TOOLS)
102
+ if any(not tool for tool in tools):
103
+ raise MCPConfigError(f"{field} must not contain empty tool names")
104
+ if len(set(tools)) != len(tools):
105
+ raise MCPConfigError(f"{field} must not contain duplicate tool names")
106
+ return tools
107
+
108
+
96
109
  def _env_assignments(value: Any, env_from: Any, server_name: str) -> tuple[list[str], list[str]]:
97
110
  """Build stdio environment assignments and collect values for redaction."""
98
111
 
@@ -187,6 +200,11 @@ def _headers(value: Any, env_from: Any, server_name: str) -> tuple[dict[str, str
187
200
  )
188
201
  if not isinstance(item, str):
189
202
  raise MCPConfigError(f"servers.{server_name}.headers.{name} must be a string")
203
+ if item.startswith("$") and MCP_ENV_NAME_RE.fullmatch(item[1:]):
204
+ raise MCPConfigError(
205
+ f"servers.{server_name}.headers.{name} resembles an environment reference; "
206
+ "use headers_from"
207
+ )
190
208
  if not item or len(item) > MAX_MCP_HEADER_VALUE_CHARS:
191
209
  raise MCPConfigError(
192
210
  f"servers.{server_name}.headers.{name} must be non-empty and at most "
@@ -265,8 +283,11 @@ def _remote_url(value: Any, server_name: str) -> str:
265
283
  return value
266
284
 
267
285
 
268
- def parse_mcp_servers(raw: str | None = None) -> list[MCPServerConfig]:
269
- """Return bounded stdio and remote configs from OCR_MCP_SERVERS_JSON."""
286
+ def parse_mcp_servers(raw: str | None = None, *, profile: str = "local") -> list[MCPServerConfig]:
287
+ """Return bounded external configs admitted by one execution profile."""
288
+
289
+ if profile not in MCP_PROFILES:
290
+ raise MCPConfigError("internal MCP execution profile is invalid")
270
291
 
271
292
  raw = os.environ.get("OCR_MCP_SERVERS_JSON", "") if raw is None else raw
272
293
  raw = raw.strip()
@@ -329,7 +350,11 @@ def parse_mcp_servers(raw: str | None = None) -> list[MCPServerConfig]:
329
350
  transport = server.get("type", "stdio")
330
351
  if transport not in {"stdio", "remote"}:
331
352
  raise MCPConfigError(f"servers.{name}.type must be 'stdio' or 'remote'")
332
- common_fields = {"name", "type", "enabled", "tools", "setup"}
353
+ if profile == "gitlab_mr" and transport != "remote":
354
+ raise MCPConfigError("GitLab merge-request reviews require external remote MCP")
355
+ common_fields = {"name", "type", "enabled", "tools"}
356
+ if transport == "stdio":
357
+ common_fields.add("setup")
333
358
  transport_fields = (
334
359
  {"command", "args", "env", "env_from"}
335
360
  if transport == "stdio"
@@ -340,7 +365,7 @@ def parse_mcp_servers(raw: str | None = None) -> list[MCPServerConfig]:
340
365
  fields = ", ".join(sorted(unknown_fields))
341
366
  raise MCPConfigError(f"servers.{name} has unsupported fields for {transport}: {fields}")
342
367
 
343
- tools = _string_list(server.get("tools", []), f"servers.{name}.tools", limit=MAX_MCP_TOOLS)
368
+ tools = _tool_names(server.get("tools", []), f"servers.{name}.tools")
344
369
  if not tools:
345
370
  raise MCPConfigError(f"servers.{name}.tools must explicitly allow at least one tool")
346
371
  if TOOL_NAME in tools:
@@ -404,28 +429,98 @@ def _replace_configured_servers() -> bool:
404
429
  raise MCPConfigError("OCR_MCP_REPLACE must be a boolean (true/false, 1/0, yes/no, or on/off)")
405
430
 
406
431
 
407
- def _existing_capability(name: str, value: Any) -> MCPCapability:
408
- """Validate the safe capability fields of one existing OCR MCP entry."""
432
+ def _existing_server(
433
+ name: str, value: Any, *, profile: str
434
+ ) -> tuple[dict[str, Any], MCPCapability, tuple[str, ...]]:
435
+ """Revalidate one inherited OCR MCP entry through the active exact schema."""
409
436
 
410
437
  if not isinstance(name, str) or not MCP_NAME_RE.fullmatch(name):
411
438
  raise MCPConfigError("Existing OCR MCP server name is invalid")
412
439
  if not isinstance(value, dict):
413
440
  raise MCPConfigError(f"Existing OCR MCP server {name!r} is not a JSON object")
414
- tools = _string_list(value.get("tools", []), f"existing.{name}.tools", limit=MAX_MCP_TOOLS)
415
- if not tools:
416
- raise MCPConfigError(
417
- f"Existing OCR MCP server {name!r} must explicitly allow at least one tool"
441
+ if name == BUILTIN_EVIDENCE_SERVER:
442
+ raise MCPConfigError("the toolkit-owned MCP server cannot be inherited")
443
+ normalized = dict(value)
444
+ transport = normalized.get("type", "stdio")
445
+ # Toolkit <=0.6.2 persisted an empty setup key for every remote entry.
446
+ # Normalize only that exact legacy no-op; non-empty inherited setup remains
447
+ # invalid and new remote input never admits the field.
448
+ if transport == "remote" and normalized.get("setup") == "":
449
+ normalized.pop("setup")
450
+ if transport == "stdio" and isinstance(normalized.get("env"), list):
451
+ environment: dict[str, str] = {}
452
+ for assignment in normalized["env"]:
453
+ if not isinstance(assignment, str) or "=" not in assignment:
454
+ raise MCPConfigError(f"Existing OCR MCP server {name!r} has invalid env")
455
+ key, item = assignment.split("=", 1)
456
+ if key in environment:
457
+ raise MCPConfigError(f"Existing OCR MCP server {name!r} has duplicate env name")
458
+ environment[key] = item
459
+ normalized["env"] = environment
460
+ elif transport == "remote" and isinstance(normalized.get("headers"), dict):
461
+ literal_headers: dict[str, object] = {}
462
+ headers_from: dict[str, str] = {}
463
+ for header, item in normalized["headers"].items():
464
+ source = item[1:] if isinstance(item, str) and item.startswith("$") else ""
465
+ if source and MCP_ENV_NAME_RE.fullmatch(source):
466
+ headers_from[header] = source
467
+ else:
468
+ literal_headers[header] = item
469
+ if literal_headers:
470
+ normalized["headers"] = literal_headers
471
+ else:
472
+ normalized.pop("headers", None)
473
+ if headers_from:
474
+ inherited_sources = normalized.get("headers_from")
475
+ if inherited_sources is None:
476
+ normalized["headers_from"] = headers_from
477
+ elif isinstance(inherited_sources, dict):
478
+ normalized_names = {str(header).casefold() for header in inherited_sources}
479
+ if any(header.casefold() in normalized_names for header in headers_from):
480
+ raise MCPConfigError(
481
+ f"Existing OCR MCP server {name!r} has duplicate header sources"
482
+ )
483
+ normalized["headers_from"] = {**inherited_sources, **headers_from}
484
+ raw = json.dumps({name: normalized}, separators=(",", ":"), ensure_ascii=False)
485
+ parsed = parse_mcp_servers(raw, profile=profile)
486
+ if len(parsed) != 1:
487
+ raise MCPConfigError(f"Existing OCR MCP server {name!r} is disabled or invalid")
488
+ server = parsed[0]
489
+ payload: dict[str, Any] = {
490
+ "type": server.transport,
491
+ "tools": server.tools,
492
+ }
493
+ if server.transport == "stdio":
494
+ payload.update(
495
+ {
496
+ "command": server.command,
497
+ "args": server.args,
498
+ "env": server.env,
499
+ "setup": server.setup,
500
+ }
418
501
  )
419
- if TOOL_NAME in tools:
420
- raise MCPConfigError(f"Existing MCP tool name {TOOL_NAME!r} is reserved by the toolkit")
421
- return MCPCapability(name, tuple(tools))
502
+ else:
503
+ payload.update({"url": server.url, "headers": server.headers})
504
+ return (
505
+ payload,
506
+ MCPCapability(server.name, tuple(server.tools), transport=server.transport),
507
+ tuple(server.secret_values),
508
+ )
422
509
 
423
510
 
424
- def compose_mcp_servers(servers: list[MCPServerConfig], *, replace: bool) -> MCPComposition:
511
+ def compose_mcp_servers(
512
+ servers: list[MCPServerConfig], *, replace: bool, profile: str = "local"
513
+ ) -> MCPComposition:
425
514
  """Build OCR's registry from independent optional and mandatory MCP entries."""
426
515
 
516
+ if profile not in MCP_PROFILES:
517
+ raise MCPConfigError("internal MCP execution profile is invalid")
518
+ if profile == "gitlab_mr" and any(server.transport != "remote" for server in servers):
519
+ raise MCPConfigError("GitLab merge-request reviews require external remote MCP")
520
+
427
521
  payload: dict[str, dict[str, Any]] = {}
428
522
  capabilities: list[MCPCapability] = []
523
+ secret_values: list[str] = []
429
524
  if not replace:
430
525
  try:
431
526
  current = read_ocr_config().get("mcp_servers", {})
@@ -433,20 +528,26 @@ def compose_mcp_servers(servers: list[MCPServerConfig], *, replace: bool) -> MCP
433
528
  raise MCPConfigError(str(exc)) from exc
434
529
  if not isinstance(current, dict):
435
530
  raise MCPConfigError("Existing OCR mcp_servers value is not a JSON object")
531
+ declared_names = {server.name for server in servers}
436
532
  for name, value in current.items():
437
- if name == BUILTIN_EVIDENCE_SERVER:
533
+ # Explicit operator input replaces the same named inherited entry;
534
+ # validate only the state that will survive into the final registry.
535
+ if name == BUILTIN_EVIDENCE_SERVER or name in declared_names:
438
536
  continue
439
- capability = _existing_capability(name, value)
440
- payload[name] = value
537
+ inherited, capability, inherited_secrets = _existing_server(
538
+ name, value, profile=profile
539
+ )
540
+ payload[name] = inherited
441
541
  capabilities.append(capability)
542
+ secret_values.extend(inherited_secrets)
442
543
 
443
- secret_values: list[str] = []
444
544
  for server in servers:
445
545
  payload[server.name] = {
446
546
  "type": server.transport,
447
- "setup": server.setup,
448
547
  "tools": server.tools,
449
548
  }
549
+ if server.transport == "stdio":
550
+ payload[server.name]["setup"] = server.setup
450
551
  if server.transport == "stdio":
451
552
  payload[server.name].update(
452
553
  {"command": server.command, "args": server.args, "env": server.env}
@@ -454,7 +555,7 @@ def compose_mcp_servers(servers: list[MCPServerConfig], *, replace: bool) -> MCP
454
555
  else:
455
556
  payload[server.name].update({"url": server.url, "headers": server.headers})
456
557
  secret_values.extend(server.secret_values)
457
- capability = MCPCapability(server.name, tuple(server.tools))
558
+ capability = MCPCapability(server.name, tuple(server.tools), transport=server.transport)
458
559
  capabilities = [item for item in capabilities if item.server != server.name]
459
560
  capabilities.append(capability)
460
561
 
@@ -497,10 +598,14 @@ def compose_mcp_servers(servers: list[MCPServerConfig], *, replace: bool) -> MCP
497
598
  )
498
599
 
499
600
 
500
- def build_mcp_composition() -> MCPComposition:
501
- """Parse environment settings into the complete OCR MCP composition."""
601
+ def build_mcp_composition(*, profile: str = "local") -> MCPComposition:
602
+ """Parse environment settings into the complete profiled MCP composition."""
502
603
 
503
- return compose_mcp_servers(parse_mcp_servers(), replace=_replace_configured_servers())
604
+ return compose_mcp_servers(
605
+ parse_mcp_servers(profile=profile),
606
+ replace=_replace_configured_servers(),
607
+ profile=profile,
608
+ )
504
609
 
505
610
 
506
611
  def apply_mcp_composition(composition: MCPComposition) -> None:
@@ -18,11 +18,13 @@ from ocr_toolkit.common.redaction import sanitize_ocr_value
18
18
  DEFAULT_MAX_RESULT_BYTES = 2_000_000
19
19
  MAX_RESULT_BYTES_HARD_LIMIT = 20_000_000
20
20
  TOOLKIT_RESULT_KEY = "_ocr_toolkit"
21
- TOOLKIT_RESULT_SCHEMA_VERSION = 2
22
- SUPPORTED_TOOLKIT_RESULT_SCHEMA_VERSIONS = frozenset({1, TOOLKIT_RESULT_SCHEMA_VERSION})
23
- AUTOMATIC_APPROVAL_BLOCK_REASON = "author-controlled merge-request context was admitted"
21
+ TOOLKIT_RESULT_SCHEMA_VERSION = 3
22
+ SUPPORTED_TOOLKIT_RESULT_SCHEMA_VERSIONS = frozenset({1, 2, TOOLKIT_RESULT_SCHEMA_VERSION})
24
23
  # The receipt can name the 16 configured external servers plus the mandatory built-in.
25
24
  MAX_TOOLKIT_MCP_USAGE_SERVERS = 17
25
+ MAX_TOOLKIT_MCP_TOOLS_PER_SERVER = 128
26
+ MAX_TOOLKIT_MCP_TOOL_NAME_CHARS = 4_096
27
+ MAX_TOOLKIT_MCP_USAGE_COUNT = 1_000_000_000
26
28
  TOOLKIT_MCP_SERVER_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
27
29
 
28
30
 
@@ -0,0 +1,266 @@
1
+ """Conservative policy and typed outcomes for GitLab automatic approval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ from ocr_toolkit.ocr_result import (
10
+ MAX_TOOLKIT_MCP_TOOL_NAME_CHARS,
11
+ MAX_TOOLKIT_MCP_TOOLS_PER_SERVER,
12
+ MAX_TOOLKIT_MCP_USAGE_COUNT,
13
+ MAX_TOOLKIT_MCP_USAGE_SERVERS,
14
+ TOOLKIT_MCP_SERVER_NAME_RE,
15
+ )
16
+ from ocr_toolkit.posting.settings import BooleanSetting
17
+ from ocr_toolkit.result_contract import ReviewOutcome
18
+
19
+ ALLOWED_CATEGORIES = frozenset({"style", "documentation", "maintainability"})
20
+ MAX_APPROVABLE_FINDINGS = 3
21
+
22
+
23
+ class ApprovalStatus(str, Enum):
24
+ """Closed public states for one automatic-approval transaction."""
25
+
26
+ APPROVED = "approved"
27
+ NOT_ELIGIBLE = "not eligible"
28
+ DISABLED = "disabled"
29
+ SKIPPED = "skipped"
30
+ FAILED = "failed"
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class ApprovalResult:
35
+ """Bounded status rendered in the review summary and runner log."""
36
+
37
+ status: ApprovalStatus
38
+ reason: str
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class ApprovalEligibility:
43
+ """Policy conclusion before provider state is consulted."""
44
+
45
+ eligible: bool
46
+ result: ApprovalResult
47
+
48
+
49
+ def evaluate_approval_policy(
50
+ setting: BooleanSetting,
51
+ outcome: ReviewOutcome,
52
+ comments: list[dict[str, Any]],
53
+ warnings: list[Any],
54
+ omitted_count: int,
55
+ toolkit_metadata: Any = None,
56
+ ) -> ApprovalEligibility:
57
+ """Evaluate the fixed v0.4.7 policy from authoritative OCR data."""
58
+
59
+ if not setting.enabled:
60
+ reason = (
61
+ "configuration was invalid and failed closed"
62
+ if not setting.valid
63
+ else "disabled by OCR_AUTO_APPROVE"
64
+ )
65
+ return ApprovalEligibility(
66
+ False,
67
+ ApprovalResult(ApprovalStatus.DISABLED, reason),
68
+ )
69
+ metadata_reason = automatic_approval_metadata_reason(toolkit_metadata)
70
+ if metadata_reason:
71
+ reason = metadata_reason
72
+ elif toolkit_metadata["evidence"]["mandatory"] is not outcome.requires_evidence_mcp:
73
+ reason = "the review-time approval receipt is missing or invalid"
74
+ elif not outcome.manifest_present:
75
+ reason = "the OCR result has no authoritative coverage manifest"
76
+ elif outcome.kind != "clean" or outcome.budget_exceeded:
77
+ reason = "the OCR review did not complete cleanly"
78
+ elif outcome.failed_count or outcome.waived_count:
79
+ reason = "coverage contained failed or waived items"
80
+ elif warnings:
81
+ reason = "the OCR review reported warnings"
82
+ elif omitted_count:
83
+ reason = "one or more findings were omitted from publication"
84
+ elif len(comments) > MAX_APPROVABLE_FINDINGS:
85
+ reason = f"the review reported more than {MAX_APPROVABLE_FINDINGS} findings"
86
+ else:
87
+ reason = ""
88
+
89
+ if reason:
90
+ return ApprovalEligibility(
91
+ False,
92
+ ApprovalResult(ApprovalStatus.NOT_ELIGIBLE, reason),
93
+ )
94
+
95
+ for comment in comments:
96
+ severity = comment.get("severity")
97
+ category = comment.get("category")
98
+ if severity != "low":
99
+ return ApprovalEligibility(
100
+ False,
101
+ ApprovalResult(
102
+ ApprovalStatus.NOT_ELIGIBLE,
103
+ "a finding had a blocking or malformed severity",
104
+ ),
105
+ )
106
+ if not isinstance(category, str) or category not in ALLOWED_CATEGORIES:
107
+ return ApprovalEligibility(
108
+ False,
109
+ ApprovalResult(
110
+ ApprovalStatus.NOT_ELIGIBLE,
111
+ "a finding had a blocking or malformed category",
112
+ ),
113
+ )
114
+
115
+ return ApprovalEligibility(
116
+ True,
117
+ ApprovalResult(
118
+ ApprovalStatus.SKIPPED,
119
+ "awaiting post-publication SHA verification",
120
+ ),
121
+ )
122
+
123
+
124
+ def approval_summary_line(result: ApprovalResult) -> str:
125
+ """Render exactly one bounded automatic-approval state line."""
126
+
127
+ return f"- Automatic approval: `{result.status.value}` — {result.reason}."
128
+
129
+
130
+ def provisional_approval_result(eligibility: ApprovalEligibility) -> ApprovalResult:
131
+ """Return a fail-closed state safe to publish before provider readback."""
132
+
133
+ if not eligibility.eligible:
134
+ return eligibility.result
135
+ return ApprovalResult(
136
+ ApprovalStatus.FAILED,
137
+ "automatic approval has not yet been confirmed",
138
+ )
139
+
140
+
141
+ def _full_sha(value: Any) -> bool:
142
+ return (
143
+ isinstance(value, str)
144
+ and len(value) == 40
145
+ and all(char in "0123456789abcdef" for char in value)
146
+ )
147
+
148
+
149
+ def _positive_id(value: Any) -> bool:
150
+ return isinstance(value, int) and not isinstance(value, bool) and value > 0
151
+
152
+
153
+ def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str:
154
+ """Return the closed review-time receipt blocker for automatic approval."""
155
+
156
+ invalid = "the review-time approval receipt is missing or invalid"
157
+ if not isinstance(toolkit_metadata, dict):
158
+ return invalid
159
+ schema_version = toolkit_metadata.get("schema_version")
160
+ if schema_version in {1, 2}:
161
+ return "the review-time approval receipt predates current eligibility controls"
162
+ if schema_version != 3 or set(toolkit_metadata) != {
163
+ "schema_version",
164
+ "review",
165
+ "context",
166
+ "mcp",
167
+ "evidence",
168
+ }:
169
+ return invalid
170
+
171
+ review = toolkit_metadata.get("review")
172
+ if not isinstance(review, dict) or set(review) != {"source_sha", "policy_sha", "mr_author_id"}:
173
+ return invalid
174
+ if not _full_sha(review.get("source_sha")) or not _full_sha(review.get("policy_sha")):
175
+ return invalid
176
+ if not _positive_id(review.get("mr_author_id")):
177
+ return invalid
178
+
179
+ context = toolkit_metadata.get("context")
180
+ if not isinstance(context, dict) or set(context) != {"mode", "state", "classes"}:
181
+ return invalid
182
+ mode = context.get("mode")
183
+ state = context.get("state")
184
+ classes = context.get("classes")
185
+ expected_classes = [] if mode == "off" else ["merge_request_metadata"]
186
+ if (
187
+ mode not in {"off", "metadata"}
188
+ or state not in {"disabled", "complete", "degraded"}
189
+ or classes != expected_classes
190
+ or (mode == "off" and state != "disabled")
191
+ or (mode == "metadata" and state == "disabled")
192
+ ):
193
+ return invalid
194
+
195
+ mcp = toolkit_metadata.get("mcp")
196
+ if not isinstance(mcp, dict) or set(mcp) != {"capabilities", "usage"}:
197
+ return invalid
198
+ capabilities = mcp.get("capabilities")
199
+ usage = mcp.get("usage")
200
+ if (
201
+ not isinstance(capabilities, list)
202
+ or not 1 <= len(capabilities) <= MAX_TOOLKIT_MCP_USAGE_SERVERS
203
+ or not isinstance(usage, dict)
204
+ or len(usage) > MAX_TOOLKIT_MCP_USAGE_SERVERS
205
+ ):
206
+ return invalid
207
+ servers: set[str] = set()
208
+ tool_owners: set[str] = set()
209
+ external = False
210
+ builtin_server = "ocr_toolkit_evidence"
211
+ for capability in capabilities:
212
+ if not isinstance(capability, dict) or set(capability) != {"server", "transport", "tools"}:
213
+ return invalid
214
+ server = capability.get("server")
215
+ transport = capability.get("transport")
216
+ tools = capability.get("tools")
217
+ if (
218
+ not isinstance(server, str)
219
+ or TOOLKIT_MCP_SERVER_NAME_RE.fullmatch(server) is None
220
+ or server in servers
221
+ or transport not in {"builtin", "stdio", "remote"}
222
+ or not isinstance(tools, list)
223
+ or not 1 <= len(tools) <= MAX_TOOLKIT_MCP_TOOLS_PER_SERVER
224
+ or any(
225
+ not isinstance(tool, str) or not tool or len(tool) > MAX_TOOLKIT_MCP_TOOL_NAME_CHARS
226
+ for tool in tools
227
+ )
228
+ or len(set(tools)) != len(tools)
229
+ or any(tool in tool_owners for tool in tools)
230
+ or (server == builtin_server) != (transport == "builtin")
231
+ or (server == builtin_server and tools != [builtin_server])
232
+ ):
233
+ return invalid
234
+ servers.add(server)
235
+ tool_owners.update(tools)
236
+ external = external or server != builtin_server
237
+ if builtin_server not in servers:
238
+ return invalid
239
+ if any(
240
+ not isinstance(server, str)
241
+ or server not in servers
242
+ or not isinstance(count, int)
243
+ or isinstance(count, bool)
244
+ or not 0 < count <= MAX_TOOLKIT_MCP_USAGE_COUNT
245
+ for server, count in usage.items()
246
+ ):
247
+ return invalid
248
+
249
+ evidence = toolkit_metadata.get("evidence")
250
+ if not isinstance(evidence, dict) or set(evidence) != {"mandatory", "used"}:
251
+ return invalid
252
+ mandatory = evidence.get("mandatory")
253
+ used = evidence.get("used")
254
+ evidence_called = usage.get(builtin_server, 0) > 0
255
+ if (
256
+ not isinstance(mandatory, bool)
257
+ or not isinstance(used, bool)
258
+ or used is not evidence_called
259
+ or (mandatory and not used)
260
+ ):
261
+ return invalid
262
+ if context.get("state") == "degraded":
263
+ return "the selected review context was degraded"
264
+ if external:
265
+ return "external MCP was configured for a comment-only review"
266
+ return ""
@@ -488,7 +488,11 @@ def format_mcp_usage_summary(toolkit_metadata: Any) -> str:
488
488
  or toolkit_metadata.get("schema_version") not in SUPPORTED_TOOLKIT_RESULT_SCHEMA_VERSIONS
489
489
  ):
490
490
  return ""
491
- mcp_usage = toolkit_metadata.get("mcp_usage")
491
+ if toolkit_metadata.get("schema_version") == 3:
492
+ mcp = toolkit_metadata.get("mcp")
493
+ mcp_usage = mcp.get("usage") if isinstance(mcp, dict) else None
494
+ else:
495
+ mcp_usage = toolkit_metadata.get("mcp_usage")
492
496
  if not isinstance(mcp_usage, dict):
493
497
  return ""
494
498
  used: list[tuple[str, int]] = []