fix(proxy): document /mcp-rest/tools/call request body in OpenAPI

Co-Authored-By: bot_apk <apk@cognition.ai>
This commit is contained in:
Devin AI 2026-07-04 18:32:01 +00:00
parent 0932dde167
commit 723126b457
4 changed files with 256 additions and 2 deletions

View file

@ -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),
):
"""

View file

@ -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": {

View file

@ -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"]

View file

@ -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: {