From 841e3ca4298b012f423af2f3aa18aba8fa2426f9 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:56:51 +0000 Subject: [PATCH] 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. --- .../mcp_server/openapi_to_mcp_generator.py | 126 +++++++++- .../test_openapi_to_mcp_generator.py | 229 +++++++++++++++++- 2 files changed, 344 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py index de70fe1331e..0720c1142af 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -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}" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py index 39f3c767220..d2981a148ec 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py @@ -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"]