From 93935170d69ab5bb7b14f50783c373bc6a9798c3 Mon Sep 17 00:00:00 2001 From: Suhas Aitham <83656128+suhasaitham22@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:48:32 -0700 Subject: [PATCH 1/2] fix(mcp): document /mcp-rest/tools/call request body in OpenAPI spec --- litellm/proxy/_types.py | 34 +++++++++++++ litellm/proxy/mcp_server/rest_endpoints.py | 4 +- .../proxy/mcp_server/test_rest_endpoints.py | 48 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index f165355da5a..c092b0e35c6 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1481,6 +1481,40 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase): return values +class MCPToolCallRequest(LiteLLMPydanticObjectBase): + """Request body for `POST /mcp-rest/tools/call`. + + Fields stay optional here on purpose: the endpoint answers a missing + `server_id` or `name` with its 400 `missing_parameter` response rather + than a framework 422, and unknown fields pass through to the pre-call + pipeline, matching the previous untyped `request.json()` handling. + """ + + model_config = ConfigDict(extra="allow") + + server_id: str | None = Field( + default=None, + description="Required. The id of the MCP server that owns the tool to call.", + ) + name: str | None = Field( + default=None, + description="Required. The name of the tool to call.", + ) + arguments: dict[str, object] = Field( + default_factory=dict, + description="Arguments to pass to the tool.", + ) + metadata: dict[str, object] | None = Field( + default=None, + description="Optional request metadata, forwarded to logging and guardrails.", + ) + + @field_validator("arguments", mode="before") + @classmethod + def _coerce_null_arguments(cls, value: object) -> object: + return {} if value is None else value + + class UpdateMCPServerRequest(LiteLLMPydanticObjectBase): server_id: str server_name: str | None = None diff --git a/litellm/proxy/mcp_server/rest_endpoints.py b/litellm/proxy/mcp_server/rest_endpoints.py index 2737b567985..3eba1534b2a 100644 --- a/litellm/proxy/mcp_server/rest_endpoints.py +++ b/litellm/proxy/mcp_server/rest_endpoints.py @@ -26,6 +26,7 @@ from litellm.exceptions import ( ) from litellm.proxy._types import ( LitellmUserRoles, + MCPToolCallRequest, UserAPIKeyAuth, user_api_key_has_admin_view, ) @@ -1319,6 +1320,7 @@ if MCP_AVAILABLE: @catalog_operation(global_manager) async def call_tool_rest_api( request: Request, + body: MCPToolCallRequest | None = None, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ @@ -1339,7 +1341,7 @@ if MCP_AVAILABLE: reject_disallowed_mcp_client(request.headers, user_api_key_dict) try: user_api_key_dict = await acting_user_auth(user_api_key_dict) - data = await request.json() + data = body.model_dump() if body is not None else await request.json() tool_name: Final[str | None] = data.get("name") tool_arguments: Final[dict[str, object]] = data.get("arguments") or {} diff --git a/tests/unit/proxy/mcp_server/test_rest_endpoints.py b/tests/unit/proxy/mcp_server/test_rest_endpoints.py index fdb1ff3cd09..a1a41d4c5d5 100644 --- a/tests/unit/proxy/mcp_server/test_rest_endpoints.py +++ b/tests/unit/proxy/mcp_server/test_rest_endpoints.py @@ -19,6 +19,7 @@ from starlette.requests import Request from litellm.constants import MCP_TOOL_LISTING_TIMEOUT from litellm.proxy._types import ( + MCPToolCallRequest, NewMCPServerRequest, UpdateMCPServerRequest, UserAPIKeyAuth, @@ -5106,3 +5107,50 @@ async def test_rest_guardrail_bearer_excludes_admission_key( assert (authorization.removeprefix("Bearer ") or None) == expected finally: global_mcp_tool_registry.unregister_tools_with_prefix("guarded-") + + +class TestCallToolRestApiRequestBody: + """POST /mcp-rest/tools/call documents its body in the generated OpenAPI + spec, so a client built from openapi.json passes server_id, name and + arguments instead of calling the tool blind (issue #32121).""" + + def test_openapi_documents_the_tools_call_body(self) -> None: + from fastapi import FastAPI + + app = FastAPI() + app.include_router(rest_endpoints.router) + paths: Final = app.openapi()["paths"] + + operation: Final = paths["/mcp-rest/tools/call"]["post"] + body_schema: Final = operation["requestBody"]["content"]["application/json"]["schema"] + refs: Final = body_schema.get("anyOf", [body_schema]) + model_ref: Final = next(ref["$ref"] for ref in refs if "$ref" in ref) + components: Final = app.openapi()["components"]["schemas"] + properties: Final = components[model_ref.rsplit("/", 1)[-1]]["properties"] + + assert {"server_id", "name", "arguments", "metadata"} <= set(properties) + assert all(field.get("description") for field in properties.values()) + + @pytest.mark.parametrize( + ("body", "message"), + ( + (MCPToolCallRequest(name="demo-tool"), "server_id is required in request body"), + (MCPToolCallRequest(server_id="server-1"), "name is required in request body"), + ), + ) + async def test_missing_required_fields_keep_the_400_response(self, body: MCPToolCallRequest, message: str) -> None: + request: Final = _build_request(path="/mcp-rest/tools/call", method="POST", json_body={}) + + with pytest.raises(HTTPException) as exc_info: + await rest_endpoints.call_tool_rest_api(request, body=body, user_api_key_dict=UserAPIKeyAuth()) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == {"error": "missing_parameter", "message": message} + + def test_null_arguments_are_treated_as_no_arguments(self) -> None: + body: Final = MCPToolCallRequest.model_validate( + {"server_id": "server-1", "name": "demo-tool", "arguments": None} + ) + + assert body.arguments == {} + assert body.model_dump()["arguments"] == {} From c380d78cca4ddbbba9463cf8294ef545978450a8 Mon Sep 17 00:00:00 2001 From: Suhas Aitham <83656128+suhasaitham22@users.noreply.github.com> Date: Fri, 9 Oct 2026 06:42:38 +0000 Subject: [PATCH 2/2] fix(mcp): regenerate OpenAPI snapshot and dashboard types for the tools/call body --- litellm/proxy/_lazy_openapi_snapshot.json | 156 ++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 52 +++++- 2 files changed, 207 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index c76b9be7c33..130a2d01037 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -36650,6 +36650,57 @@ "title": "MCPEnvVarScope", "type": "string" }, + "MCPToolCallRequest": { + "additionalProperties": true, + "description": "Request body for `POST /mcp-rest/tools/call`.\n\nFields stay optional here on purpose: the endpoint answers a missing\n`server_id` or `name` with its 400 `missing_parameter` response rather\nthan a framework 422, and unknown fields pass through to the pre-call\npipeline, matching the previous untyped `request.json()` handling.", + "properties": { + "arguments": { + "additionalProperties": true, + "description": "Arguments to pass to the tool.", + "title": "Arguments", + "type": "object" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Optional request metadata, forwarded to logging and guardrails.", + "title": "Metadata" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The name of the tool to call.", + "title": "Name" + }, + "server_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The id of the MCP server that owns the tool to call.", + "title": "Server Id" + } + }, + "title": "MCPToolCallRequest", + "type": "object" + }, "NewMCPServerRequest": { "properties": { "alias": { @@ -37415,6 +37466,23 @@ "post": { "description": "REST API to call a specific MCP tool with the provided arguments", "operationId": "call_tool_rest_api_mcp_rest_tools_call_post_2", + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MCPToolCallRequest" + }, + { + "type": "null" + } + ], + "title": "Body" + } + } + } + }, "responses": { "200": { "content": { @@ -37423,6 +37491,16 @@ } }, "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" } }, "security": [ @@ -46985,6 +47063,57 @@ "title": "MCPEnvVarScope", "type": "string" }, + "MCPToolCallRequest": { + "additionalProperties": true, + "description": "Request body for `POST /mcp-rest/tools/call`.\n\nFields stay optional here on purpose: the endpoint answers a missing\n`server_id` or `name` with its 400 `missing_parameter` response rather\nthan a framework 422, and unknown fields pass through to the pre-call\npipeline, matching the previous untyped `request.json()` handling.", + "properties": { + "arguments": { + "additionalProperties": true, + "description": "Arguments to pass to the tool.", + "title": "Arguments", + "type": "object" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Optional request metadata, forwarded to logging and guardrails.", + "title": "Metadata" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The name of the tool to call.", + "title": "Name" + }, + "server_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required. The id of the MCP server that owns the tool to call.", + "title": "Server Id" + } + }, + "title": "MCPToolCallRequest", + "type": "object" + }, "NewMCPServerRequest": { "properties": { "alias": { @@ -47622,6 +47751,23 @@ "post": { "description": "REST API to call a specific MCP tool with the provided arguments", "operationId": "call_tool_rest_api_mcp_rest_tools_call_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MCPToolCallRequest" + }, + { + "type": "null" + } + ], + "title": "Body" + } + } + } + }, "responses": { "200": { "content": { @@ -47630,6 +47776,16 @@ } }, "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" } }, "security": [ diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 960616e2631..c1f9e0ea17f 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -38130,6 +38130,43 @@ export interface components { /** Total */ total: number; }; + /** + * MCPToolCallRequest + * @description Request body for `POST /mcp-rest/tools/call`. + * + * Fields stay optional here on purpose: the endpoint answers a missing + * `server_id` or `name` with its 400 `missing_parameter` response rather + * than a framework 422, and unknown fields pass through to the pre-call + * pipeline, matching the previous untyped `request.json()` handling. + */ + MCPToolCallRequest: { + /** + * Arguments + * @description Arguments to pass to the tool. + */ + arguments?: { + [key: string]: unknown; + }; + /** + * Metadata + * @description Optional request metadata, forwarded to logging and guardrails. + */ + metadata?: { + [key: string]: unknown; + } | null; + /** + * Name + * @description Required. The name of the tool to call. + */ + name?: string | null; + /** + * Server Id + * @description Required. The id of the MCP server that owns the tool to call. + */ + server_id?: string | null; + } & { + [key: string]: unknown; + }; /** * MCPToolSearchSettings * @description `litellm_settings.mcp_tool_search`: how the native `mcp_tool_search` virtual tool ranks the caller's tools. @@ -65191,7 +65228,11 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["MCPToolCallRequest"] | null; + }; + }; responses: { /** @description Successful Response */ 200: { @@ -65202,6 +65243,15 @@ export interface operations { "application/json": unknown; }; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; list_tool_rest_api_mcp_rest_tools_list_get: {