api-mcp-compiler 0.1.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.
Files changed (56) hide show
  1. api_mcp_compiler/__init__.py +1 -0
  2. api_mcp_compiler/benchmarks.py +188 -0
  3. api_mcp_compiler/cli.py +392 -0
  4. api_mcp_compiler/codegen/__init__.py +6 -0
  5. api_mcp_compiler/codegen/composite.py +55 -0
  6. api_mcp_compiler/codegen/mcp_server.py +404 -0
  7. api_mcp_compiler/codegen/schema.py +249 -0
  8. api_mcp_compiler/codegen/soap_server.py +381 -0
  9. api_mcp_compiler/codegen/tools.py +308 -0
  10. api_mcp_compiler/contracts.py +147 -0
  11. api_mcp_compiler/evaluation/__init__.py +9 -0
  12. api_mcp_compiler/evaluation/harness.py +509 -0
  13. api_mcp_compiler/evaluation/model_driver.py +241 -0
  14. api_mcp_compiler/evaluation/oracles.py +226 -0
  15. api_mcp_compiler/evaluation/preregistration.py +103 -0
  16. api_mcp_compiler/evaluation/restbench.py +208 -0
  17. api_mcp_compiler/evaluation/state.py +281 -0
  18. api_mcp_compiler/ingest/__init__.py +0 -0
  19. api_mcp_compiler/ingest/coverage.py +78 -0
  20. api_mcp_compiler/ingest/documents.py +91 -0
  21. api_mcp_compiler/ingest/openapi.py +1424 -0
  22. api_mcp_compiler/ingest/refs.py +299 -0
  23. api_mcp_compiler/ingest/swagger2.py +336 -0
  24. api_mcp_compiler/ingest/wsdl.py +848 -0
  25. api_mcp_compiler/ingest/xsd.py +311 -0
  26. api_mcp_compiler/models.py +1402 -0
  27. api_mcp_compiler/planning/__init__.py +0 -0
  28. api_mcp_compiler/planning/approval.py +135 -0
  29. api_mcp_compiler/planning/baseline.py +121 -0
  30. api_mcp_compiler/planning/overlay.py +40 -0
  31. api_mcp_compiler/planning/report.py +124 -0
  32. api_mcp_compiler/planning/semantic.py +875 -0
  33. api_mcp_compiler/policy/__init__.py +7 -0
  34. api_mcp_compiler/policy/synthesis.py +460 -0
  35. api_mcp_compiler/provenance.py +129 -0
  36. api_mcp_compiler/reporting/__init__.py +0 -0
  37. api_mcp_compiler/reporting/conversion_report.py +262 -0
  38. api_mcp_compiler/routes.py +119 -0
  39. api_mcp_compiler/runtime/__init__.py +6 -0
  40. api_mcp_compiler/runtime/governance.py +181 -0
  41. api_mcp_compiler/runtime/mock.py +290 -0
  42. api_mcp_compiler/schemas/api_semantic_ir.schema.json +918 -0
  43. api_mcp_compiler/schemas/eval_corpus.schema.json +248 -0
  44. api_mcp_compiler/schemas/evaluation_run.schema.json +215 -0
  45. api_mcp_compiler/schemas/mcp_tool_surface.schema.json +263 -0
  46. api_mcp_compiler/schemas/policy_manifest.schema.json +262 -0
  47. api_mcp_compiler/schemas/preregistration.schema.json +103 -0
  48. api_mcp_compiler/schemas/tool_overlay.schema.json +158 -0
  49. api_mcp_compiler/schemas/tool_plan.schema.json +254 -0
  50. api_mcp_compiler-0.1.0.dist-info/METADATA +304 -0
  51. api_mcp_compiler-0.1.0.dist-info/RECORD +56 -0
  52. api_mcp_compiler-0.1.0.dist-info/WHEEL +5 -0
  53. api_mcp_compiler-0.1.0.dist-info/entry_points.txt +2 -0
  54. api_mcp_compiler-0.1.0.dist-info/licenses/LICENSE +202 -0
  55. api_mcp_compiler-0.1.0.dist-info/licenses/NOTICE +22 -0
  56. api_mcp_compiler-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,188 @@
