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.
- api_mcp_compiler/__init__.py +1 -0
- api_mcp_compiler/benchmarks.py +188 -0
- api_mcp_compiler/cli.py +392 -0
- api_mcp_compiler/codegen/__init__.py +6 -0
- api_mcp_compiler/codegen/composite.py +55 -0
- api_mcp_compiler/codegen/mcp_server.py +404 -0
- api_mcp_compiler/codegen/schema.py +249 -0
- api_mcp_compiler/codegen/soap_server.py +381 -0
- api_mcp_compiler/codegen/tools.py +308 -0
- api_mcp_compiler/contracts.py +147 -0
- api_mcp_compiler/evaluation/__init__.py +9 -0
- api_mcp_compiler/evaluation/harness.py +509 -0
- api_mcp_compiler/evaluation/model_driver.py +241 -0
- api_mcp_compiler/evaluation/oracles.py +226 -0
- api_mcp_compiler/evaluation/preregistration.py +103 -0
- api_mcp_compiler/evaluation/restbench.py +208 -0
- api_mcp_compiler/evaluation/state.py +281 -0
- api_mcp_compiler/ingest/__init__.py +0 -0
- api_mcp_compiler/ingest/coverage.py +78 -0
- api_mcp_compiler/ingest/documents.py +91 -0
- api_mcp_compiler/ingest/openapi.py +1424 -0
- api_mcp_compiler/ingest/refs.py +299 -0
- api_mcp_compiler/ingest/swagger2.py +336 -0
- api_mcp_compiler/ingest/wsdl.py +848 -0
- api_mcp_compiler/ingest/xsd.py +311 -0
- api_mcp_compiler/models.py +1402 -0
- api_mcp_compiler/planning/__init__.py +0 -0
- api_mcp_compiler/planning/approval.py +135 -0
- api_mcp_compiler/planning/baseline.py +121 -0
- api_mcp_compiler/planning/overlay.py +40 -0
- api_mcp_compiler/planning/report.py +124 -0
- api_mcp_compiler/planning/semantic.py +875 -0
- api_mcp_compiler/policy/__init__.py +7 -0
- api_mcp_compiler/policy/synthesis.py +460 -0
- api_mcp_compiler/provenance.py +129 -0
- api_mcp_compiler/reporting/__init__.py +0 -0
- api_mcp_compiler/reporting/conversion_report.py +262 -0
- api_mcp_compiler/routes.py +119 -0
- api_mcp_compiler/runtime/__init__.py +6 -0
- api_mcp_compiler/runtime/governance.py +181 -0
- api_mcp_compiler/runtime/mock.py +290 -0
- api_mcp_compiler/schemas/api_semantic_ir.schema.json +918 -0
- api_mcp_compiler/schemas/eval_corpus.schema.json +248 -0
- api_mcp_compiler/schemas/evaluation_run.schema.json +215 -0
- api_mcp_compiler/schemas/mcp_tool_surface.schema.json +263 -0
- api_mcp_compiler/schemas/policy_manifest.schema.json +262 -0
- api_mcp_compiler/schemas/preregistration.schema.json +103 -0
- api_mcp_compiler/schemas/tool_overlay.schema.json +158 -0
- api_mcp_compiler/schemas/tool_plan.schema.json +254 -0
- api_mcp_compiler-0.1.0.dist-info/METADATA +304 -0
- api_mcp_compiler-0.1.0.dist-info/RECORD +56 -0
- api_mcp_compiler-0.1.0.dist-info/WHEEL +5 -0
- api_mcp_compiler-0.1.0.dist-info/entry_points.txt +2 -0
- api_mcp_compiler-0.1.0.dist-info/licenses/LICENSE +202 -0
- api_mcp_compiler-0.1.0.dist-info/licenses/NOTICE +22 -0
- 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")
|
api_mcp_compiler/cli.py
ADDED
|
@@ -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
|