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/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
+ }