1
+ """Fetching and verifying third-party benchmark documents.
2
+
3
+ Benchmark specifications are deliberately not stored in this repository. Using material and
4
+ redistributing it are different acts, and only the second carries an obligation this project
5
+ would have to resolve, so fetching sidesteps the question rather than answering it.
6
+
7
+ What makes that safe is the digest. A recorded source is verified on every fetch and a
8
+ mismatch is refused, so a result stays reconstructible from a manifest and a trace even though
9
+ the bytes live elsewhere. That is the same discipline the compiler already applies to every
10
+ document it loads.
11
+
12
+ Nothing here is called during ingestion. Ingestion never reaches the network; fetching is a
13
+ separate step a person runs deliberately.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any
23
+ from urllib.error import URLError
24
+ from urllib.parse import urlsplit
25
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
26
+
27
+ from api_mcp_compiler.models import BenchmarkManifest, BenchmarkSource
28
+
29
+ #: Refuse anything larger. A benchmark specification is a document, not an archive.
30
+ MAX_FETCH_BYTES = 16 * 1024 * 1024
31
+ FETCH_TIMEOUT_SECONDS = 30
32
+
33
+
34
+ class BenchmarkFetchError(RuntimeError):
35
+ """Raised when a source cannot be fetched or does not verify."""
36
+
37
+
38
+ class DigestMismatchError(BenchmarkFetchError):
39
+ """Raised when fetched bytes do not match the digest the manifest recorded.
40
+
41
+ This is the failure the whole mechanism exists to produce. A silent acceptance here would
42
+ make every downstream result unreproducible without anyone noticing.
43
+ """
44
+
45
+
46
+ class _SameHostRedirectHandler(HTTPRedirectHandler):
47
+ """Follows redirects only within the original host.
48
+
49
+ A redirect to another host turns a pinned, reviewed source into an arbitrary one, which is
50
+ precisely what pinning was meant to prevent.
51
+ """
52
+
53
+ def redirect_request(
54
+ self,
55
+ req: Request,
56
+ fp: Any,
57
+ code: int,
58
+ msg: str,
59
+ headers: Any,
60
+ newurl: str,
61
+ ) -> Request | None:
62
+ if urlsplit(newurl).hostname != urlsplit(req.full_url).hostname:
63
+ raise BenchmarkFetchError(
64
+ f"refusing a redirect from {req.full_url} to another host at {newurl}"
65
+ )
66
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class FetchOutcome:
71
+ """What happened for one source."""
72
+
73
+ source_id: str
74
+ target: Path
75
+ digest: str
76
+ recorded: bool
77
+ skipped: bool = False
78
+
79
+
80
+ def digest_of(payload: bytes) -> str:
81
+ """Return the prefixed sha256 of fetched bytes."""
82
+ return f"sha256:{hashlib.sha256(payload).hexdigest()}"
83
+
84
+
85
+ def resolve_target(source: BenchmarkSource, root: Path) -> Path:
86
+ """Resolve where a source is written, refusing anything outside the benchmark directory.
87
+
88
+ A manifest is a document a person edits, so a target that escapes the directory has to be
89
+ refused rather than trusted.
90
+ """
91
+ candidate = (root / source.target).resolve()
92
+ base = root.resolve()
93
+ if not candidate.is_relative_to(base):
94
+ raise BenchmarkFetchError(
95
+ f"source {source.source_id!r} targets {source.target!r}, which resolves outside "
96
+ f"{base}"
97
+ )
98
+ return candidate
99
+
100
+
101
+ def download(url: str) -> bytes:
102
+ """Fetch one document over HTTPS, refusing anything unexpected.
103
+
104
+ Bytes are returned rather than written, so a caller can verify before anything reaches the
105
+ filesystem and a failed verification cannot leave a poisoned file behind.
106
+ """
107
+ if urlsplit(url).scheme != "https":
108
+ raise BenchmarkFetchError(f"refusing to fetch {url!r}: only https is permitted")
109
+ opener = build_opener(_SameHostRedirectHandler)
110
+ request = Request(url, headers={"Accept": "application/json, text/plain, */*"})
111
+ try:
112
+ with opener.open(request, timeout=FETCH_TIMEOUT_SECONDS) as response:
113
+ payload = response.read(MAX_FETCH_BYTES + 1)
114
+ except URLError as error:
115
+ reason = str(getattr(error, "reason", error))
116
+ hint = ""
117
+ if "CERTIFICATE_VERIFY_FAILED" in reason:
118
+ hint = (
119
+ " This interpreter has no certificate authority bundle. On a python.org "
120
+ "build, run its 'Install Certificates.command', or set SSL_CERT_FILE to a "
121
+ "bundle. Certificate verification is never disabled to work around this."
122
+ )
123
+ raise BenchmarkFetchError(f"could not fetch {url}: {reason}.{hint}") from error
124
+ except OSError as error:
125
+ raise BenchmarkFetchError(f"could not fetch {url}: {error}") from error
126
+ if not isinstance(payload, bytes): # pragma: no cover - urllib always yields bytes
127
+ raise BenchmarkFetchError(f"{url} did not return bytes")
128
+ if len(payload) > MAX_FETCH_BYTES:
129
+ raise BenchmarkFetchError(
130
+ f"{url} exceeded the {MAX_FETCH_BYTES} byte ceiling for a benchmark document"
131
+ )
132
+ return payload
133
+
134
+
135
+ def fetch_source(
136
+ source: BenchmarkSource, root: Path, *, record: bool = False
137
+ ) -> tuple[FetchOutcome, BenchmarkSource]:
138
+ """Fetch one source, verify it, and write it only if it verified.
139
+
140
+ With no recorded digest the first fetch is trust-on-first-use and `record` must be set,
141
+ which keeps the moment a source becomes trusted explicit rather than incidental. Every
142
+ fetch afterwards is verified.
143
+ """
144
+ target = resolve_target(source, root)
145
+ if source.sha256 and target.is_file() and digest_of(target.read_bytes()) == source.sha256:
146
+ return (
147
+ FetchOutcome(source.source_id, target, source.sha256, recorded=False, skipped=True),
148
+ source,
149
+ )
150
+
151
+ if source.sha256 is None and not record:
152
+ # Refused before any request. Fetching bytes only to reject them would be pointless
153
+ # traffic, and it would make trusting a source look like something that happens on
154
+ # its own rather than a deliberate act.
155
+ raise BenchmarkFetchError(
156
+ f"source {source.source_id!r} has no recorded digest. Re-run with --record to "
157
+ "trust it on first use, once you are satisfied the source is the right one."
158
+ )
159
+
160
+ payload = download(source.url)
161
+ actual = digest_of(payload)
162
+
163
+ if source.sha256 is None:
164
+ source = source.model_copy(update={"sha256": actual})
165
+ elif actual != source.sha256:
166
+ raise DigestMismatchError(
167
+ f"source {source.source_id!r} fetched from {source.url} has digest {actual}, but "
168
+ f"the manifest records {source.sha256}. Nothing was written."
169
+ )
170
+
171
+ target.parent.mkdir(parents=True, exist_ok=True)
172
+ target.write_bytes(payload)
173
+ return (
174
+ FetchOutcome(source.source_id, target, actual, recorded=source.sha256 == actual),
175
+ source,
176
+ )
177
+
178
+
179
+ def load_manifest(path: Path) -> BenchmarkManifest:
180
+ """Read a benchmark manifest."""
181
+ return BenchmarkManifest.model_validate(json.loads(path.read_text(encoding="utf-8")))
182
+
183
+
184
+ def save_manifest(manifest: BenchmarkManifest, path: Path) -> None:
185
+ """Write a benchmark manifest as canonical JSON."""
186
+ from api_mcp_compiler.contracts import canonical_json
187
+
188
+ path.write_text(canonical_json(manifest.model_dump(mode="json")), encoding="utf-8")
@@ -0,0 +1,392 @@
1
+ """Command line interface for the API-to-MCP agent-readiness compiler.
2
+
3
+ Exposes inspection, baseline planning, tool-surface generation and contract validation.
4
+ No command runs an MCP server, binds to an SDK, or performs network access.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from enum import StrEnum
11
+ from pathlib import Path
12
+
13
+ import typer
14
+
15
+ from api_mcp_compiler.codegen.mcp_server import GENERATED_REQUIREMENTS, emit_server
16
+ from api_mcp_compiler.codegen.soap_server import emit_soap_server
17
+ from api_mcp_compiler.codegen.tools import generate_surface
18
+ from api_mcp_compiler.contracts import (
19
+ ContractViolation,
20
+ canonical_json,
21
+ dump_canonical,
22
+ validate_ir,
23
+ validate_tool_plan,
24
+ validate_tool_surface,
25
+ )
26
+ from api_mcp_compiler.evaluation.harness import run_corpus
27
+ from api_mcp_compiler.ingest.openapi import parse_openapi
28
+ from api_mcp_compiler.ingest.refs import RefPolicy
29
+ from api_mcp_compiler.ingest.wsdl import parse_wsdl
30
+ from api_mcp_compiler.models import (
31
+ ApiSemanticIR,
32
+ EvalCorpus,
33
+ PlannerKind,
34
+ RiskClass,
35
+ SourceFormat,
36
+ ToolPlan,
37
+ )
38
+ from api_mcp_compiler.planning.approval import ApprovalSelectionError, approve
39
+ from api_mcp_compiler.planning.baseline import plan_baseline
40
+ from api_mcp_compiler.planning.overlay import load_overlay, restamp, save_overlay
41
+ from api_mcp_compiler.planning.report import review_report
42
+ from api_mcp_compiler.planning.semantic import plan_semantic
43
+ from api_mcp_compiler.policy.synthesis import synthesize_policy
44
+ from api_mcp_compiler.reporting.conversion_report import write_report
45
+
46
+ app = typer.Typer(no_args_is_help=True, help=__doc__)
47
+
48
+ _WSDL_SUFFIXES = {".wsdl", ".xml"}
49
+
50
+
51
+ class SourceKind(StrEnum):
52
+ """How to interpret a source document."""
53
+
54
+ AUTO = "auto"
55
+ OPENAPI = "openapi"
56
+ WSDL = "wsdl"
57
+
58
+
59
+ OVERLAY_HELP = (
60
+ "Path to a reviewed overlay. Its digest must match the specification, so decisions made "
61
+ "about other bytes are refused rather than silently applied."
62
+ )
63
+ PLANNER_HELP = "Which planner to use. The baseline exists only for controlled comparison."
64
+ ENFORCE_POLICY_HELP = (
65
+ "Derive a policy manifest and fail closed on any tool whose policy is unresolved. "
66
+ "Disabling this shows what would be emitted without governance; it is not a safe mode."
67
+ )
68
+
69
+ ALLOW_DIR_HELP = (
70
+ "Directory whose files may be loaded by $ref. Repeatable. Omitted by default, so a "
71
+ "specification cannot pull in files it was not explicitly pointed at."
72
+ )
73
+
74
+
75
+ def _parse(source: Path, kind: SourceKind, allow_dir: list[Path] | None = None) -> ApiSemanticIR:
76
+ """Dispatch a source document to the matching ingestion adapter."""
77
+ if kind is SourceKind.AUTO:
78
+ kind = SourceKind.WSDL if source.suffix.lower() in _WSDL_SUFFIXES else SourceKind.OPENAPI
79
+ if kind is SourceKind.WSDL:
80
+ return parse_wsdl(source)
81
+ return parse_openapi(source, policy=RefPolicy(allowed_directories=tuple(allow_dir or ())))
82
+
83
+
84
+ def _plan(ir: ApiSemanticIR, planner: PlannerKind, overlay: Path | None) -> ToolPlan:
85
+ """Build a plan with the requested planner, applying an overlay when one is given."""
86
+ if planner is PlannerKind.BASELINE:
87
+ return plan_baseline(ir)
88
+ return plan_semantic(ir, load_overlay(overlay) if overlay else None)
89
+
90
+
91
+ @app.command()
92
+ def inspect(
93
+ source: Path = typer.Argument(..., help="Path to an OpenAPI 3.x or WSDL 1.1 document."),
94
+ kind: SourceKind = typer.Option(SourceKind.AUTO, "--kind", help="Override format detection."),
95
+ allow_dir: list[Path] = typer.Option([], "--allow-dir", help=ALLOW_DIR_HELP),
96
+ baseline: bool = typer.Option(True, help="Include the baseline tool plan in the output."),
97
+ ) -> None:
98
+ """Parse a source document and print the normalized IR as canonical JSON."""
99
+ ir = _parse(source, kind, allow_dir)
100
+ payload: dict[str, object] = {"ir": ir.model_dump(mode="json")}
101
+ if baseline:
102
+ payload["baseline_plan"] = plan_baseline(ir).model_dump(mode="json")
103
+ typer.echo(canonical_json(payload), nl=False)
104
+
105
+
106
+ @app.command()
107
+ def plan(
108
+ source: Path = typer.Argument(..., help="Path to an OpenAPI 3.x or WSDL 1.1 document."),
109
+ kind: SourceKind = typer.Option(SourceKind.AUTO, "--kind", help="Override format detection."),
110
+ allow_dir: list[Path] = typer.Option([], "--allow-dir", help=ALLOW_DIR_HELP),
111
+ planner: PlannerKind = typer.Option(PlannerKind.SEMANTIC, "--planner", help=PLANNER_HELP),
112
+ overlay: Path | None = typer.Option(None, "--overlay", help=OVERLAY_HELP),
113
+ ) -> None:
114
+ """Print a tool plan as canonical JSON.
115
+
116
+ Every artifact is `proposed` until a reviewer records approval in an overlay, and the
117
+ emission gate refuses to make a write or destructive tool executable before then.
118
+ """
119
+ ir = _parse(source, kind, allow_dir)
120
+ typer.echo(dump_canonical(_plan(ir, planner, overlay)), nl=False)
121
+
122
+
123
+ @app.command()
124
+ def generate(
125
+ source: Path = typer.Argument(..., help="Path to an OpenAPI 3.x or WSDL 1.1 document."),
126
+ kind: SourceKind = typer.Option(SourceKind.AUTO, "--kind", help="Override format detection."),
127
+ allow_dir: list[Path] = typer.Option([], "--allow-dir", help=ALLOW_DIR_HELP),
128
+ planner: PlannerKind = typer.Option(PlannerKind.SEMANTIC, "--planner", help=PLANNER_HELP),
129
+ overlay: Path | None = typer.Option(None, "--overlay", help=OVERLAY_HELP),
130
+ enforce_policy: bool = typer.Option(
131
+ True, "--enforce-policy/--no-enforce-policy", help=ENFORCE_POLICY_HELP
132
+ ),
133
+ ) -> None:
134
+ """Generate a tool surface and print it as canonical JSON.
135
+
136
+ The surface binds to no MCP SDK and performs no I/O. A tool is emitted executable only
137
+ when its source operation carries no blocking ambiguity, its risk is classified, and any
138
+ write, destructive or privileged tool has been approved. Refused tools are still emitted,
139
+ carrying the reason, so the surface stays auditable.
140
+ """
141
+ ir = _parse(source, kind, allow_dir)
142
+ plan = _plan(ir, planner, overlay)
143
+ manifest = synthesize_policy(ir, plan) if enforce_policy else None
144
+ typer.echo(dump_canonical(generate_surface(ir, plan, manifest)), nl=False)
145
+
146
+
147
+ @app.command()
148
+ def report(
149
+ source: Path = typer.Argument(..., help="Path to an OpenAPI 3.x or WSDL 1.1 document."),
150
+ out_dir: Path = typer.Option(Path("reports"), "--out-dir", help="Directory for reports."),
151
+ kind: SourceKind = typer.Option(SourceKind.AUTO, "--kind", help="Override format detection."),
152
+ allow_dir: list[Path] = typer.Option([], "--allow-dir", help=ALLOW_DIR_HELP),
153
+ planner: PlannerKind = typer.Option(PlannerKind.SEMANTIC, "--planner", help=PLANNER_HELP),
154
+ overlay: Path | None = typer.Option(None, "--overlay", help=OVERLAY_HELP),
155
+ ) -> None:
156
+ """Write the conversion report a reviewer reads before approving anything.
157
+
158
+ One self-contained HTML file: what was read, what is proposed, what the gate is holding,
159
+ and what needs a decision. Reports are never overwritten, because a decision made against
160
+ one set of proposals is not evidence about a different set, so each run writes a file named
161
+ for the source digest it describes.
162
+ """
163
+ ir = _parse(source, kind, allow_dir)
164
+ plan = _plan(ir, planner, overlay)
165
+ manifest = synthesize_policy(ir, plan)
166
+ written = write_report(out_dir, ir, plan, generate_surface(ir, plan, manifest), manifest)
167
+ typer.echo(f"wrote {written.path}")
168
+ typer.echo(f" executable now: {len(written.executable)}")
169
+ typer.echo(f" held by the gate: {len(written.blocked)}")
170
+ if written.awaiting_review:
171
+ typer.echo(f" awaiting your approval: {len(written.awaiting_review)}")
172
+ typer.echo(" approve by class, for example:")
173
+ typer.echo(f" api-mcp-compiler approve {source} --risk read --overlay <path>")
174
+
175
+
176
+ @app.command("approve")
177
+ def approve_surface(
178
+ source: Path = typer.Argument(..., help="Path to an OpenAPI 3.x or WSDL 1.1 document."),
179
+ overlay: Path = typer.Option(..., "--overlay", help="Overlay to create or extend."),
180
+ risk: RiskClass | None = typer.Option(
181
+ None, "--risk", help="Approve every tool of a risk class."
182
+ ),
183
+ group: str | None = typer.Option(None, "--group", help="Approve every tool in a group."),
184
+ name: list[str] = typer.Option([], "--name", help="Approve one named tool. Repeatable."),
185
+ kind: SourceKind = typer.Option(SourceKind.AUTO, "--kind", help="Override format detection."),
186
+ allow_dir: list[Path] = typer.Option([], "--allow-dir", help=ALLOW_DIR_HELP),
187
+ ) -> None:
188
+ """Record approval for a class of tools, writing the overlay so nobody hand-edits JSON.
189
+
190
+ A selection must name what it covers. There is deliberately no flag that approves a whole
191
+ surface without saying what class of thing it is.
192
+ """
193
+ ir = _parse(source, kind, allow_dir)
194
+ existing = load_overlay(overlay) if overlay.is_file() else None
195
+ plan = plan_semantic(ir, existing)
196
+ try:
197
+ outcome = approve(plan, overlay=existing, risk=risk, group=group, names=name)
198
+ except ApprovalSelectionError as error:
199
+ typer.echo(f"refused: {error}", err=True)
200
+ raise typer.Exit(code=2) from error
201
+ save_overlay(outcome.overlay, overlay)
202
+ typer.echo(f"approved {len(outcome.approved)} artifact(s) in {overlay}")
203
+ for item in outcome.approved:
204
+ typer.echo(f" + {item}")
205
+ if outcome.already_approved:
206
+ typer.echo(f" already approved: {', '.join(outcome.already_approved)}")
207
+ if outcome.untouched:
208
+ typer.echo(f" still awaiting a decision: {', '.join(outcome.untouched)}")
209
+
210
+
211
+ @app.command()
212
+ def serve(
213
+ source: Path = typer.Argument(..., help="Path to an OpenAPI 3.x or WSDL 1.1 document."),
214
+ out: Path = typer.Option(..., "--out", help="Where to write the generated server module."),
215
+ kind: SourceKind = typer.Option(SourceKind.AUTO, "--kind", help="Override format detection."),
216
+ allow_dir: list[Path] = typer.Option([], "--allow-dir", help=ALLOW_DIR_HELP),
217
+ planner: PlannerKind = typer.Option(PlannerKind.SEMANTIC, "--planner", help=PLANNER_HELP),
218
+ overlay: Path | None = typer.Option(None, "--overlay", help=OVERLAY_HELP),
219
+ ) -> None:
220
+ """Emit a runnable MCP server for the approved part of a surface.
221
+
222
+ Only tools that cleared the emission gate are registered. Tools the gate withheld are
223
+ named by a `surface://withheld` resource and are deliberately absent, so a deployment
224
+ cannot pick up the tools and leave the decision behind. Policy travels with them:
225
+ confirmation, output ceilings and redaction are written into the server rather than
226
+ documented beside it.
227
+
228
+ The generated module needs `mcp` and `httpx`, which this compiler does not depend on.
229
+ """
230
+ ir = _parse(source, kind, allow_dir)
231
+ plan = _plan(ir, planner, overlay)
232
+ manifest = synthesize_policy(ir, plan)
233
+ surface = generate_surface(ir, plan, manifest)
234
+ # A SOAP service has no routes: every operation is a POST to one endpoint and what
235
+ # distinguishes them is the envelope, so it needs a different emitter rather than a
236
+ # branch inside the same one.
237
+ soap = ir.service.source_format is SourceFormat.WSDL
238
+ if soap:
239
+ soap_emitted = emit_soap_server(ir, surface, manifest)
240
+ generated, registered, withheld = (
241
+ soap_emitted.source,
242
+ soap_emitted.registered,
243
+ soap_emitted.withheld,
244
+ )
245
+ upstream = soap_emitted.endpoint
246
+ else:
247
+ http_emitted = emit_server(ir, surface, manifest)
248
+ generated, registered, withheld = (
249
+ http_emitted.source,
250
+ http_emitted.registered,
251
+ http_emitted.withheld,
252
+ )
253
+ upstream = http_emitted.base_url
254
+ out.parent.mkdir(parents=True, exist_ok=True)
255
+ out.write_text(generated, encoding="utf-8")
256
+ typer.echo(f"wrote {out} for {ir.service.service_id} ({'SOAP' if soap else 'HTTP'})")
257
+ typer.echo(f" registered {len(registered)}: {', '.join(registered)}")
258
+ if withheld:
259
+ typer.echo(f" withheld {len(withheld)}:")
260
+ for name, reason in sorted(withheld.items()):
261
+ typer.echo(f" {name}: {reason}")
262
+ typer.echo(f" upstream {upstream}")
263
+ typer.echo(f" run it with: pip install {' '.join(GENERATED_REQUIREMENTS)} && python {out}")
264
+
265
+
266
+ @app.command()
267
+ def policy(
268
+ source: Path = typer.Argument(..., help="Path to an OpenAPI 3.x or WSDL 1.1 document."),
269
+ kind: SourceKind = typer.Option(SourceKind.AUTO, "--kind", help="Override format detection."),
270
+ allow_dir: list[Path] = typer.Option([], "--allow-dir", help=ALLOW_DIR_HELP),
271
+ planner: PlannerKind = typer.Option(PlannerKind.SEMANTIC, "--planner", help=PLANNER_HELP),
272
+ overlay: Path | None = typer.Option(None, "--overlay", help=OVERLAY_HELP),
273
+ ) -> None:
274
+ """Print the governance manifest for a planned surface as canonical JSON.
275
+
276
+ Policy is derived separately from code generation. Anything that cannot be derived is
277
+ named in `unresolved`, and generation then refuses the tool rather than defaulting it.
278
+ """
279
+ ir = _parse(source, kind, allow_dir)
280
+ typer.echo(dump_canonical(synthesize_policy(ir, _plan(ir, planner, overlay))), nl=False)
281
+
282
+
283
+ @app.command()
284
+ def review(
285
+ source: Path = typer.Argument(..., help="Path to an OpenAPI 3.x or WSDL 1.1 document."),
286
+ kind: SourceKind = typer.Option(SourceKind.AUTO, "--kind", help="Override format detection."),
287
+ allow_dir: list[Path] = typer.Option([], "--allow-dir", help=ALLOW_DIR_HELP),
288
+ overlay: Path | None = typer.Option(None, "--overlay", help=OVERLAY_HELP),
289
+ ) -> None:
290
+ """Print the human review report for the semantic plan.
291
+
292
+ This is the artifact the approval gate depends on: every proposed rename, omission,
293
+ grouping, projection and composite, with its rationale and confidence.
294
+ """
295
+ ir = _parse(source, kind, allow_dir)
296
+ typer.echo(review_report(ir, plan_semantic(ir, load_overlay(overlay) if overlay else None)))
297
+
298
+
299
+ @app.command("overlay-restamp")
300
+ def overlay_restamp(
301
+ source: Path = typer.Argument(..., help="Path to the specification the overlay describes."),
302
+ overlay: Path = typer.Argument(..., help="Overlay to re-stamp in place."),
303
+ kind: SourceKind = typer.Option(SourceKind.AUTO, "--kind", help="Override format detection."),
304
+ allow_dir: list[Path] = typer.Option([], "--allow-dir", help=ALLOW_DIR_HELP),
305
+ ) -> None:
306
+ """Bind an overlay to the current specification revision.
307
+
308
+ Run this only after re-reading the decisions against the changed specification. The
309
+ digest is what stops an approval granted for one revision from applying to another, so
310
+ re-stamping without reviewing defeats the mechanism it exists to provide.
311
+ """
312
+ ir = _parse(source, kind, allow_dir)
313
+ current = load_overlay(overlay)
314
+ if current.source_digest == ir.service.source_digest:
315
+ typer.echo(f"{overlay}: already bound to {ir.service.source_digest}.")
316
+ return
317
+ save_overlay(restamp(current, ir.service.source_digest), overlay)
318
+ typer.echo(
319
+ f"{overlay}: re-stamped from {current.source_digest} to {ir.service.source_digest}. "
320
+ "Confirm the recorded decisions still hold."
321
+ )
322
+
323
+
324
+ @app.command()
325
+ def evaluate(
326
+ source: Path = typer.Argument(..., help="Path to an OpenAPI 3.x or WSDL 1.1 document."),
327
+ corpus: Path = typer.Argument(..., help="Path to an evaluation corpus."),
328
+ kind: SourceKind = typer.Option(SourceKind.AUTO, "--kind", help="Override format detection."),
329
+ allow_dir: list[Path] = typer.Option([], "--allow-dir", help=ALLOW_DIR_HELP),
330
+ planner: PlannerKind = typer.Option(PlannerKind.SEMANTIC, "--planner", help=PLANNER_HELP),
331
+ overlay: Path | None = typer.Option(None, "--overlay", help=OVERLAY_HELP),
332
+ enforce_policy: bool = typer.Option(
333
+ True, "--enforce-policy/--no-enforce-policy", help=ENFORCE_POLICY_HELP
334
+ ),
335
+ ) -> None:
336
+ """Run an evaluation corpus against a generated surface and print the result.
337
+
338
+ The only driver available replays the reference solution each task records. It is correct
339
+ by construction and therefore scores every surface identically, which makes it useful for
340
+ checking that the harness agrees with itself and useless for comparing surfaces. No
341
+ output of this command is evidence that one planner outperforms another.
342
+ """
343
+ ir = _parse(source, kind, allow_dir)
344
+ plan = _plan(ir, planner, overlay)
345
+ manifest = synthesize_policy(ir, plan) if enforce_policy else None
346
+ loaded = EvalCorpus.model_validate(json.loads(corpus.read_text(encoding="utf-8")))
347
+ run = run_corpus(loaded, ir, generate_surface(ir, plan, manifest), manifest)
348
+ typer.echo(dump_canonical(run), nl=False)
349
+
350
+
351
+ @app.command()
352
+ def validate(
353
+ source: Path = typer.Argument(..., help="Path to an OpenAPI 3.x or WSDL 1.1 document."),
354
+ kind: SourceKind = typer.Option(SourceKind.AUTO, "--kind", help="Override format detection."),
355
+ allow_dir: list[Path] = typer.Option([], "--allow-dir", help=ALLOW_DIR_HELP),
356
+ ) -> None:
357
+ """Validate the IR and baseline plan against their schemas and report ambiguities.
358
+
359
+ Exits non-zero when a contract is violated. Blocking ambiguities are reported but do not
360
+ fail the command: they are the work queue for later phases, not defects in this one.
361
+ """
362
+ ir = _parse(source, kind, allow_dir)
363
+ baseline = plan_baseline(ir)
364
+ try:
365
+ validate_ir(ir.model_dump(mode="json"), label=f"IR for {source}")
366
+ validate_tool_plan(baseline.model_dump(mode="json"), label=f"baseline plan for {source}")
367
+ manifest = synthesize_policy(ir, baseline)
368
+ surface = generate_surface(ir, baseline, manifest)
369
+ validate_tool_surface(
370
+ surface.model_dump(mode="json"), label=f"tool surface for {source}"
371
+ )
372
+ except ContractViolation as error:
373
+ typer.echo(str(error), err=True)
374
+ raise typer.Exit(code=1) from error
375
+
376
+ blocking = ir.blocking_ambiguities
377
+ typer.echo(
378
+ f"{source}: {len(ir.operations)} operations, {len(baseline.artifacts)} baseline artifacts, "
379
+ f"{len(surface.executable_tools)}/{len(surface.tools)} executable, "
380
+ f"{len(ir.ambiguities)} ambiguities ({len(blocking)} blocking). Contracts valid."
381
+ )
382
+ for tool in surface.tools:
383
+ if tool.blockers:
384
+ reasons = ", ".join(item.value for item in tool.blockers)
385
+ typer.echo(f" DISABLED {tool.name}: {reasons}")
386
+ for item in ir.ambiguities:
387
+ marker = "BLOCKING" if item.blocking else "note "
388
+ typer.echo(f" {marker} {item.code} at {item.field}: {item.detail}")
389
+
390
+
391
+ if __name__ == "__main__":
392
+ app()
@@ -0,0 +1,6 @@
1
+ """Generation of transport-independent tool surfaces from an IR and a reviewed plan.
2
+
3
+ Code generation is deliberately separate from planning and from policy. This package turns
4
+ a plan plus the IR it references into tool descriptors, and refuses executable status to
5
+ anything that has not cleared the safety gate. It binds to no SDK and performs no I/O.
6
+ """
@@ -0,0 +1,55 @@
1
+ """Threading a value from one step of a composite into the next.
2
+
3
+ A composite exists because one of its steps needs a value the goal cannot supply: an
4
+ identifier that only an earlier call returns. Working out which value, and where it goes, is
5
+ what turns a proposal into something executable.
6
+
7
+ The derivation is the same one the planner used to propose the pair, shared rather than
8
+ reimplemented, so a composite is never executed on a different rule from the one that
9
+ justified it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+ from api_mcp_compiler.models import OperationIR
17
+ from api_mcp_compiler.routes import thread_binding
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ThreadedArgument:
22
+ """One value carried from an earlier step into a later one."""
23
+
24
+ step_index: int
25
+ argument: str
26
+ from_step: int
27
+ response_field: str
28
+
29
+
30
+ def composite_threading(operations: list[OperationIR]) -> dict[str, ThreadedArgument]:
31
+ """Resolve every argument a composite fills for itself, keyed by argument name.
32
+
33
+ A single operation threads nothing: there is no earlier step to take a value from.
34
+ """
35
+ if len(operations) < 2:
36
+ return {}
37
+ threaded: dict[str, ThreadedArgument] = {}
38
+ for index, writer in enumerate(operations):
39
+ if index == 0:
40
+ continue
41
+ for earlier, reader in enumerate(operations[:index]):
42
+ binding = thread_binding(reader, writer)
43
+ if binding is None:
44
+ continue
45
+ argument, field = binding
46
+ threaded.setdefault(
47
+ argument,
48
+ ThreadedArgument(
49
+ step_index=index,
50
+ argument=argument,
51
+ from_step=earlier,
52
+ response_field=field,
53
+ ),
54
+ )
55
+ return threaded