openapi-to-mcp generator: stop dropping header/cookie params and form bodies

The generated tool functions advertised every OpenAPI parameter in the MCP
input schema but silently discarded anything the LLM supplied for
in: header and in: cookie parameters, and always serialized requestBody as
JSON regardless of the declared content type, so form-urlencoded and
multipart APIs received malformed requests.

Header params are now sent as request headers, ranked below
operator-configured headers so a caller can never override an operator
value; cookie params are encoded into the Cookie header, appended after any
operator-configured cookies. requestBody now encodes per the operation's
content type: data= for application/x-www-form-urlencoded and files= for
multipart/form-data on POST (PUT/PATCH multipart keeps the JSON fallback
because the shared HTTP handler only accepts files on POST). The input
schema also picks up properties from form/multipart schemas instead of only
application/json.
This commit is contained in:
mateo-berri 2026-06-10 16:56:51 +00:00
parent 242918cd73
commit 841e3ca429
No known key found for this signature in database
2 changed files with 344 additions and 11 deletions

View file

@ -234,9 +234,11 @@ def resolve_operation_params(
def extract_parameters(operation: Dict[str, Any]) -> tuple:
"""Extract parameter names from OpenAPI operation."""
"""Extract parameter names from an OpenAPI operation, grouped by location."""
path_params = []
query_params = []
header_params = []
cookie_params = []
body_params = []
# OpenAPI 3.x and 2.x parameters
@ -249,6 +251,10 @@ def extract_parameters(operation: Dict[str, Any]) -> tuple:
path_params.append(param_name)
elif param.get("in") == "query":
query_params.append(param_name)
elif param.get("in") == "header":
header_params.append(param_name)
elif param.get("in") == "cookie":
cookie_params.append(param_name)
elif param.get("in") == "body":
body_params.append(param_name)
@ -256,7 +262,24 @@ def extract_parameters(operation: Dict[str, Any]) -> tuple:
if "requestBody" in operation:
body_params.append("body")
return path_params, query_params, body_params
return path_params, query_params, header_params, cookie_params, body_params
# Request-body content types the generated tool functions can encode, in
# preference order when a spec offers several.
_BODY_CONTENT_TYPES = (
"application/json",
"application/x-www-form-urlencoded",
"multipart/form-data",
)
def _request_body_content_type(operation: Dict[str, Any]) -> str:
content = operation.get("requestBody", {}).get("content", {})
for content_type in _BODY_CONTENT_TYPES:
if content_type in content:
return content_type
return "application/json"
def build_input_schema(operation: Dict[str, Any]) -> Dict[str, Any]:
@ -286,9 +309,10 @@ def build_input_schema(operation: Dict[str, Any]) -> Dict[str, Any]:
request_body = operation["requestBody"]
content = request_body.get("content", {})
# Try to get JSON schema
if "application/json" in content:
schema = content["application/json"].get("schema", {})
schema = content.get(_request_body_content_type(operation), {}).get(
"schema", {}
)
if schema or "application/json" in content:
properties["body"] = {
"type": "object",
"description": request_body.get("description", "Request body"),
@ -344,6 +368,75 @@ def _merge_openapi_tool_request_headers(
return effective_headers
def _build_request_headers(
static_headers: Dict[str, str],
header_params: List[str],
cookie_params: List[str],
kwargs: Dict[str, Any],
) -> Dict[str, str]:
"""Final request headers for a generated tool call.
Caller-supplied ``in: header`` params rank below operator-configured and
auth headers, so an LLM-provided value can never override them.
``in: cookie`` params are encoded into the ``Cookie`` header, appended
after any operator-configured value so the operator's cookies win for
servers that take the first match.
"""
param_headers: Dict[str, str] = {}
for param_name in header_params:
param_value = kwargs.get(param_name, "")
if param_value:
param_headers[param_name] = str(param_value)
effective_headers = _merge_openapi_tool_request_headers(static_headers)
effective_lower_names = {k.lower() for k in effective_headers}
param_headers = {
k: v for k, v in param_headers.items() if k.lower() not in effective_lower_names
}
effective_headers = {**param_headers, **effective_headers}
cookie_pairs = [
f"{param_name}={kwargs[param_name]}"
for param_name in cookie_params
if kwargs.get(param_name, "")
]
if cookie_pairs:
existing_cookie_names = [k for k in effective_headers if k.lower() == "cookie"]
if existing_cookie_names:
name = existing_cookie_names[0]
effective_headers[name] = (
effective_headers[name] + "; " + "; ".join(cookie_pairs)
)
else:
effective_headers["Cookie"] = "; ".join(cookie_pairs)
return effective_headers
def _encode_request_body(
json_body: Optional[Dict[str, Any]],
body_content_type: str,
method: str,
) -> Dict[str, Any]:
"""Encode the body per the operation's requestBody content type.
multipart is only supported on POST (the HTTP handler's other verbs don't
accept files); PUT/PATCH multipart keeps the JSON fallback.
"""
if json_body is None:
return {}
if body_content_type == "application/x-www-form-urlencoded":
return {"data": json_body}
if body_content_type == "multipart/form-data" and method == "post":
return {
"files": {
k: (None, v if isinstance(v, (str, bytes)) else json.dumps(v))
for k, v in json_body.items()
}
}
return {"json": json_body}
def create_tool_function(
path: str,
method: str,
@ -370,7 +463,14 @@ def create_tool_function(
if headers is None:
headers = {}
path_params, query_params, body_params = extract_parameters(operation)
(
path_params,
query_params,
header_params,
cookie_params,
body_params,
) = extract_parameters(operation)
body_content_type = _request_body_content_type(operation)
original_method = method.lower()
async def tool_function(**kwargs: Any) -> str:
@ -381,7 +481,9 @@ def create_tool_function(
The function safely handles parameter names that aren't valid Python identifiers
by using **kwargs instead of named parameters.
"""
effective_headers = _merge_openapi_tool_request_headers(headers)
effective_headers = _build_request_headers(
headers, header_params, cookie_params, kwargs
)
# Build URL from base_url and path
url = base_url + path
@ -432,17 +534,21 @@ def create_tool_function(
except (json.JSONDecodeError, TypeError):
json_body = {"data": body_value}
body_kwargs = _encode_request_body(
json_body, body_content_type, original_method
)
client = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP)
if original_method == "get":
response = await client.get(url, params=params, headers=effective_headers)
elif original_method == "post":
response = await client.post(
url, params=params, json=json_body, headers=effective_headers
url, params=params, headers=effective_headers, **body_kwargs
)
elif original_method == "put":
response = await client.put(
url, params=params, json=json_body, headers=effective_headers
url, params=params, headers=effective_headers, **body_kwargs
)
elif original_method == "delete":
response = await client.delete(
@ -450,7 +556,7 @@ def create_tool_function(
)
elif original_method == "patch":
response = await client.patch(
url, params=params, json=json_body, headers=effective_headers
url, params=params, headers=effective_headers, **body_kwargs
)
else:
return f"Unsupported HTTP method: {original_method}"

View file

@ -420,6 +420,8 @@ class TestExtractParameters:
"parameters": [
{"name": "repo-id", "in": "path"},
{"name": "filter", "in": "query"},
{"name": "X-Request-Id", "in": "header"},
{"name": "session", "in": "cookie"},
{"name": "data", "in": "body"},
],
"requestBody": {
@ -427,10 +429,18 @@ class TestExtractParameters:
},
}
path_params, query_params, body_params = extract_parameters(operation)
(
path_params,
query_params,
header_params,
cookie_params,
body_params,
) = extract_parameters(operation)
assert "repo-id" in path_params
assert "filter" in query_params
assert "X-Request-Id" in header_params
assert "session" in cookie_params
assert "data" in body_params
assert "body" in body_params # From requestBody
@ -1207,3 +1217,220 @@ class TestRequestExtraHeaders:
call_args = async_client.get.call_args
headers_sent = call_args[1]["headers"]
assert "X-TOKEN" not in headers_sent
class TestHeaderAndCookieParameters:
"""OpenAPI ``in: header`` / ``in: cookie`` parameters must reach the wire."""
@pytest.mark.asyncio
async def test_header_parameter_is_sent(self):
operation = {
"parameters": [
{"name": "X-Request-Id", "in": "header", "schema": {"type": "string"}}
]
}
func = create_tool_function(
path="/data",
method="get",
operation=operation,
base_url="https://api.example.com",
)
with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
async_client = _create_mock_client("get", "ok")
mock_client.return_value = async_client
await func(**{"X-Request-Id": "req-42"})
headers_sent = async_client.get.call_args[1]["headers"]
assert headers_sent["X-Request-Id"] == "req-42"
@pytest.mark.asyncio
async def test_header_parameter_cannot_override_operator_header(self):
operation = {
"parameters": [
{"name": "X-Tenant", "in": "header", "schema": {"type": "string"}}
]
}
func = create_tool_function(
path="/data",
method="get",
operation=operation,
base_url="https://api.example.com",
headers={"X-Tenant": "operator-tenant"},
)
with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
async_client = _create_mock_client("get", "ok")
mock_client.return_value = async_client
await func(**{"X-Tenant": "attacker-tenant"})
headers_sent = async_client.get.call_args[1]["headers"]
assert headers_sent["X-Tenant"] == "operator-tenant"
@pytest.mark.asyncio
async def test_cookie_parameter_is_sent_as_cookie_header(self):
operation = {
"parameters": [
{"name": "session", "in": "cookie", "schema": {"type": "string"}},
{"name": "theme", "in": "cookie", "schema": {"type": "string"}},
]
}
func = create_tool_function(
path="/data",
method="get",
operation=operation,
base_url="https://api.example.com",
)
with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
async_client = _create_mock_client("get", "ok")
mock_client.return_value = async_client
await func(session="abc123", theme="dark")
headers_sent = async_client.get.call_args[1]["headers"]
assert headers_sent["Cookie"] == "session=abc123; theme=dark"
@pytest.mark.asyncio
async def test_cookie_parameter_appends_after_operator_cookie(self):
operation = {
"parameters": [
{"name": "session", "in": "cookie", "schema": {"type": "string"}}
]
}
func = create_tool_function(
path="/data",
method="get",
operation=operation,
base_url="https://api.example.com",
headers={"Cookie": "tenant=operator"},
)
with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
async_client = _create_mock_client("get", "ok")
mock_client.return_value = async_client
await func(session="abc123")
headers_sent = async_client.get.call_args[1]["headers"]
assert headers_sent["Cookie"] == "tenant=operator; session=abc123"
class TestRequestBodyContentTypes:
"""requestBody must be encoded per the operation's declared content type."""
@pytest.mark.asyncio
async def test_form_urlencoded_body_sent_as_data(self):
operation = {
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"properties": {"grant_type": {"type": "string"}},
}
}
}
}
}
func = create_tool_function(
path="/token",
method="post",
operation=operation,
base_url="https://api.example.com",
)
with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
async_client = _create_mock_client("post", "ok")
mock_client.return_value = async_client
await func(body={"grant_type": "client_credentials"})
kwargs = async_client.post.call_args[1]
assert kwargs["data"] == {"grant_type": "client_credentials"}
assert "json" not in kwargs
@pytest.mark.asyncio
async def test_multipart_body_sent_as_files(self):
operation = {
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {"file_name": {"type": "string"}},
}
}
}
}
}
func = create_tool_function(
path="/upload",
method="post",
operation=operation,
base_url="https://api.example.com",
)
with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
async_client = _create_mock_client("post", "ok")
mock_client.return_value = async_client
await func(body={"file_name": "report.csv"})
kwargs = async_client.post.call_args[1]
assert kwargs["files"] == {"file_name": (None, "report.csv")}
assert "json" not in kwargs
@pytest.mark.asyncio
async def test_json_body_still_sent_as_json(self):
operation = {
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
}
}
}
}
}
func = create_tool_function(
path="/items",
method="post",
operation=operation,
base_url="https://api.example.com",
)
with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
async_client = _create_mock_client("post", "ok")
mock_client.return_value = async_client
await func(body={"name": "widget"})
kwargs = async_client.post.call_args[1]
assert kwargs["json"] == {"name": "widget"}
def test_form_body_schema_appears_in_input_schema(self):
operation = {
"requestBody": {
"required": True,
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"properties": {"grant_type": {"type": "string"}},
}
}
},
}
}
schema = build_input_schema(operation)
assert schema["properties"]["body"]["properties"] == {
"grant_type": {"type": "string"}
}
assert "body" in schema["required"]