Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ def _create_tool(self, path: str, method: str, operation: Dict[str, Any], base_u
description = operation.get("summary") or operation.get("description", "")
tags = operation.get("tags", [])

inputs, header_fields, body_field = self._extract_inputs(path, operation)
inputs, header_fields, body_field, body_content_type = self._extract_inputs(path, operation)
outputs = self._extract_outputs(operation)
auth = self._extract_auth(operation)

Expand All @@ -575,7 +575,8 @@ def _create_tool(self, path: str, method: str, operation: Dict[str, Any], base_u
url=full_url,
body_field=body_field if body_field else None,
header_fields=header_fields if header_fields else None,
auth=auth
auth=auth,
content_type=body_content_type or "application/json"
)

return Tool(
Expand All @@ -587,8 +588,8 @@ def _create_tool(self, path: str, method: str, operation: Dict[str, Any], base_u
tool_call_template=call_template
)

def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSchema, List[str], Optional[str]]:
"""Extracts input schema, header fields, and body field from an OpenAPI operation.
def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSchema, List[str], Optional[str], Optional[str]]:
"""Extracts input schema, header fields, body field, and body media type from an OpenAPI operation.

- Merges path-level and operation-level parameters
- Resolves $ref for parameters
Expand All @@ -598,6 +599,7 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch
required = []
header_fields = []
body_field = None
body_content_type = None

# Merge path-level and operation-level parameters
path_item = self.spec.get("paths", {}).get(path, {}) if path else {}
Expand Down Expand Up @@ -639,6 +641,16 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch

# Non-body parameter
schema = self._resolve_ref_obj(param.get("schema", {}), set()) or {}
param_content_obj = None
if not schema and isinstance(param.get("content"), dict):
# OpenAPI 3.x allows a Parameter Object to carry its schema under a
# 'content' map (media-type -> Media Type Object) instead of 'schema',
# e.g. for parameters that need a media type other than the implicit one.
for media_type_obj_candidate in param["content"].values():
if isinstance(media_type_obj_candidate, dict):
param_content_obj = media_type_obj_candidate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a content-form query parameter uses application/json, this branch exposes an object input but drops its serialization metadata. Retain the media type in the generated call template and JSON-serialize that query value.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/http/src/utcp_http/openapi_converter.py, line 649:

<comment>When a content-form query parameter uses `application/json`, this branch exposes an object input but drops its serialization metadata. Retain the media type in the generated call template and JSON-serialize that query value.</comment>

<file context>
@@ -639,6 +639,16 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch
+                # e.g. for parameters that need a media type other than the implicit one.
+                for media_type_obj_candidate in param["content"].values():
+                    if isinstance(media_type_obj_candidate, dict):
+                        param_content_obj = media_type_obj_candidate
+                        schema = self._resolve_ref_obj(param_content_obj.get("schema", {}), set()) or {}
+                        break
</file context>

schema = self._resolve_ref_obj(param_content_obj.get("schema", {}), set()) or {}
break
if not schema:
# OpenAPI 2.0 non-body params use top-level type/items
if "type" in param:
Expand All @@ -647,10 +659,10 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch
schema["items"] = param.get("items")
if "enum" in param:
schema["enum"] = param.get("enum")
# Examples can live on the parameter itself and on its schema;
# collect both into the normalized 'examples' keyword.
param_examples = self._merge_examples(param, schema)

# Examples can live on the parameter itself, its schema, and (for the
# 'content' form) the Media Type Object; collect all into 'examples'.
param_examples = self._merge_examples(param, schema, param_content_obj)

prop = {
"description": param.get("description", ""),
Expand All @@ -667,12 +679,20 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch
request_body = operation.get("requestBody")
if request_body:
content = request_body.get("content", {})
json_schema = content.get("application/json", {}).get("schema")
json_schema = self._resolve_ref_obj(json_schema, set()) if json_schema else None

# Examples can live on the media type object and on the schema;
# collect both into the normalized 'examples' keyword.
body_content_type = "application/json" if "application/json" in content else None
media_type_obj = content.get("application/json", {})
json_schema = media_type_obj.get("schema")
# Fall back to the first schema-bearing media type when the body has no
# application/json entry (e.g. application/xml-only), matching the
# fallback _extract_outputs already does for response bodies.
if json_schema is None and isinstance(content, dict):
for candidate_media_type, candidate_media_type_obj in content.items():
if isinstance(candidate_media_type_obj, dict) and "schema" in candidate_media_type_obj:
media_type_obj = candidate_media_type_obj

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When the fallback selects a non-JSON media type, the generated tool still sends application/json because the selected media type is not propagated. Preserve the media type and use JSON encoding for +json types.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/http/src/utcp_http/openapi_converter.py, line 688:

<comment>When the fallback selects a non-JSON media type, the generated tool still sends `application/json` because the selected media type is not propagated. Preserve the media type and use JSON encoding for `+json` types.</comment>

<file context>
@@ -667,12 +677,18 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch
+            if json_schema is None and isinstance(content, dict):
+                for candidate_media_type_obj in content.values():
+                    if isinstance(candidate_media_type_obj, dict) and "schema" in candidate_media_type_obj:
+                        media_type_obj = candidate_media_type_obj
+                        json_schema = candidate_media_type_obj.get("schema")
+                        break
</file context>

json_schema = candidate_media_type_obj.get("schema")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
body_content_type = candidate_media_type
break
json_schema = self._resolve_ref_obj(json_schema, set()) if json_schema else None

if json_schema:
body_examples = self._merge_examples(media_type_obj, json_schema)
Expand All @@ -686,11 +706,15 @@ def _extract_inputs(self, path: str, operation: Dict[str, Any]) -> Tuple[JsonSch
prop["examples"] = body_examples

properties[body_field] = prop
if json_schema.get("required"):
# requestBody.required (a bool on the request body itself) governs whether
# the body is a mandatory tool input; json_schema.get("required") is a
# different thing (the list of the body object's own required properties)
# and says nothing about whether the body as a whole may be omitted.
if request_body.get("required"):
required.append(body_field)

schema = JsonSchema(properties=properties, required=required if required else None)
return schema, header_fields, body_field
return schema, header_fields, body_field, body_content_type

def _extract_outputs(self, operation: Dict[str, Any]) -> JsonSchema:
"""Extracts the output schema from an OpenAPI operation, resolving refs."""
Expand Down
130 changes: 130 additions & 0 deletions plugins/communication_protocols/http/tests/test_openapi_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,3 +423,133 @@ def test_openapi_converter_example_dedup_is_type_aware_and_order_insensitive():
assert tool.inputs.properties.get("body").examples == [{"a": 1, "b": 2}]
# True vs 1 kept distinct (== would have collapsed them); duplicate True removed
assert tool.outputs.examples == [True, 1]


def test_openapi_converter_request_body_falls_back_to_first_schema_bearing_media_type():
"""A requestBody declared only in a non-JSON media type must not be dropped.

_extract_inputs hard-coded content["application/json"] with no fallback, unlike
_extract_outputs which already falls back to the first schema-bearing media type.
A body declared only as e.g. application/xml produced a tool with no body input at all.
"""
openapi_spec = {
"openapi": "3.0.0",
"info": {"title": "Test API", "version": "1.0.0"},
"paths": {
"/items": {
"post": {
"operationId": "createItem",
"requestBody": {
"content": {
"application/xml": {
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
},
"example": {"name": "widget"},
}
}
},
"responses": {"200": {"description": "ok"}},
}
}
},
}

converter = OpenApiConverter(openapi_spec)
manual = converter.convert()

tool = next((t for t in manual.tools if t.name == "createItem"), None)
assert tool is not None

body_param = tool.inputs.properties.get("body")
assert body_param is not None
assert body_param.properties.get("name").type == "string"
assert body_param.examples == [{"name": "widget"}]
# The fallback media type must reach the call template, or the tool would still
# send this body as application/json and JSON-encode an XML payload.
assert tool.tool_call_template.content_type == "application/xml"


def test_openapi_converter_request_body_required_flag_is_the_outer_flag():
"""requestBody.required (the outer flag) must drive the tool's own required list,
not json_schema.get("required") (the body object's own required properties).

A requestBody marked required=true whose schema has no property-level "required"
list previously left the "body" tool input optional, even though OpenAPI says the
body itself may not be omitted.
"""
openapi_spec = {
"openapi": "3.0.0",
"info": {"title": "Test API", "version": "1.0.0"},
"paths": {
"/items": {
"post": {
"operationId": "createItem",
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {"type": "object", "properties": {"name": {"type": "string"}}},
}
},
},
"responses": {"200": {"description": "ok"}},
}
}
},
}

converter = OpenApiConverter(openapi_spec)
manual = converter.convert()

tool = next((t for t in manual.tools if t.name == "createItem"), None)
assert tool is not None
assert tool.inputs.required == ["body"]


def test_openapi_converter_parameter_content_form_schema_and_examples():
"""A Parameter Object may carry its schema under 'content' instead of 'schema'.

_extract_inputs only read param["schema"], so a parameter declared with the OAS3
content form (content: {<media-type>: {schema, example}}) lost both its schema and
its examples.
"""
openapi_spec = {
"openapi": "3.0.0",
"info": {"title": "Test API", "version": "1.0.0"},
"paths": {
"/items": {
"get": {
"operationId": "listItems",
"parameters": [
{
"name": "filter",
"in": "query",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"status": {"type": "string"}},
},
"example": {"status": "active"},
}
},
}
],
"responses": {"200": {"description": "ok"}},
}
}
},
}

converter = OpenApiConverter(openapi_spec)
manual = converter.convert()

tool = next((t for t in manual.tools if t.name == "listItems"), None)
assert tool is not None

filter_param = tool.inputs.properties.get("filter")
assert filter_param is not None
assert filter_param.properties.get("status").type == "string"
assert filter_param.examples == [{"status": "active"}]