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/rubric.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AI-Readiness Rubric
|
|
3
|
+
-------------------
|
|
4
|
+
Seven categories used to score how ready an API (from an OpenAPI spec) is
|
|
5
|
+
for reliable use by AI agents / MCP servers. Each endpoint gets checked
|
|
6
|
+
against every category; failures become concrete, cite-able gaps.
|
|
7
|
+
|
|
8
|
+
Categories:
|
|
9
|
+
1. Capability description clarity -- maps to "Purpose" in Hasan et al. (2026)
|
|
10
|
+
2. Side-effect signaling -- maps to MCP tool annotations (destructive/open-world)
|
|
11
|
+
3. Actionable error responses -- adjacent to "Limitations" in Hasan et al.
|
|
12
|
+
4. Auth/scope clarity for agents -- platform-level; not covered by description-quality rubrics
|
|
13
|
+
5. Ambiguity between endpoints -- Wang et al. (2026) found 73% repeated tool names
|
|
14
|
+
6. Parameter explanation -- "Opaque Parameters" smell: 84.3% prevalence
|
|
15
|
+
7. Usage guidelines (when to call) -- "Missing Usage Guidelines" smell: 89.3% prevalence
|
|
16
|
+
|
|
17
|
+
Provenance note (be honest about this in interviews):
|
|
18
|
+
Categories 1-5 were derived independently from how MCP tool selection works
|
|
19
|
+
plus standard API design principles. Categories 6-7 were added after checking
|
|
20
|
+
the rubric against published empirical work:
|
|
21
|
+
|
|
22
|
+
- Hasan, Li, Rajbahadur, Adams & Hassan (Queen's University), "Model Context
|
|
23
|
+
Protocol (MCP) Tool Descriptions Are Smelly!" arXiv:2602.14878.
|
|
24
|
+
856 tools / 103 servers. Six components: Purpose, Guidelines, Limitations,
|
|
25
|
+
Parameter Explanation, Length & Completeness, Examples.
|
|
26
|
+
97.1% of descriptions had >=1 smell; 56% had an Unclear Purpose smell.
|
|
27
|
+
|
|
28
|
+
- Wang et al., "From Docs to Descriptions: Smell-Aware Evaluation of MCP
|
|
29
|
+
Server Descriptions," arXiv:2602.18914. 10,831 servers, 18 smell
|
|
30
|
+
categories across accuracy / functionality / completeness / conciseness.
|
|
31
|
+
|
|
32
|
+
IMPORTANT TRADE-OFF (cite this, it's the senior-level point):
|
|
33
|
+
Hasan et al. found that augmenting ALL description components improved task
|
|
34
|
+
success by a median 5.85pp but increased execution steps by 67.46% and
|
|
35
|
+
REGRESSED performance in 16.67% of cases. More completeness is not strictly
|
|
36
|
+
better -- richer descriptions consume context window and raise cost. Their
|
|
37
|
+
ablations found shorter, targeted descriptions often performed equivalently.
|
|
38
|
+
Treat this rubric's score as a diagnostic, not a target to maximise.
|
|
39
|
+
|
|
40
|
+
CAVEAT: the weights in CATEGORY_WEIGHTS are judgement calls, not empirical.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import difflib
|
|
44
|
+
import re
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Finding:
|
|
50
|
+
category: str
|
|
51
|
+
severity: str # "fail", "warning", "pass"
|
|
52
|
+
endpoint: str
|
|
53
|
+
message: str
|
|
54
|
+
scope: str = "endpoint" # "endpoint" or "spec"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class EndpointInfo:
|
|
59
|
+
path: str
|
|
60
|
+
method: str
|
|
61
|
+
summary: str
|
|
62
|
+
description: str
|
|
63
|
+
parameters: list
|
|
64
|
+
responses: dict
|
|
65
|
+
request_body: dict = field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def full_text(self):
|
|
69
|
+
return f"{self.summary} {self.description}".strip()
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def id(self):
|
|
73
|
+
return f"{self.method.upper()} {self.path}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check_description_clarity(ep: EndpointInfo) -> list[Finding]:
|
|
77
|
+
"""
|
|
78
|
+
Maps to the 'Purpose' component. Hasan et al. found 56% of real MCP tools
|
|
79
|
+
fail to state their purpose clearly — the single most consequential gap.
|
|
80
|
+
|
|
81
|
+
Length threshold follows the published guidance that a tool description
|
|
82
|
+
should run to roughly 3-4 sentences (~150 chars) rather than a bare label.
|
|
83
|
+
"""
|
|
84
|
+
findings = []
|
|
85
|
+
text = ep.full_text
|
|
86
|
+
sentence_count = len([s for s in re.split(r"[.!?]+", text) if s.strip()])
|
|
87
|
+
|
|
88
|
+
if not text:
|
|
89
|
+
findings.append(Finding(
|
|
90
|
+
"description_clarity", "fail", ep.id,
|
|
91
|
+
"No summary or description at all — a model has nothing to decide whether to call this."
|
|
92
|
+
))
|
|
93
|
+
elif len(text) < 40 or sentence_count < 1:
|
|
94
|
+
findings.append(Finding(
|
|
95
|
+
"description_clarity", "fail", ep.id,
|
|
96
|
+
f"Description is a bare label ({len(text)} chars) — this is the 'Unclear Purpose' smell, "
|
|
97
|
+
"found in 56% of real MCP tools and the strongest predictor of wrong tool selection."
|
|
98
|
+
))
|
|
99
|
+
elif len(text) < 150 or sentence_count < 3:
|
|
100
|
+
findings.append(Finding(
|
|
101
|
+
"description_clarity", "warning", ep.id,
|
|
102
|
+
f"Description is thin ({len(text)} chars, ~{sentence_count} sentence(s)) — published guidance "
|
|
103
|
+
"suggests 3-4 sentences covering function, behaviour, and what is returned."
|
|
104
|
+
))
|
|
105
|
+
else:
|
|
106
|
+
findings.append(Finding("description_clarity", "pass", ep.id, "Has a substantive description."))
|
|
107
|
+
return findings
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def check_side_effects(ep: EndpointInfo) -> list[Finding]:
|
|
111
|
+
findings = []
|
|
112
|
+
write_methods = {"post", "put", "patch", "delete"}
|
|
113
|
+
is_write = ep.method.lower() in write_methods
|
|
114
|
+
text = ep.full_text.lower()
|
|
115
|
+
mentions_effect = any(k in text for k in [
|
|
116
|
+
"creates", "deletes", "updates", "modifies", "cancels", "read-only",
|
|
117
|
+
"does not modify", "no side effect", "idempotent"
|
|
118
|
+
])
|
|
119
|
+
if is_write and not mentions_effect:
|
|
120
|
+
findings.append(Finding(
|
|
121
|
+
"side_effects", "fail", ep.id,
|
|
122
|
+
f"{ep.method.upper()} endpoint doesn't state its side effect in the description — "
|
|
123
|
+
"an agent can't tell if this is safe to call speculatively."
|
|
124
|
+
))
|
|
125
|
+
elif is_write:
|
|
126
|
+
findings.append(Finding("side_effects", "pass", ep.id, "Write endpoint documents its effect."))
|
|
127
|
+
else:
|
|
128
|
+
findings.append(Finding("side_effects", "pass", ep.id, "Read-only endpoint (low risk by method)."))
|
|
129
|
+
|
|
130
|
+
# Idempotency signal for write endpoints
|
|
131
|
+
if is_write:
|
|
132
|
+
has_idempotency_key = any(
|
|
133
|
+
"idempot" in str(p).lower() for p in ep.parameters
|
|
134
|
+
) or "idempot" in text
|
|
135
|
+
if not has_idempotency_key:
|
|
136
|
+
findings.append(Finding(
|
|
137
|
+
"side_effects", "warning", ep.id,
|
|
138
|
+
"No idempotency key or note found — a retrying agent could create duplicate side effects."
|
|
139
|
+
))
|
|
140
|
+
return findings
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def check_error_responses(ep: EndpointInfo) -> list[Finding]:
|
|
144
|
+
"""
|
|
145
|
+
An agent needs to know what a failure means so it can decide what to try
|
|
146
|
+
next. A bare status code dead-ends it.
|
|
147
|
+
|
|
148
|
+
NOTE: OpenAPI's `default` response is a legitimate — and common — way to
|
|
149
|
+
document errors (Stripe's entire public spec uses it). Counting only 4xx/5xx
|
|
150
|
+
codes here previously failed every endpoint in specs that use `default`.
|
|
151
|
+
"""
|
|
152
|
+
findings = []
|
|
153
|
+
error_codes = [
|
|
154
|
+
c for c in ep.responses
|
|
155
|
+
if str(c).startswith(("4", "5")) or str(c).lower() == "default"
|
|
156
|
+
]
|
|
157
|
+
if not error_codes:
|
|
158
|
+
findings.append(Finding(
|
|
159
|
+
"error_responses", "fail", ep.id,
|
|
160
|
+
"No error responses documented (no 4xx/5xx codes and no `default` response) — "
|
|
161
|
+
"an agent has no way to distinguish or recover from failures."
|
|
162
|
+
))
|
|
163
|
+
return findings
|
|
164
|
+
|
|
165
|
+
vague_count = 0
|
|
166
|
+
for code in error_codes:
|
|
167
|
+
resp = ep.responses[code]
|
|
168
|
+
desc = str(resp.get("description", "")) if isinstance(resp, dict) else ""
|
|
169
|
+
has_schema = isinstance(resp, dict) and bool(resp.get("content"))
|
|
170
|
+
if len(desc) < 15 and not has_schema:
|
|
171
|
+
vague_count += 1
|
|
172
|
+
|
|
173
|
+
if vague_count == len(error_codes):
|
|
174
|
+
findings.append(Finding(
|
|
175
|
+
"error_responses", "warning", ep.id,
|
|
176
|
+
f"{len(error_codes)} error code(s) documented but all vague (generic descriptions, no schema) — "
|
|
177
|
+
"an agent gets a status code but no actionable next step."
|
|
178
|
+
))
|
|
179
|
+
else:
|
|
180
|
+
findings.append(Finding("error_responses", "pass", ep.id, "Error responses have useful detail."))
|
|
181
|
+
return findings
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def check_auth_clarity(ep: EndpointInfo, security_schemes: dict) -> list[Finding]:
|
|
185
|
+
findings = []
|
|
186
|
+
if not security_schemes:
|
|
187
|
+
findings.append(Finding(
|
|
188
|
+
"auth_clarity", "warning", ep.id,
|
|
189
|
+
"No security schemes defined in the spec at all — agents have no way to know what scope to request."
|
|
190
|
+
))
|
|
191
|
+
return findings
|
|
192
|
+
|
|
193
|
+
oauth_schemes = {
|
|
194
|
+
k: v for k, v in security_schemes.items()
|
|
195
|
+
if isinstance(v, dict) and v.get("type") == "oauth2"
|
|
196
|
+
}
|
|
197
|
+
if not oauth_schemes:
|
|
198
|
+
findings.append(Finding(
|
|
199
|
+
"auth_clarity", "warning", ep.id,
|
|
200
|
+
"No OAuth2 scheme found — scoped, delegated-authority tokens for agents aren't possible as specified."
|
|
201
|
+
))
|
|
202
|
+
else:
|
|
203
|
+
has_scopes = any(
|
|
204
|
+
v.get("flows", {}).get("clientCredentials", {}).get("scopes")
|
|
205
|
+
or v.get("flows", {}).get("authorizationCode", {}).get("scopes")
|
|
206
|
+
for v in oauth_schemes.values()
|
|
207
|
+
)
|
|
208
|
+
if has_scopes:
|
|
209
|
+
findings.append(Finding("auth_clarity", "pass", ep.id, "OAuth2 with defined scopes present."))
|
|
210
|
+
else:
|
|
211
|
+
findings.append(Finding(
|
|
212
|
+
"auth_clarity", "warning", ep.id,
|
|
213
|
+
"OAuth2 present but no scopes defined — can't distinguish delegated vs autonomous agent access."
|
|
214
|
+
))
|
|
215
|
+
return findings
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def check_ambiguity(endpoints: list[EndpointInfo], max_reported: int = 25) -> list[Finding]:
|
|
219
|
+
"""
|
|
220
|
+
Pairwise-compare endpoint descriptions to flag near-duplicates that could
|
|
221
|
+
confuse a model.
|
|
222
|
+
|
|
223
|
+
On large specs this is O(n^2) in comparisons and can produce thousands of
|
|
224
|
+
findings, so we score against all of them but only report the worst
|
|
225
|
+
`max_reported` pairs — an unreadable report gets ignored, which helps nobody.
|
|
226
|
+
"""
|
|
227
|
+
scored = []
|
|
228
|
+
for i in range(len(endpoints)):
|
|
229
|
+
for j in range(i + 1, len(endpoints)):
|
|
230
|
+
a, b = endpoints[i], endpoints[j]
|
|
231
|
+
if a.path == b.path:
|
|
232
|
+
continue
|
|
233
|
+
text_a, text_b = a.full_text.lower(), b.full_text.lower()
|
|
234
|
+
if not text_a or not text_b:
|
|
235
|
+
continue
|
|
236
|
+
similarity = difflib.SequenceMatcher(None, text_a, text_b).ratio()
|
|
237
|
+
if similarity > 0.7:
|
|
238
|
+
scored.append((similarity, a, b))
|
|
239
|
+
|
|
240
|
+
if not scored:
|
|
241
|
+
return [Finding("ambiguity", "pass", "all", "No significant description overlap detected.")]
|
|
242
|
+
|
|
243
|
+
scored.sort(key=lambda t: t[0], reverse=True)
|
|
244
|
+
|
|
245
|
+
findings = [
|
|
246
|
+
Finding(
|
|
247
|
+
"ambiguity", "warning", f"{a.id} vs {b.id}",
|
|
248
|
+
f"Descriptions are {sim:.0%} similar — a model may confuse these two endpoints."
|
|
249
|
+
)
|
|
250
|
+
for sim, a, b in scored[:max_reported]
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
if len(scored) > max_reported:
|
|
254
|
+
findings.append(Finding(
|
|
255
|
+
"ambiguity", "warning", "(summary)",
|
|
256
|
+
f"{len(scored)} similar-description pairs found in total; showing the "
|
|
257
|
+
f"{max_reported} most similar. Large catalogues usually need naming and "
|
|
258
|
+
"description conventions rather than pair-by-pair fixes."
|
|
259
|
+
))
|
|
260
|
+
return findings
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def check_parameter_explanation(ep: EndpointInfo) -> list[Finding]:
|
|
264
|
+
"""
|
|
265
|
+
Maps to the 'Opaque Parameters' smell (84.3% prevalence in Hasan et al.).
|
|
266
|
+
|
|
267
|
+
The paper's motivating case: a Yahoo Finance tool referred to "start" and
|
|
268
|
+
"end" without naming them as explicit parameters or specifying a date
|
|
269
|
+
format. The model couldn't construct a bounded range, so it fell back to a
|
|
270
|
+
broad `period` parameter and pulled multi-year windows for narrow questions
|
|
271
|
+
— inflating payload size, latency and token cost. As the authors put it,
|
|
272
|
+
that is a specification problem in the description, not a model bug.
|
|
273
|
+
|
|
274
|
+
So: a parameter needs a description AND, for anything format-sensitive,
|
|
275
|
+
an explicit format hint.
|
|
276
|
+
"""
|
|
277
|
+
findings = []
|
|
278
|
+
params = [p for p in ep.parameters if isinstance(p, dict) and p.get("name")]
|
|
279
|
+
|
|
280
|
+
if not params:
|
|
281
|
+
findings.append(Finding(
|
|
282
|
+
"parameter_explanation", "pass", ep.id, "No parameters to document."
|
|
283
|
+
))
|
|
284
|
+
return findings
|
|
285
|
+
|
|
286
|
+
undocumented = [p["name"] for p in params if not str(p.get("description", "")).strip()]
|
|
287
|
+
if undocumented:
|
|
288
|
+
severity = "fail" if len(undocumented) == len(params) else "warning"
|
|
289
|
+
findings.append(Finding(
|
|
290
|
+
"parameter_explanation", severity, ep.id,
|
|
291
|
+
f"{len(undocumented)}/{len(params)} parameter(s) have no description "
|
|
292
|
+
f"({', '.join(undocumented[:4])}) — the agent must guess their meaning."
|
|
293
|
+
))
|
|
294
|
+
|
|
295
|
+
# Format-sensitive params (dates, times, enums) need an explicit format hint
|
|
296
|
+
format_sensitive = []
|
|
297
|
+
for p in params:
|
|
298
|
+
name = p["name"].lower()
|
|
299
|
+
schema = p.get("schema", {}) if isinstance(p.get("schema"), dict) else {}
|
|
300
|
+
desc = str(p.get("description", "")).lower()
|
|
301
|
+
# Split on non-alphanumerics so we match whole tokens, not substrings.
|
|
302
|
+
# (Naive substring matching flags "customer_id" because "to" is inside "custo-mer".)
|
|
303
|
+
name_tokens = set(re.split(r"[^a-z0-9]+", name))
|
|
304
|
+
temporal_tokens = {"date", "time", "datetime", "timestamp", "from", "to",
|
|
305
|
+
"start", "end", "since", "until", "before", "after"}
|
|
306
|
+
looks_temporal = bool(name_tokens & temporal_tokens)
|
|
307
|
+
has_format_hint = (
|
|
308
|
+
schema.get("format")
|
|
309
|
+
or schema.get("enum")
|
|
310
|
+
or schema.get("pattern")
|
|
311
|
+
or any(hint in desc for hint in ["yyyy", "iso", "format", "utc", "epoch", "rfc"])
|
|
312
|
+
)
|
|
313
|
+
if looks_temporal and not has_format_hint:
|
|
314
|
+
format_sensitive.append(p["name"])
|
|
315
|
+
|
|
316
|
+
if format_sensitive:
|
|
317
|
+
findings.append(Finding(
|
|
318
|
+
"parameter_explanation", "warning", ep.id,
|
|
319
|
+
f"Date/time parameter(s) with no format specified ({', '.join(format_sensitive[:4])}) — "
|
|
320
|
+
"agents commonly pick the wrong format or fall back to a broader default range."
|
|
321
|
+
))
|
|
322
|
+
|
|
323
|
+
if not undocumented and not format_sensitive:
|
|
324
|
+
findings.append(Finding(
|
|
325
|
+
"parameter_explanation", "pass", ep.id,
|
|
326
|
+
"All parameters documented with adequate format detail."
|
|
327
|
+
))
|
|
328
|
+
return findings
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def check_usage_guidelines(ep: EndpointInfo) -> list[Finding]:
|
|
332
|
+
"""
|
|
333
|
+
Maps to the 'Missing Usage Guidelines' smell (89.3% prevalence).
|
|
334
|
+
|
|
335
|
+
A description says what a tool does. Guidelines say WHEN to reach for it —
|
|
336
|
+
activation criteria — and when to reach for something else instead. This is
|
|
337
|
+
what stops an agent picking a plausible-but-wrong endpoint.
|
|
338
|
+
"""
|
|
339
|
+
findings = []
|
|
340
|
+
text = ep.full_text.lower()
|
|
341
|
+
|
|
342
|
+
if not text:
|
|
343
|
+
findings.append(Finding(
|
|
344
|
+
"usage_guidelines", "fail", ep.id,
|
|
345
|
+
"No description, so no usage guidance either — nothing tells the agent when to call this."
|
|
346
|
+
))
|
|
347
|
+
return findings
|
|
348
|
+
|
|
349
|
+
# Regex rather than literal substrings: real descriptions insert words into
|
|
350
|
+
# these phrases ("use this ONLY when...", "call this ONLY IF..."), which a
|
|
351
|
+
# fixed-string list silently misses.
|
|
352
|
+
activation_patterns = [
|
|
353
|
+
r"\buse (this|it|these)?\s*\w{0,8}\s*(when|if|for|to|after|before|once)\b",
|
|
354
|
+
r"\bcall (this|it)?\s*\w{0,8}\s*(when|if|after|before|once)\b",
|
|
355
|
+
r"\b(when|if) you need\b",
|
|
356
|
+
r"\bfor (cases|situations|scenarios) where\b",
|
|
357
|
+
r"\bintended for\b",
|
|
358
|
+
r"\b(do not|don't|never) (use|call)\b",
|
|
359
|
+
r"\b(instead|rather than) (use|call|using|calling)\b",
|
|
360
|
+
r"\b(prefer|preferred over)\b",
|
|
361
|
+
r"\bshould (only )?be (used|called)\b",
|
|
362
|
+
]
|
|
363
|
+
has_guidance = any(re.search(p, text) for p in activation_patterns)
|
|
364
|
+
|
|
365
|
+
if has_guidance:
|
|
366
|
+
findings.append(Finding(
|
|
367
|
+
"usage_guidelines", "pass", ep.id,
|
|
368
|
+
"Description includes activation criteria (when to use / when not to)."
|
|
369
|
+
))
|
|
370
|
+
else:
|
|
371
|
+
findings.append(Finding(
|
|
372
|
+
"usage_guidelines", "warning", ep.id,
|
|
373
|
+
"No activation criteria — description states what the endpoint does but not when an agent "
|
|
374
|
+
"should choose it over a similar one. This smell appears in 89.3% of real MCP tools."
|
|
375
|
+
))
|
|
376
|
+
return findings
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# --------------------------------------------------------------------------
|
|
380
|
+
# Category 8: Tool surface & schema strictness
|
|
381
|
+
# Derived from OpenAI's function-calling guidance, which is explicit that
|
|
382
|
+
# function definitions count against the model's context limit and are billed
|
|
383
|
+
# as input tokens, and recommends keeping the initially-available tool count
|
|
384
|
+
# small (they suggest under 20) for higher accuracy.
|
|
385
|
+
#
|
|
386
|
+
# This converges with two independent sources:
|
|
387
|
+
# - MCP client best practices (progressive discovery; loading every tool
|
|
388
|
+
# definition upfront wastes tokens and degrades model performance)
|
|
389
|
+
# - Hasan et al., who measured a 67.46% increase in execution steps when all
|
|
390
|
+
# description components were enriched
|
|
391
|
+
# Three sources, one conclusion: fewer, tighter tools beat more, richer ones.
|
|
392
|
+
# --------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
# OpenAI describes this as a soft suggestion rather than a hard limit.
|
|
395
|
+
RECOMMENDED_MAX_TOOLS = 20
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def check_tool_surface(endpoints: list[EndpointInfo]) -> list[Finding]:
|
|
399
|
+
"""Spec-level check: is the catalogue small enough for reliable selection?"""
|
|
400
|
+
n = len(endpoints)
|
|
401
|
+
if n == 0:
|
|
402
|
+
return []
|
|
403
|
+
if n <= RECOMMENDED_MAX_TOOLS:
|
|
404
|
+
return [Finding(
|
|
405
|
+
"tool_surface", "pass", "(spec)",
|
|
406
|
+
f"{n} operation(s) — within the recommended ceiling of "
|
|
407
|
+
f"~{RECOMMENDED_MAX_TOOLS} tools available at the start of a turn.",
|
|
408
|
+
scope="spec",
|
|
409
|
+
)]
|
|
410
|
+
|
|
411
|
+
severity = "fail" if n > RECOMMENDED_MAX_TOOLS * 3 else "warning"
|
|
412
|
+
return [Finding(
|
|
413
|
+
"tool_surface", severity, "(spec)",
|
|
414
|
+
f"{n} operations — well above the ~{RECOMMENDED_MAX_TOOLS} tools that "
|
|
415
|
+
"should be available to a model at the start of a turn. Exposing all of "
|
|
416
|
+
"them as tools inflates context cost and degrades selection accuracy. "
|
|
417
|
+
"Expose a curated subset, group by namespace, or use progressive "
|
|
418
|
+
"discovery / tool search to load the rest on demand.",
|
|
419
|
+
scope="spec",
|
|
420
|
+
)]
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def check_schema_strictness(ep: EndpointInfo) -> list[Finding]:
|
|
424
|
+
"""
|
|
425
|
+
Endpoint-level checks derived from OpenAI's schema guidance:
|
|
426
|
+
- strict mode requires `additionalProperties: false` and every property
|
|
427
|
+
listed in `required`
|
|
428
|
+
- enums and object structure should make invalid states unrepresentable
|
|
429
|
+
"""
|
|
430
|
+
findings = []
|
|
431
|
+
body_schema = _request_body_schema(ep)
|
|
432
|
+
|
|
433
|
+
if body_schema:
|
|
434
|
+
props = body_schema.get("properties") or {}
|
|
435
|
+
required = set(body_schema.get("required") or [])
|
|
436
|
+
additional = body_schema.get("additionalProperties")
|
|
437
|
+
|
|
438
|
+
if props:
|
|
439
|
+
if additional is not False:
|
|
440
|
+
findings.append(Finding(
|
|
441
|
+
"tool_surface", "warning", ep.id,
|
|
442
|
+
"Request body schema doesn't set `additionalProperties: false` — "
|
|
443
|
+
"incompatible with strict mode, so argument generation is "
|
|
444
|
+
"best-effort rather than schema-guaranteed."
|
|
445
|
+
))
|
|
446
|
+
missing_required = [p for p in props if p not in required]
|
|
447
|
+
if missing_required and len(missing_required) == len(props):
|
|
448
|
+
findings.append(Finding(
|
|
449
|
+
"tool_surface", "warning", ep.id,
|
|
450
|
+
f"No request-body properties are marked required "
|
|
451
|
+
f"({len(props)} optional) — strict mode requires all fields in "
|
|
452
|
+
"`properties` to be listed in `required` (use a null type for "
|
|
453
|
+
"genuinely optional ones)."
|
|
454
|
+
))
|
|
455
|
+
|
|
456
|
+
# Enum opportunity: params whose name implies a closed set but which accept free strings.
|
|
457
|
+
enum_candidates = []
|
|
458
|
+
for p in ep.parameters:
|
|
459
|
+
if not isinstance(p, dict) or not p.get("name"):
|
|
460
|
+
continue
|
|
461
|
+
schema = p.get("schema") if isinstance(p.get("schema"), dict) else {}
|
|
462
|
+
if not schema or schema.get("enum"):
|
|
463
|
+
continue
|
|
464
|
+
if schema.get("type") != "string":
|
|
465
|
+
continue
|
|
466
|
+
tokens = set(re.split(r"[^a-z0-9]+", p["name"].lower()))
|
|
467
|
+
closed_set_hints = {"status", "state", "type", "kind", "mode", "currency",
|
|
468
|
+
"direction", "sort", "order", "level", "category"}
|
|
469
|
+
if tokens & closed_set_hints:
|
|
470
|
+
enum_candidates.append(p["name"])
|
|
471
|
+
|
|
472
|
+
if enum_candidates:
|
|
473
|
+
findings.append(Finding(
|
|
474
|
+
"tool_surface", "warning", ep.id,
|
|
475
|
+
f"Parameter(s) suggesting a fixed set of values accept free-form strings "
|
|
476
|
+
f"({', '.join(enum_candidates[:4])}) — an enum would make invalid states "
|
|
477
|
+
"unrepresentable instead of leaving the model to guess valid values."
|
|
478
|
+
))
|
|
479
|
+
|
|
480
|
+
if not findings:
|
|
481
|
+
findings.append(Finding(
|
|
482
|
+
"tool_surface", "pass", ep.id, "Schema is strict-mode friendly."
|
|
483
|
+
))
|
|
484
|
+
return findings
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _request_body_schema(ep: EndpointInfo) -> dict:
|
|
488
|
+
"""Best-effort extraction of the JSON request body schema."""
|
|
489
|
+
body = ep.request_body if isinstance(ep.request_body, dict) else {}
|
|
490
|
+
content = body.get("content") or {}
|
|
491
|
+
for media_type, media in content.items():
|
|
492
|
+
if "json" in str(media_type).lower() and isinstance(media, dict):
|
|
493
|
+
schema = media.get("schema")
|
|
494
|
+
if isinstance(schema, dict):
|
|
495
|
+
return schema
|
|
496
|
+
return {}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# Weights are judgement calls, NOT empirical. Description clarity is weighted
|
|
500
|
+
# highest because 'Unclear Purpose' most directly drives wrong tool selection;
|
|
501
|
+
# be upfront that this is a prior, not a measured effect size.
|
|
502
|
+
CATEGORY_WEIGHTS = {
|
|
503
|
+
"description_clarity": 0.20,
|
|
504
|
+
"side_effects": 0.13,
|
|
505
|
+
"error_responses": 0.13,
|
|
506
|
+
"auth_clarity": 0.09,
|
|
507
|
+
"ambiguity": 0.13,
|
|
508
|
+
"parameter_explanation": 0.12,
|
|
509
|
+
"usage_guidelines": 0.10,
|
|
510
|
+
"tool_surface": 0.10,
|
|
511
|
+
}
|