diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index a6067a60105..62a57f8e5d3 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -15,7 +15,8 @@ from typing import ( ) import httpx -from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, status +from pydantic import BaseModel, ConfigDict, Field from litellm._logging import verbose_logger from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError @@ -64,6 +65,42 @@ def _connection_error_message(exc: BaseException) -> str: return "Failed to connect to MCP server. Check proxy logs for details." +class MCPRestToolCallRequest(BaseModel): + """Request body for ``POST /mcp-rest/tools/call``. + + Declared so the generated OpenAPI spec documents the fields a client must + send; the handler still reads the raw JSON body. Extra keys are allowed + because the same route also serves the tool-search / tool-call virtual-tool + flows, which carry a different payload shape. + """ + + model_config = ConfigDict( + extra="allow", + json_schema_extra={ + "example": { + "server_id": "17a4490465f74d3696caf12b30220166", + "name": "google_maps-getPlaces", + "arguments": {"query": "coffee near me"}, + } + }, + ) + + name: str = Field( + description="Name of the MCP tool to invoke, as returned by GET /mcp-rest/tools/list.", + ) + arguments: dict[str, object] = Field( + default_factory=dict, + description="Tool arguments matching the tool's inputSchema. Pass {} when the tool takes no arguments.", + ) + server_id: Optional[str] = Field( + default=None, + description=( + "MCP server the tool belongs to (UUID, server_name, or alias). " + "Required when calling a tool on a specific server." + ), + ) + + if MCP_AVAILABLE: from mcp.types import Tool as MCPTool @@ -738,6 +775,7 @@ if MCP_AVAILABLE: @router.post("/tools/call", dependencies=[Depends(user_api_key_auth)]) async def call_tool_rest_api( request: Request, + body: Optional[MCPRestToolCallRequest] = Body(default=None), user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 27cdc483d4a..ad072d8d480 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -13602,6 +13602,47 @@ "title": "MCPCredentials", "type": "object" }, + "MCPRestToolCallRequest": { + "additionalProperties": true, + "description": "Request body for ``POST /mcp-rest/tools/call``.\n\nDeclared so the generated OpenAPI spec documents the fields a client must\nsend; the handler still reads the raw JSON body. Extra keys are allowed\nbecause the same route also serves the tool-search / tool-call virtual-tool\nflows, which carry a different payload shape.", + "example": { + "arguments": { + "query": "coffee near me" + }, + "name": "google_maps-getPlaces", + "server_id": "17a4490465f74d3696caf12b30220166" + }, + "properties": { + "arguments": { + "additionalProperties": true, + "description": "Tool arguments matching the tool's inputSchema. Pass {} when the tool takes no arguments.", + "title": "Arguments", + "type": "object" + }, + "name": { + "description": "Name of the MCP tool to invoke, as returned by GET /mcp-rest/tools/list.", + "title": "Name", + "type": "string" + }, + "server_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "MCP server the tool belongs to (UUID, server_name, or alias). Required when calling a tool on a specific server.", + "title": "Server Id" + } + }, + "required": [ + "name" + ], + "title": "MCPRestToolCallRequest", + "type": "object" + }, "NewMCPServerRequest": { "properties": { "alias": { @@ -14099,6 +14140,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/MCPRestToolCallRequest" + }, + { + "type": "null" + } + ], + "title": "Body" + } + } + } + }, "responses": { "200": { "content": { @@ -17509,6 +17567,47 @@ "title": "MCPCredentials", "type": "object" }, + "MCPRestToolCallRequest": { + "additionalProperties": true, + "description": "Request body for ``POST /mcp-rest/tools/call``.\n\nDeclared so the generated OpenAPI spec documents the fields a client must\nsend; the handler still reads the raw JSON body. Extra keys are allowed\nbecause the same route also serves the tool-search / tool-call virtual-tool\nflows, which carry a different payload shape.", + "example": { + "arguments": { + "query": "coffee near me" + }, + "name": "google_maps-getPlaces", + "server_id": "17a4490465f74d3696caf12b30220166" + }, + "properties": { + "arguments": { + "additionalProperties": true, + "description": "Tool arguments matching the tool's inputSchema. Pass {} when the tool takes no arguments.", + "title": "Arguments", + "type": "object" + }, + "name": { + "description": "Name of the MCP tool to invoke, as returned by GET /mcp-rest/tools/list.", + "title": "Name", + "type": "string" + }, + "server_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "MCP server the tool belongs to (UUID, server_name, or alias). Required when calling a tool on a specific server.", + "title": "Server Id" + } + }, + "required": [ + "name" + ], + "title": "MCPRestToolCallRequest", + "type": "object" + }, "NewMCPServerRequest": { "properties": { "alias": { @@ -18006,6 +18105,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/MCPRestToolCallRequest" + }, + { + "type": "null" + } + ], + "title": "Body" + } + } + } + }, "responses": { "200": { "content": { diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py index fb4eee54e1d..4beedcf48b5 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py @@ -2115,3 +2115,62 @@ class TestToolResponseMcpInfoEnrichment: "server_id": "server-uuid", "alias": None, } + + +class TestToolsCallOpenAPIDocumentation: + """Regression coverage for issue #32121: POST /mcp-rest/tools/call must + document its request body (server_id / name / arguments) in the OpenAPI + spec, both on the live route and in the served lazy-feature snapshot.""" + + def test_route_documents_request_body(self): + from fastapi.openapi.utils import get_openapi + + routes = [ + r + for r in rest_endpoints.router.routes + if getattr(r, "path", "") == "/mcp-rest/tools/call" + and "POST" in getattr(r, "methods", set()) + ] + assert routes, "POST /mcp-rest/tools/call route not registered" + + spec = get_openapi(title="t", version="1", routes=routes) + operation = spec["paths"]["/mcp-rest/tools/call"]["post"] + assert "requestBody" in operation + + schema = spec["components"]["schemas"]["MCPRestToolCallRequest"] + assert {"server_id", "name", "arguments"} <= set(schema["properties"]) + assert schema["required"] == ["name"] + assert schema["additionalProperties"] is True + + def test_request_model_requires_name_and_allows_extra_keys(self): + from pydantic import ValidationError + + from litellm.proxy._experimental.mcp_server.rest_endpoints import ( + MCPRestToolCallRequest, + ) + + parsed = MCPRestToolCallRequest.model_validate( + {"name": "mcp_tool_search", "arguments": {"query": "x"}, "extra": "kept"} + ) + assert parsed.name == "mcp_tool_search" + assert parsed.arguments == {"query": "x"} + assert parsed.model_dump()["extra"] == "kept" + + assert MCPRestToolCallRequest(name="demo").arguments == {} + + with pytest.raises(ValidationError): + MCPRestToolCallRequest.model_validate({"arguments": {}}) + + def test_served_snapshot_documents_request_body(self): + from litellm.proxy._lazy_openapi_snapshot import load_snapshot + + snapshot = load_snapshot() + assert snapshot is not None + + for fragment_name in ("mcp_rest", "mcp_app"): + fragment = snapshot[fragment_name] + post = fragment["paths"]["/mcp-rest/tools/call"]["post"] + schema = post["requestBody"]["content"]["application/json"]["schema"] + refs = [entry.get("$ref") for entry in schema.get("anyOf", [])] or [schema.get("$ref")] + assert "#/components/schemas/MCPRestToolCallRequest" in refs + assert "MCPRestToolCallRequest" in fragment["components"]["schemas"] diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index bfd1efc99f3..0c1a3d4bc2c 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -27002,6 +27002,43 @@ export interface components { */ transport: "sse" | "http" | "stdio"; }; + /** + * MCPRestToolCallRequest + * @description Request body for ``POST /mcp-rest/tools/call``. + * + * Declared so the generated OpenAPI spec documents the fields a client must + * send; the handler still reads the raw JSON body. Extra keys are allowed + * because the same route also serves the tool-search / tool-call virtual-tool + * flows, which carry a different payload shape. + * @example { + * "arguments": { + * "query": "coffee near me" + * }, + * "name": "google_maps-getPlaces", + * "server_id": "17a4490465f74d3696caf12b30220166" + * } + */ + MCPRestToolCallRequest: { + /** + * Arguments + * @description Tool arguments matching the tool's inputSchema. Pass {} when the tool takes no arguments. + */ + arguments?: { + [key: string]: unknown; + }; + /** + * Name + * @description Name of the MCP tool to invoke, as returned by GET /mcp-rest/tools/list. + */ + name: string; + /** + * Server Id + * @description MCP server the tool belongs to (UUID, server_name, or alias). Required when calling a tool on a specific server. + */ + server_id?: string | null; + } & { + [key: string]: unknown; + }; /** * MCPSemanticFilterSettings * @description Configuration for MCP Semantic Tool Filter @@ -42578,7 +42615,11 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["MCPRestToolCallRequest"] | null; + }; + }; responses: { /** @description Successful Response */ 200: {