openapi-agent-ready 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.
- agent_ready/__init__.py +16 -0
- agent_ready/auditor.py +208 -0
- agent_ready/cli.py +175 -0
- agent_ready/mcp_generator.py +119 -0
- agent_ready/report_generator.py +93 -0
- agent_ready/rubric.py +511 -0
- openapi_agent_ready-0.1.0.dist-info/METADATA +326 -0
- openapi_agent_ready-0.1.0.dist-info/RECORD +11 -0
- openapi_agent_ready-0.1.0.dist-info/WHEEL +4 -0
- openapi_agent_ready-0.1.0.dist-info/entry_points.txt +2 -0
- openapi_agent_ready-0.1.0.dist-info/licenses/LICENSE +21 -0
agent_ready/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""agent-ready: audit OpenAPI specs for AI-agent and MCP readiness."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .auditor import SpecLoadError, extract_endpoints, load_spec, run_audit
|
|
6
|
+
from .rubric import CATEGORY_WEIGHTS, EndpointInfo, Finding
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CATEGORY_WEIGHTS",
|
|
10
|
+
"EndpointInfo",
|
|
11
|
+
"Finding",
|
|
12
|
+
"SpecLoadError",
|
|
13
|
+
"extract_endpoints",
|
|
14
|
+
"load_spec",
|
|
15
|
+
"run_audit",
|
|
16
|
+
]
|
agent_ready/auditor.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core audit engine: parse an OpenAPI spec, run the rubric, produce findings.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.request import urlopen
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from .rubric import (
|
|
14
|
+
CATEGORY_WEIGHTS,
|
|
15
|
+
EndpointInfo,
|
|
16
|
+
Finding,
|
|
17
|
+
check_ambiguity,
|
|
18
|
+
check_auth_clarity,
|
|
19
|
+
check_description_clarity,
|
|
20
|
+
check_error_responses,
|
|
21
|
+
check_parameter_explanation,
|
|
22
|
+
check_schema_strictness,
|
|
23
|
+
check_side_effects,
|
|
24
|
+
check_tool_surface,
|
|
25
|
+
check_usage_guidelines,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
SEVERITY_VALUE = {"pass": 1.0, "warning": 0.5, "fail": 0.0}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SpecLoadError(Exception):
|
|
32
|
+
"""Raised when a spec can't be loaded or parsed."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_spec(source: str, timeout: int = 30) -> dict:
|
|
36
|
+
"""Load an OpenAPI spec from a local path or an http(s) URL."""
|
|
37
|
+
if source.startswith(("http://", "https://")):
|
|
38
|
+
try:
|
|
39
|
+
with urlopen(source, timeout=timeout) as resp:
|
|
40
|
+
raw = resp.read().decode("utf-8")
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
raise SpecLoadError(f"Could not fetch spec from {source}: {exc}") from exc
|
|
43
|
+
else:
|
|
44
|
+
path = Path(source)
|
|
45
|
+
if not path.exists():
|
|
46
|
+
raise SpecLoadError(f"Spec file not found: {source}")
|
|
47
|
+
raw = path.read_text(encoding="utf-8")
|
|
48
|
+
|
|
49
|
+
# YAML is a superset of JSON, so safe_load handles both. Try JSON first for
|
|
50
|
+
# a clearer error on malformed JSON.
|
|
51
|
+
try:
|
|
52
|
+
if source.endswith(".json") or raw.lstrip().startswith("{"):
|
|
53
|
+
return json.loads(raw)
|
|
54
|
+
return yaml.safe_load(raw)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
raise SpecLoadError(f"Could not parse spec as JSON or YAML: {exc}") from exc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _resolve_ref(spec: dict, ref: str):
|
|
60
|
+
"""Resolve a local $ref like '#/components/parameters/PageSize'."""
|
|
61
|
+
if not ref.startswith("#/"):
|
|
62
|
+
return None
|
|
63
|
+
node = spec
|
|
64
|
+
for part in ref[2:].split("/"):
|
|
65
|
+
part = part.replace("~1", "/").replace("~0", "~")
|
|
66
|
+
if not isinstance(node, dict) or part not in node:
|
|
67
|
+
return None
|
|
68
|
+
node = node[part]
|
|
69
|
+
return node
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def extract_endpoints(spec: dict) -> list[EndpointInfo]:
|
|
73
|
+
"""Pull every operation out of the spec, resolving parameter $refs."""
|
|
74
|
+
endpoints = []
|
|
75
|
+
paths = spec.get("paths") or {}
|
|
76
|
+
if not isinstance(paths, dict):
|
|
77
|
+
return endpoints
|
|
78
|
+
|
|
79
|
+
http_methods = {"get", "post", "put", "patch", "delete", "head", "options"}
|
|
80
|
+
|
|
81
|
+
for path, methods in paths.items():
|
|
82
|
+
if not isinstance(methods, dict):
|
|
83
|
+
continue
|
|
84
|
+
# Parameters can be declared once at the path level and shared.
|
|
85
|
+
shared_params = methods.get("parameters", []) or []
|
|
86
|
+
|
|
87
|
+
for method, op in methods.items():
|
|
88
|
+
if method.lower() not in http_methods or not isinstance(op, dict):
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
raw_params = list(shared_params) + list(op.get("parameters", []) or [])
|
|
92
|
+
resolved = []
|
|
93
|
+
for p in raw_params:
|
|
94
|
+
if isinstance(p, dict) and "$ref" in p:
|
|
95
|
+
target = _resolve_ref(spec, p["$ref"])
|
|
96
|
+
if isinstance(target, dict):
|
|
97
|
+
resolved.append(target)
|
|
98
|
+
elif isinstance(p, dict):
|
|
99
|
+
resolved.append(p)
|
|
100
|
+
|
|
101
|
+
endpoints.append(
|
|
102
|
+
EndpointInfo(
|
|
103
|
+
path=path,
|
|
104
|
+
method=method,
|
|
105
|
+
summary=op.get("summary") or "",
|
|
106
|
+
description=op.get("description") or "",
|
|
107
|
+
parameters=resolved,
|
|
108
|
+
responses=op.get("responses") or {},
|
|
109
|
+
request_body=op.get("requestBody") or {},
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
return endpoints
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_audit(spec: dict) -> dict:
|
|
116
|
+
"""Run every rubric check and aggregate into a scored result."""
|
|
117
|
+
endpoints = extract_endpoints(spec)
|
|
118
|
+
security_schemes = (spec.get("components") or {}).get("securitySchemes") or {}
|
|
119
|
+
|
|
120
|
+
findings: list[Finding] = []
|
|
121
|
+
for ep in endpoints:
|
|
122
|
+
findings += check_description_clarity(ep)
|
|
123
|
+
findings += check_side_effects(ep)
|
|
124
|
+
findings += check_error_responses(ep)
|
|
125
|
+
findings += check_auth_clarity(ep, security_schemes)
|
|
126
|
+
findings += check_parameter_explanation(ep)
|
|
127
|
+
findings += check_usage_guidelines(ep)
|
|
128
|
+
findings += check_schema_strictness(ep)
|
|
129
|
+
findings += check_ambiguity(endpoints)
|
|
130
|
+
findings += check_tool_surface(endpoints)
|
|
131
|
+
|
|
132
|
+
# Score per category. Spec-level findings (e.g. "this catalogue has 587
|
|
133
|
+
# tools") describe the whole API, so averaging them in alongside hundreds of
|
|
134
|
+
# endpoint-level findings would drown them out entirely. Where both exist,
|
|
135
|
+
# the category score is a 50/50 blend of the two scopes.
|
|
136
|
+
endpoint_vals = defaultdict(list)
|
|
137
|
+
spec_vals = defaultdict(list)
|
|
138
|
+
for f in findings:
|
|
139
|
+
bucket = spec_vals if f.scope == "spec" else endpoint_vals
|
|
140
|
+
bucket[f.category].append(SEVERITY_VALUE[f.severity])
|
|
141
|
+
|
|
142
|
+
category_summary = {}
|
|
143
|
+
for cat, weight in CATEGORY_WEIGHTS.items():
|
|
144
|
+
ep_scores = endpoint_vals.get(cat, [])
|
|
145
|
+
sp_scores = spec_vals.get(cat, [])
|
|
146
|
+
ep_avg = sum(ep_scores) / len(ep_scores) if ep_scores else None
|
|
147
|
+
sp_avg = sum(sp_scores) / len(sp_scores) if sp_scores else None
|
|
148
|
+
|
|
149
|
+
if ep_avg is not None and sp_avg is not None:
|
|
150
|
+
avg = 0.5 * ep_avg + 0.5 * sp_avg
|
|
151
|
+
elif ep_avg is not None:
|
|
152
|
+
avg = ep_avg
|
|
153
|
+
elif sp_avg is not None:
|
|
154
|
+
avg = sp_avg
|
|
155
|
+
else:
|
|
156
|
+
avg = 0.0
|
|
157
|
+
|
|
158
|
+
category_summary[cat] = {"score": round(avg * 100, 1), "weight": weight}
|
|
159
|
+
|
|
160
|
+
overall = round(
|
|
161
|
+
sum(category_summary[c]["score"] * w for c, w in CATEGORY_WEIGHTS.items()), 1
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
"endpoints": endpoints,
|
|
166
|
+
"endpoint_count": len(endpoints),
|
|
167
|
+
"overall_score": overall,
|
|
168
|
+
"category_summary": category_summary,
|
|
169
|
+
"gaps": [f for f in findings if f.severity in ("fail", "warning")],
|
|
170
|
+
"passes": [f for f in findings if f.severity == "pass"],
|
|
171
|
+
"fail_count": sum(1 for f in findings if f.severity == "fail"),
|
|
172
|
+
"warning_count": sum(1 for f in findings if f.severity == "warning"),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def result_to_dict(spec: dict, result: dict) -> dict:
|
|
177
|
+
"""Serialise an audit result to plain JSON-safe data (for --format json)."""
|
|
178
|
+
info = spec.get("info") or {}
|
|
179
|
+
return {
|
|
180
|
+
"api": {
|
|
181
|
+
"title": info.get("title", "Untitled API"),
|
|
182
|
+
"version": str(info.get("version", "unknown")),
|
|
183
|
+
},
|
|
184
|
+
"overall_score": result["overall_score"],
|
|
185
|
+
"endpoint_count": result["endpoint_count"],
|
|
186
|
+
"fail_count": result["fail_count"],
|
|
187
|
+
"warning_count": result["warning_count"],
|
|
188
|
+
"categories": result["category_summary"],
|
|
189
|
+
"findings": [asdict(f) for f in result["gaps"]],
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def endpoints_ready_for_mcp(
|
|
194
|
+
result: dict,
|
|
195
|
+
blocking_categories=("description_clarity", "parameter_explanation"),
|
|
196
|
+
) -> list[EndpointInfo]:
|
|
197
|
+
"""
|
|
198
|
+
Endpoints with no FAIL findings in the blocking categories — i.e. safe to
|
|
199
|
+
wrap as MCP tools. Description clarity and parameter explanation are the
|
|
200
|
+
blockers because they most directly cause wrong tool selection and
|
|
201
|
+
malformed arguments.
|
|
202
|
+
"""
|
|
203
|
+
blocked = {
|
|
204
|
+
f.endpoint
|
|
205
|
+
for f in result["gaps"]
|
|
206
|
+
if f.severity == "fail" and f.category in blocking_categories
|
|
207
|
+
}
|
|
208
|
+
return [ep for ep in result["endpoints"] if ep.id not in blocked]
|
agent_ready/cli.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for agent-ready.
|
|
3
|
+
|
|
4
|
+
Exit codes (designed for CI use):
|
|
5
|
+
0 audit passed all configured gates
|
|
6
|
+
1 audit ran, but a gate failed (score below --min-score, or
|
|
7
|
+
--max-fails exceeded)
|
|
8
|
+
2 the spec could not be loaded or parsed
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .auditor import (
|
|
17
|
+
SpecLoadError,
|
|
18
|
+
endpoints_ready_for_mcp,
|
|
19
|
+
load_spec,
|
|
20
|
+
result_to_dict,
|
|
21
|
+
run_audit,
|
|
22
|
+
)
|
|
23
|
+
from .mcp_generator import generate_mcp_scaffold
|
|
24
|
+
from .report_generator import generate_report
|
|
25
|
+
|
|
26
|
+
EXIT_OK = 0
|
|
27
|
+
EXIT_GATE_FAILED = 1
|
|
28
|
+
EXIT_SPEC_ERROR = 2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="agent-ready",
|
|
34
|
+
description=(
|
|
35
|
+
"Audit an OpenAPI spec for AI-agent readiness and optionally "
|
|
36
|
+
"generate an MCP server scaffold."
|
|
37
|
+
),
|
|
38
|
+
epilog=(
|
|
39
|
+
"CI example:\n"
|
|
40
|
+
" agent-ready openapi.yaml --min-score 70 --max-fails 0 --quiet\n"
|
|
41
|
+
),
|
|
42
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument("spec", help="Path or http(s) URL to an OpenAPI spec (.json/.yaml)")
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--format",
|
|
47
|
+
choices=["markdown", "json", "text"],
|
|
48
|
+
default="text",
|
|
49
|
+
help="Output format (default: text summary to stdout)",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument("--out", help="Write the report to this file instead of stdout")
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--min-score",
|
|
54
|
+
type=float,
|
|
55
|
+
help="Exit 1 if the overall score is below this value (CI gate)",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--max-fails",
|
|
59
|
+
type=int,
|
|
60
|
+
help="Exit 1 if the number of FAIL findings exceeds this value (CI gate)",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--generate-mcp",
|
|
64
|
+
metavar="PATH",
|
|
65
|
+
nargs="?",
|
|
66
|
+
const="mcp_server_scaffold.py",
|
|
67
|
+
help="Generate an MCP server scaffold for the endpoints that pass",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument("--quiet", action="store_true", help="Suppress non-essential output")
|
|
70
|
+
parser.add_argument("--version", action="version", version="%(prog)s 0.1.0")
|
|
71
|
+
return parser
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _text_summary(spec: dict, result: dict) -> str:
|
|
75
|
+
info = spec.get("info") or {}
|
|
76
|
+
lines = [
|
|
77
|
+
f"{info.get('title', 'Untitled API')} (v{info.get('version', 'unknown')})",
|
|
78
|
+
f"AI-readiness score: {result['overall_score']}/100",
|
|
79
|
+
(
|
|
80
|
+
f"Endpoints: {result['endpoint_count']} "
|
|
81
|
+
f"Fails: {result['fail_count']} Warnings: {result['warning_count']}"
|
|
82
|
+
),
|
|
83
|
+
"",
|
|
84
|
+
"Category scores:",
|
|
85
|
+
]
|
|
86
|
+
for cat, summary in result["category_summary"].items():
|
|
87
|
+
label = cat.replace("_", " ").title()
|
|
88
|
+
lines.append(f" {label:<28} {summary['score']:>5}/100")
|
|
89
|
+
return "\n".join(lines)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main(argv=None) -> int:
|
|
93
|
+
try:
|
|
94
|
+
return _run(argv)
|
|
95
|
+
except BrokenPipeError:
|
|
96
|
+
# Downstream command closed the pipe (e.g. `agent-ready spec.yaml | head`).
|
|
97
|
+
# Suppress Python's noisy teardown warning and exit quietly.
|
|
98
|
+
try:
|
|
99
|
+
sys.stdout.close()
|
|
100
|
+
except BrokenPipeError:
|
|
101
|
+
pass
|
|
102
|
+
return EXIT_OK
|
|
103
|
+
except KeyboardInterrupt:
|
|
104
|
+
print("\ninterrupted", file=sys.stderr)
|
|
105
|
+
return 130
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _run(argv=None) -> int:
|
|
109
|
+
args = build_parser().parse_args(argv)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
spec = load_spec(args.spec)
|
|
113
|
+
except SpecLoadError as exc:
|
|
114
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
115
|
+
return EXIT_SPEC_ERROR
|
|
116
|
+
|
|
117
|
+
if not isinstance(spec, dict) or not spec.get("paths"):
|
|
118
|
+
print(
|
|
119
|
+
"error: no 'paths' found — this does not look like an OpenAPI spec.",
|
|
120
|
+
file=sys.stderr,
|
|
121
|
+
)
|
|
122
|
+
return EXIT_SPEC_ERROR
|
|
123
|
+
|
|
124
|
+
result = run_audit(spec)
|
|
125
|
+
|
|
126
|
+
if result["endpoint_count"] == 0:
|
|
127
|
+
print("error: spec contains no operations to audit.", file=sys.stderr)
|
|
128
|
+
return EXIT_SPEC_ERROR
|
|
129
|
+
|
|
130
|
+
if args.format == "json":
|
|
131
|
+
output = json.dumps(result_to_dict(spec, result), indent=2)
|
|
132
|
+
elif args.format == "markdown":
|
|
133
|
+
output = generate_report(spec, result)
|
|
134
|
+
else:
|
|
135
|
+
output = _text_summary(spec, result)
|
|
136
|
+
|
|
137
|
+
if args.out:
|
|
138
|
+
Path(args.out).write_text(output, encoding="utf-8")
|
|
139
|
+
if not args.quiet:
|
|
140
|
+
print(f"Report written to {args.out}")
|
|
141
|
+
print(f"AI-readiness score: {result['overall_score']}/100")
|
|
142
|
+
else:
|
|
143
|
+
print(output)
|
|
144
|
+
|
|
145
|
+
if args.generate_mcp:
|
|
146
|
+
ready = endpoints_ready_for_mcp(result)
|
|
147
|
+
generate_mcp_scaffold(spec, ready, args.generate_mcp)
|
|
148
|
+
if not args.quiet:
|
|
149
|
+
print(
|
|
150
|
+
f"MCP scaffold written to {args.generate_mcp} "
|
|
151
|
+
f"({len(ready)}/{result['endpoint_count']} endpoints included)"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# CI gates
|
|
155
|
+
gate_failed = False
|
|
156
|
+
if args.min_score is not None and result["overall_score"] < args.min_score:
|
|
157
|
+
print(
|
|
158
|
+
f"GATE FAILED: score {result['overall_score']} is below "
|
|
159
|
+
f"minimum {args.min_score}",
|
|
160
|
+
file=sys.stderr,
|
|
161
|
+
)
|
|
162
|
+
gate_failed = True
|
|
163
|
+
if args.max_fails is not None and result["fail_count"] > args.max_fails:
|
|
164
|
+
print(
|
|
165
|
+
f"GATE FAILED: {result['fail_count']} FAIL finding(s) exceeds "
|
|
166
|
+
f"maximum {args.max_fails}",
|
|
167
|
+
file=sys.stderr,
|
|
168
|
+
)
|
|
169
|
+
gate_failed = True
|
|
170
|
+
|
|
171
|
+
return EXIT_GATE_FAILED if gate_failed else EXIT_OK
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
sys.exit(main())
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generates a runnable FastMCP server scaffold from the endpoints that
|
|
3
|
+
passed the AI-readiness audit. Each OpenAPI operation becomes an MCP
|
|
4
|
+
tool with a model-facing description (distinct from the raw API docs).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _to_snake_case(path: str, method: str) -> str:
|
|
11
|
+
name = re.sub(r"[{}]", "", path)
|
|
12
|
+
name = re.sub(r"[^a-zA-Z0-9]+", "_", name).strip("_").lower()
|
|
13
|
+
return f"{method.lower()}_{name}"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _to_python_identifier(name: str) -> str:
|
|
17
|
+
"""Sanitize an OpenAPI parameter name (e.g. 'Idempotency-Key') into a valid Python identifier."""
|
|
18
|
+
ident = re.sub(r"[^a-zA-Z0-9_]", "_", name).lower()
|
|
19
|
+
if ident and ident[0].isdigit():
|
|
20
|
+
ident = f"_{ident}"
|
|
21
|
+
return ident or "param"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _model_facing_description(ep) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Rewrites the raw OpenAPI description into something aimed at a model's
|
|
27
|
+
tool-selection decision: what it's for, and when to use it vs. not.
|
|
28
|
+
"""
|
|
29
|
+
base = ep.description.strip() or ep.summary.strip() or f"{ep.method.upper()} {ep.path}"
|
|
30
|
+
write_methods = {"post", "put", "patch", "delete"}
|
|
31
|
+
if ep.method.lower() in write_methods:
|
|
32
|
+
note = " NOTE: this endpoint has side effects — it changes data, it is not read-only."
|
|
33
|
+
else:
|
|
34
|
+
note = " This is a read-only lookup — safe to call speculatively."
|
|
35
|
+
return base + note
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def generate_mcp_scaffold(spec: dict, endpoints: list, out_path: str):
|
|
39
|
+
base_url = ""
|
|
40
|
+
servers = spec.get("servers", [])
|
|
41
|
+
if servers:
|
|
42
|
+
base_url = servers[0].get("url", "")
|
|
43
|
+
|
|
44
|
+
lines = []
|
|
45
|
+
lines.append('"""')
|
|
46
|
+
lines.append("Auto-generated MCP server scaffold.")
|
|
47
|
+
lines.append("Generated by api-readiness-auditor from an OpenAPI spec.")
|
|
48
|
+
lines.append("Review every tool description before shipping — these are a first draft,")
|
|
49
|
+
lines.append("written to be model-facing rather than copied straight from API docs.")
|
|
50
|
+
lines.append('"""')
|
|
51
|
+
lines.append("")
|
|
52
|
+
lines.append("import httpx")
|
|
53
|
+
lines.append("from mcp.server.fastmcp import FastMCP")
|
|
54
|
+
lines.append("")
|
|
55
|
+
lines.append(f'BASE_URL = "{base_url}"')
|
|
56
|
+
lines.append("")
|
|
57
|
+
lines.append('mcp = FastMCP("generated-api-server")')
|
|
58
|
+
lines.append("")
|
|
59
|
+
|
|
60
|
+
for ep in endpoints:
|
|
61
|
+
func_name = _to_snake_case(ep.path, ep.method)
|
|
62
|
+
description = _model_facing_description(ep)
|
|
63
|
+
params = [p for p in ep.parameters if isinstance(p, dict) and p.get("name")]
|
|
64
|
+
|
|
65
|
+
path_params, query_params, header_params = [], [], []
|
|
66
|
+
for p in params:
|
|
67
|
+
location = p.get("in", "query")
|
|
68
|
+
entry = (p["name"], _to_python_identifier(p["name"]))
|
|
69
|
+
if location == "path":
|
|
70
|
+
path_params.append(entry)
|
|
71
|
+
elif location == "header":
|
|
72
|
+
header_params.append(entry)
|
|
73
|
+
else:
|
|
74
|
+
query_params.append(entry)
|
|
75
|
+
|
|
76
|
+
all_params = path_params + query_params + header_params
|
|
77
|
+
sig_params = ", ".join(f"{ident}: str = None" for _, ident in all_params)
|
|
78
|
+
|
|
79
|
+
url_path = ep.path
|
|
80
|
+
for original, ident in path_params:
|
|
81
|
+
url_path = url_path.replace("{" + original + "}", "{" + ident + "}")
|
|
82
|
+
|
|
83
|
+
lines.append("@mcp.tool()")
|
|
84
|
+
lines.append(f"def {func_name}({sig_params}):")
|
|
85
|
+
lines.append(f' """{description}"""')
|
|
86
|
+
if path_params:
|
|
87
|
+
lines.append(f' url = BASE_URL + f"{url_path}"')
|
|
88
|
+
else:
|
|
89
|
+
lines.append(f' url = BASE_URL + "{url_path}"')
|
|
90
|
+
|
|
91
|
+
if query_params:
|
|
92
|
+
q_entries = ", ".join(f'"{orig}": {ident}' for orig, ident in query_params)
|
|
93
|
+
lines.append(f" params = {{{q_entries}}}")
|
|
94
|
+
lines.append(" params = {k: v for k, v in params.items() if v is not None}")
|
|
95
|
+
else:
|
|
96
|
+
lines.append(" params = {}")
|
|
97
|
+
|
|
98
|
+
if header_params:
|
|
99
|
+
h_entries = ", ".join(f'"{orig}": {ident}' for orig, ident in header_params)
|
|
100
|
+
lines.append(f" headers = {{{h_entries}}}")
|
|
101
|
+
lines.append(" headers = {k: v for k, v in headers.items() if v is not None}")
|
|
102
|
+
else:
|
|
103
|
+
lines.append(" headers = {}")
|
|
104
|
+
|
|
105
|
+
method = ep.method.lower()
|
|
106
|
+
if method == "get":
|
|
107
|
+
lines.append(" resp = httpx.get(url, params=params, headers=headers)")
|
|
108
|
+
else:
|
|
109
|
+
lines.append(f" resp = httpx.{method}(url, params=params, headers=headers, json={{}})")
|
|
110
|
+
lines.append(" resp.raise_for_status()")
|
|
111
|
+
lines.append(" return resp.json()")
|
|
112
|
+
lines.append("")
|
|
113
|
+
|
|
114
|
+
lines.append('if __name__ == "__main__":')
|
|
115
|
+
lines.append(" mcp.run()")
|
|
116
|
+
lines.append("")
|
|
117
|
+
|
|
118
|
+
with open(out_path, "w") as f:
|
|
119
|
+
f.write("\n".join(lines))
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Generates a markdown AI-readiness report from an audit result."""
|
|
2
|
+
|
|
3
|
+
CATEGORY_LABELS = {
|
|
4
|
+
"description_clarity": "1. Capability Description Clarity",
|
|
5
|
+
"side_effects": "2. Side-Effect Signaling",
|
|
6
|
+
"error_responses": "3. Actionable Error Responses",
|
|
7
|
+
"auth_clarity": "4. Auth/Scope Clarity for Agents",
|
|
8
|
+
"ambiguity": "5. Endpoint Ambiguity",
|
|
9
|
+
"parameter_explanation": "6. Parameter Explanation",
|
|
10
|
+
"usage_guidelines": "7. Usage Guidelines (When to Call)",
|
|
11
|
+
"tool_surface": "8. Tool Surface & Schema Strictness",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
SEVERITY_ICON = {"fail": "🔴", "warning": "🟡", "pass": "🟢"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def generate_report(spec: dict, result: dict) -> str:
|
|
18
|
+
title = spec.get("info", {}).get("title", "Untitled API")
|
|
19
|
+
version = spec.get("info", {}).get("version", "unknown")
|
|
20
|
+
|
|
21
|
+
lines = []
|
|
22
|
+
lines.append(f"# AI-Readiness Report: {title} (v{version})")
|
|
23
|
+
lines.append("")
|
|
24
|
+
lines.append(f"**Overall score: {result['overall_score']}/100** ")
|
|
25
|
+
lines.append(f"**Endpoints analyzed:** {result['endpoint_count']}")
|
|
26
|
+
lines.append("")
|
|
27
|
+
lines.append("## Category Scores")
|
|
28
|
+
lines.append("")
|
|
29
|
+
lines.append("| Category | Score | Weight |")
|
|
30
|
+
lines.append("|---|---|---|")
|
|
31
|
+
for cat, label in CATEGORY_LABELS.items():
|
|
32
|
+
summary = result["category_summary"].get(cat, {"score": 0, "weight": 0})
|
|
33
|
+
lines.append(f"| {label} | {summary['score']}/100 | {int(summary['weight']*100)}% |")
|
|
34
|
+
lines.append("")
|
|
35
|
+
|
|
36
|
+
lines.append("## Gaps Found")
|
|
37
|
+
lines.append("")
|
|
38
|
+
if not result["gaps"]:
|
|
39
|
+
lines.append("No gaps found — every endpoint passed every category.")
|
|
40
|
+
else:
|
|
41
|
+
by_category = {}
|
|
42
|
+
for f in result["gaps"]:
|
|
43
|
+
by_category.setdefault(f.category, []).append(f)
|
|
44
|
+
for cat, label in CATEGORY_LABELS.items():
|
|
45
|
+
findings = by_category.get(cat, [])
|
|
46
|
+
if not findings:
|
|
47
|
+
continue
|
|
48
|
+
lines.append(f"### {label}")
|
|
49
|
+
lines.append("")
|
|
50
|
+
for f in findings:
|
|
51
|
+
icon = SEVERITY_ICON[f.severity]
|
|
52
|
+
lines.append(f"- {icon} **{f.endpoint}** — {f.message}")
|
|
53
|
+
lines.append("")
|
|
54
|
+
|
|
55
|
+
lines.append("## What Passed")
|
|
56
|
+
lines.append("")
|
|
57
|
+
pass_counts = {}
|
|
58
|
+
for f in result["passes"]:
|
|
59
|
+
pass_counts[f.category] = pass_counts.get(f.category, 0) + 1
|
|
60
|
+
for cat, label in CATEGORY_LABELS.items():
|
|
61
|
+
count = pass_counts.get(cat, 0)
|
|
62
|
+
lines.append(f"- {label}: {count} endpoint(s) passed cleanly")
|
|
63
|
+
lines.append("")
|
|
64
|
+
|
|
65
|
+
lines.append("## Recommended Next Step")
|
|
66
|
+
lines.append("")
|
|
67
|
+
lines.append(
|
|
68
|
+
"Don't try to fix every gap at once. Prioritize the endpoints with 🔴 fails in "
|
|
69
|
+
"**Capability Description Clarity** and **Parameter Explanation** first — these "
|
|
70
|
+
"most directly cause wrong tool selection and malformed arguments, which matters "
|
|
71
|
+
"more early on than auth polish or ambiguity cleanup."
|
|
72
|
+
)
|
|
73
|
+
lines.append("")
|
|
74
|
+
lines.append("### A caveat on maximising this score")
|
|
75
|
+
lines.append("")
|
|
76
|
+
lines.append(
|
|
77
|
+
"This score is a diagnostic, not a target. Hasan et al. (arXiv:2602.14878) found "
|
|
78
|
+
"that augmenting *all* description components raised task success by a median 5.85 "
|
|
79
|
+
"percentage points — but increased execution steps by 67.46% and regressed "
|
|
80
|
+
"performance in 16.67% of cases, because richer descriptions consume context window "
|
|
81
|
+
"and raise cost. Their ablations found shorter, targeted descriptions often performed "
|
|
82
|
+
"equivalently. Fix the fails; don't gold-plate the passes."
|
|
83
|
+
)
|
|
84
|
+
lines.append("")
|
|
85
|
+
lines.append("---")
|
|
86
|
+
lines.append("")
|
|
87
|
+
lines.append("*Rubric categories 1–5 derived independently from MCP tool-selection mechanics "
|
|
88
|
+
"and API design principles; categories 6–7 added after validation against "
|
|
89
|
+
"arXiv:2602.14878 (856 tools / 103 servers) and arXiv:2602.18914 (10,831 servers). "
|
|
90
|
+
"Category weights are judgement calls, not measured effect sizes.*")
|
|
91
|
+
lines.append("")
|
|
92
|
+
|
|
93
|
+
return "\n".join(lines)
|