diff --git a/app/api/endpoints/mcp.py b/app/api/endpoints/mcp.py index f3f342daa..7e7daae0f 100644 --- a/app/api/endpoints/mcp.py +++ b/app/api/endpoints/mcp.py @@ -109,13 +109,14 @@ def create_jsonrpc_error( """ 创建 JSON-RPC 错误响应 """ + error_detail: Dict[str, object] = {"code": code, "message": message} error = { "jsonrpc": "2.0", "id": request_id, - "error": {"code": code, "message": message}, + "error": error_detail, } if data is not None: - error["error"]["data"] = data + error_detail["data"] = data return error @@ -164,7 +165,7 @@ async def _dispatch_jsonrpc_method( }, ) async def mcp_jsonrpc( - request: Request, _: Annotated[str, Depends(verify_apikey)] = None + request: Request, _: Annotated[Union[str, None], Depends(verify_apikey)] = None ) -> Union[JSONResponse, Response]: """ MCP 标准 JSON-RPC 2.0 端点 @@ -184,7 +185,9 @@ async def mcp_jsonrpc( if not isinstance(body, dict) or body.get("jsonrpc") != "2.0": return JSONResponse( status_code=400, - content=create_jsonrpc_error(body.get("id"), -32600, "Invalid Request"), + content=create_jsonrpc_error( + body.get("id") if isinstance(body, dict) else None, -32600, "Invalid Request" + ), ) method = body.get("method") @@ -300,7 +303,7 @@ async def handle_tools_call(params: Dict[str, Any]) -> Dict[str, Any]: }, ) async def delete_mcp_session( - _: Annotated[str, Depends(verify_apikey)] = None, + _: Annotated[Union[str, None], Depends(verify_apikey)] = None, ) -> Union[JSONResponse, Response]: """ 终止 MCP 会话(无状态模式下仅返回成功) @@ -347,7 +350,7 @@ async def list_tools(_: Annotated[str, Depends(verify_apikey)], page: Compatible summary="调用工具", response_model=_SchemaResponse[_SchemaToolCallData], ) -async def call_tool(request: _SchemaToolCallRequest, _: Annotated[str, Depends(verify_apikey)] = None) -> Any: +async def call_tool(request: _SchemaToolCallRequest, _: Annotated[Union[str, None], Depends(verify_apikey)] = None) -> Any: """ 调用指定的工具 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index da4508260..d6e108be5 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -51,6 +51,10 @@ Fake-IP 等非公网解析地址需要通过 `IMAGE_PROXY_ALLOWED_PRIVATE_RANGES ### 端点 **POST** `/api/v1/mcp` +请求体须为单个 JSON-RPC 2.0 对象。非对象值(包括数组批量请求)或错误的协议版本返回 +HTTP 400、JSON-RPC 错误码 `-32600`;非对象请求的 `id` 为 `null`。无法解析的 JSON 返回 +HTTP 400、错误码 `-32700`。 + ### 支持的方法 - `initialize`: 初始化会话,协商协议版本和能力。 - `notifications/initialized`: 客户端确认初始化完成。 diff --git a/tests/test_mcp_requests.py b/tests/test_mcp_requests.py new file mode 100644 index 000000000..a7beffc57 --- /dev/null +++ b/tests/test_mcp_requests.py @@ -0,0 +1,43 @@ +"""MCP JSON-RPC 请求格式错误的协议回归。""" + +import json + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.endpoints import mcp + + +@pytest.mark.parametrize( + ("body", "request_id"), + [ + (None, None), + ([], None), + ([{"jsonrpc": "2.0", "method": "ping", "id": 1}], None), + ("invalid", None), + (1, None), + (True, None), + ({}, None), + ({"jsonrpc": "1.0", "id": 7}, 7), + ], +) +def test_invalid_request_returns_jsonrpc_error(body: object, request_id: object) -> None: + """非对象及错误版本的请求须返回 400 协议错误,不能在取 id 时崩溃。""" + app = FastAPI() + app.include_router(mcp.router, prefix="/mcp") + app.dependency_overrides[mcp.verify_apikey] = lambda: "test-api-key" + + with TestClient(app) as client: + response = client.post( + "/mcp", + content=json.dumps(body), + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 400 + assert response.json() == { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32600, "message": "Invalid Request"}, + }