diff --git a/tools/generate-mcp-tools.py b/tools/generate-mcp-tools.py index 673565b..4f7b2a6 100755 --- a/tools/generate-mcp-tools.py +++ b/tools/generate-mcp-tools.py @@ -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) if not ok: 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") if not content: 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 +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: """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 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: raise SystemExit(f"{name}: no {KEEP_CONTENT_TYPE} request body in the spec") body["content"] = kept