fix(tools): translate OpenAPI 3.0 nullable when inlining schemas

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Stéphane Tailland
2026-08-29 12:19:39 +02:00
parent efb892831c
commit f5090d57a5
+32 -2
View File
@@ -97,7 +97,7 @@ def responses_for(spec: dict[str, Any], method: str, path: str) -> dict[str, Any
ok = sorted(c for c in (op.get("responses") or {}) if c.isdigit() and int(c) < 400) ok = sorted(c for c in (op.get("responses") or {}) if c.isdigit() and int(c) < 400)
if not ok: if not ok:
return None return None
body = resolve_refs(op["responses"][ok[0]], spec) body = as_json_schema(resolve_refs(op["responses"][ok[0]], spec))
content = body.get("content") content = body.get("content")
if not content: if not content:
return None return None
@@ -105,6 +105,32 @@ def responses_for(spec: dict[str, Any], method: str, path: str) -> dict[str, Any
return {ok[0]: {"content": kept}} if kept else None return {ok[0]: {"content": kept}} if kept else None
def as_json_schema(node: Any) -> Any:
"""Translate OpenAPI 3.0's `nullable: true` into JSON Schema's null type.
The plugin's fields are documented as "OpenAPI format", and it validates them
with a JSON Schema validator (`jsonschema_rs`). `nullable` is not a JSON
Schema keyword, so it is ignored and `type: string` is enforced — meaning any
3.0 spec with a nullable field produces a tool that rejects the backend's own
valid response.
Seen for real: `list-products` returns `previous: null` on the first page, and
the call failed with *null is not of type "string", /previous* — after the
backend had already done the work, and only visible in the agent's log.
A 3.1 spec expresses this as `type: [x, "null"]` and needs none of this.
"""
if isinstance(node, dict):
out = {k: as_json_schema(v) for k, v in node.items() if k != "nullable"}
if node.get("nullable") is True and "type" in out:
t = out["type"]
out["type"] = [*t, "null"] if isinstance(t, list) else [t, "null"]
return out
if isinstance(node, list):
return [as_json_schema(v) for v in node]
return node
def resolve_refs(node: Any, spec: dict[str, Any], seen: frozenset[str] = frozenset()) -> Any: def resolve_refs(node: Any, spec: dict[str, Any], seen: frozenset[str] = frozenset()) -> Any:
"""Inline `$ref`s. The plugin's schema states it does not resolve them. """Inline `$ref`s. The plugin's schema states it does not resolve them.
@@ -167,7 +193,11 @@ def build(domain: Path) -> str:
tool["host"] = UPSTREAM_HOST tool["host"] = UPSTREAM_HOST
if body := tool.get("request_body"): if body := tool.get("request_body"):
kept = {k: v for k, v in body["content"].items() if k == KEEP_CONTENT_TYPE} kept = {
k: as_json_schema(v)
for k, v in body["content"].items()
if k == KEEP_CONTENT_TYPE
}
if not kept: if not kept:
raise SystemExit(f"{name}: no {KEEP_CONTENT_TYPE} request body in the spec") raise SystemExit(f"{name}: no {KEEP_CONTENT_TYPE} request body in the spec")
body["content"] = kept body["content"] = kept