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.
@@ -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)