diff --git a/app/api/endpoints/mcp.py b/app/api/endpoints/mcp.py index e36bb859..be238fc2 100644 --- a/app/api/endpoints/mcp.py +++ b/app/api/endpoints/mcp.py @@ -2,17 +2,32 @@ 通过HTTP API暴露MoviePilot的智能体工具功能 """ -from typing import List, Any, Dict, Annotated +import uuid +from typing import List, Any, Dict, Annotated, Optional, Union -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request, Header +from fastapi.responses import JSONResponse, Response from app import schemas from app.agent.tools.manager import MoviePilotToolsManager from app.core.security import verify_apikey from app.log import logger +# 导入版本号 +try: + from version import APP_VERSION +except ImportError: + APP_VERSION = "unknown" + router = APIRouter() +# MCP 协议版本 +MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2024-11-05"] +MCP_PROTOCOL_VERSION = MCP_PROTOCOL_VERSIONS[0] # 默认使用最新版本 + +# 全局会话管理器 +_sessions: Dict[str, Dict[str, Any]] = {} + # 全局工具管理器实例(单例模式,按用户ID缓存) _tools_managers: Dict[str, MoviePilotToolsManager] = {} @@ -39,6 +54,302 @@ def get_tools_manager(user_id: str = "mcp_user", session_id: str = "mcp_session" return _tools_managers[cache_key] +def get_session(session_id: Optional[str]) -> Optional[Dict[str, Any]]: + """获取会话""" + if not session_id: + return None + return _sessions.get(session_id) + + +def create_session(user_id: str) -> Dict[str, Any]: + """创建新会话""" + session_id = str(uuid.uuid4()) + session = { + "id": session_id, + "user_id": user_id, + "initialized": False, + "protocol_version": None, + "capabilities": {} + } + _sessions[session_id] = session + return session + + +def delete_session(session_id: str): + """删除会话""" + if session_id in _sessions: + del _sessions[session_id] + # 同时清理工具管理器缓存 + cache_key = f"{_sessions.get(session_id, {}).get('user_id', 'mcp_user')}_{session_id}" + if cache_key in _tools_managers: + del _tools_managers[cache_key] + + +def create_jsonrpc_response(request_id: Union[str, int, None], result: Any) -> Dict[str, Any]: + """创建 JSON-RPC 成功响应""" + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + return response + + +def create_jsonrpc_error(request_id: Union[str, int, None], code: int, message: str, data: Any = None) -> Dict[str, Any]: + """创建 JSON-RPC 错误响应""" + error = { + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": code, + "message": message + } + } + if data is not None: + error["error"]["data"] = data + return error + + +# ==================== MCP JSON-RPC 端点 ==================== + +@router.post("", summary="MCP JSON-RPC 端点") +async def mcp_jsonrpc( + request: Request, + mcp_session_id: Optional[str] = Header(None, alias="MCP-Session-Id"), + mcp_protocol_version: Optional[str] = Header(None, alias="MCP-Protocol-Version"), + _: Annotated[str, Depends(verify_apikey)] = None +) -> JSONResponse: + """ + MCP 标准 JSON-RPC 2.0 端点 + + 处理所有 MCP 协议消息(初始化、工具列表、工具调用等) + """ + try: + body = await request.json() + except Exception as e: + logger.error(f"解析请求体失败: {e}") + return JSONResponse( + status_code=400, + content=create_jsonrpc_error(None, -32700, "Parse error", str(e)) + ) + + # 验证 JSON-RPC 格式 + 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") + ) + + method = body.get("method") + params = body.get("params", {}) + request_id = body.get("id") + + # 如果有 id,则为请求;没有 id 则为通知 + is_notification = request_id is None + + try: + # 处理初始化请求 + if method == "initialize": + result = await handle_initialize(params, mcp_session_id) + response = create_jsonrpc_response(request_id, result["result"]) + # 如果创建了新会话,在响应头中返回 + if "session_id" in result: + headers = {"MCP-Session-Id": result["session_id"]} + return JSONResponse(content=response, headers=headers) + return JSONResponse(content=response) + + # 处理已初始化通知 + elif method == "notifications/initialized": + if is_notification: + session = get_session(mcp_session_id) + if session: + session["initialized"] = True + # 通知不需要响应 + return Response(status_code=202) + else: + return JSONResponse( + content=create_jsonrpc_error(request_id, -32600, "initialized must be a notification") + ) + + # 验证会话(除了 initialize 和 ping) + if method not in ["initialize", "ping"]: + session = get_session(mcp_session_id) + if not session: + return JSONResponse( + status_code=404, + content=create_jsonrpc_error(request_id, -32002, "Session not found") + ) + if not session.get("initialized") and method != "notifications/initialized": + return JSONResponse( + content=create_jsonrpc_error(request_id, -32003, "Not initialized") + ) + + # 处理工具列表请求 + if method == "tools/list": + result = await handle_tools_list(mcp_session_id) + return JSONResponse(content=create_jsonrpc_response(request_id, result)) + + # 处理工具调用请求 + elif method == "tools/call": + result = await handle_tools_call(params, mcp_session_id) + return JSONResponse(content=create_jsonrpc_response(request_id, result)) + + # 处理 ping 请求 + elif method == "ping": + return JSONResponse(content=create_jsonrpc_response(request_id, {})) + + # 未知方法 + else: + return JSONResponse( + content=create_jsonrpc_error(request_id, -32601, f"Method not found: {method}") + ) + + except Exception as e: + logger.error(f"处理 MCP 请求失败: {e}", exc_info=True) + return JSONResponse( + status_code=500, + content=create_jsonrpc_error(request_id, -32603, "Internal error", str(e)) + ) + + +async def handle_initialize(params: Dict[str, Any], session_id: Optional[str]) -> Dict[str, Any]: + """处理初始化请求""" + protocol_version = params.get("protocolVersion") + client_info = params.get("clientInfo", {}) + client_capabilities = params.get("capabilities", {}) + + logger.info(f"MCP 初始化请求: 客户端={client_info.get('name')}, 协议版本={protocol_version}") + + # 如果没有提供会话ID,创建新会话 + new_session = None + if not session_id: + new_session = create_session(user_id="mcp_user") + session_id = new_session["id"] + + session = get_session(session_id) or new_session + if not session: + raise ValueError("Failed to create session") + + # 版本协商:选择客户端和服务器都支持的版本 + negotiated_version = MCP_PROTOCOL_VERSION + if protocol_version in MCP_PROTOCOL_VERSIONS: + # 客户端版本在支持列表中,使用客户端版本 + negotiated_version = protocol_version + logger.info(f"使用客户端协议版本: {negotiated_version}") + else: + # 客户端版本不支持,使用服务器默认版本 + logger.warning(f"协议版本不匹配: 客户端={protocol_version}, 使用服务器版本={negotiated_version}") + + session["protocol_version"] = negotiated_version + session["capabilities"] = client_capabilities + + result = { + "result": { + "protocolVersion": negotiated_version, + "capabilities": { + "tools": { + "listChanged": False # 暂不支持工具列表变更通知 + }, + "logging": {} + }, + "serverInfo": { + "name": "MoviePilot", + "version": APP_VERSION, + "description": "MoviePilot MCP Server - 电影自动化管理工具", + }, + "instructions": "MoviePilot MCP 服务器,提供媒体管理、订阅、下载等工具。使用 tools/list 查看所有可用工具。" + } + } + + # 如果是新创建的会话,返回会话ID + if new_session: + result["session_id"] = session_id + + return result + + +async def handle_tools_list(session_id: Optional[str]) -> Dict[str, Any]: + """处理工具列表请求""" + session = get_session(session_id) + user_id = session.get("user_id", "mcp_user") if session else "mcp_user" + + manager = get_tools_manager(user_id=user_id, session_id=session_id or "default") + tools = manager.list_tools() + + # 转换为 MCP 工具格式 + mcp_tools = [] + for tool in tools: + mcp_tool = { + "name": tool.name, + "description": tool.description, + "inputSchema": tool.input_schema + } + mcp_tools.append(mcp_tool) + + return { + "tools": mcp_tools + } + + +async def handle_tools_call(params: Dict[str, Any], session_id: Optional[str]) -> Dict[str, Any]: + """处理工具调用请求""" + tool_name = params.get("name") + arguments = params.get("arguments", {}) + + if not tool_name: + raise ValueError("Missing tool name") + + session = get_session(session_id) + user_id = session.get("user_id", "mcp_user") if session else "mcp_user" + + manager = get_tools_manager(user_id=user_id, session_id=session_id or "default") + + try: + result_text = await manager.call_tool(tool_name, arguments) + + return { + "content": [ + { + "type": "text", + "text": result_text + } + ] + } + except Exception as e: + logger.error(f"工具调用失败: {tool_name}, 错误: {e}", exc_info=True) + return { + "content": [ + { + "type": "text", + "text": f"错误: {str(e)}" + } + ], + "isError": True + } + + +@router.delete("", summary="终止 MCP 会话") +async def delete_mcp_session( + mcp_session_id: Optional[str] = Header(None, alias="MCP-Session-Id"), + _: Annotated[str, Depends(verify_apikey)] = None +) -> JSONResponse: + """ + 终止 MCP 会话(可选实现) + + 客户端可以主动调用此接口终止会话 + """ + if not mcp_session_id: + return JSONResponse( + status_code=400, + content={"detail": "Missing MCP-Session-Id header"} + ) + + delete_session(mcp_session_id) + return Response(status_code=204) + + +# ==================== 兼容的 RESTful API 端点 ==================== + @router.get("/tools", summary="列出所有可用工具", response_model=List[Dict[str, Any]]) async def list_tools( _: Annotated[str, Depends(verify_apikey)] @@ -72,7 +383,7 @@ async def list_tools( @router.post("/tools/call", summary="调用工具", response_model=schemas.ToolCallResponse) async def call_tool( request: schemas.ToolCallRequest, - + _: Annotated[str, Depends(verify_apikey)] = None ) -> Any: """ 调用指定的工具 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 03622028..01d0cea6 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -1,9 +1,75 @@ -# MoviePilot 工具API文档 +# MoviePilot MCP (Model Context Protocol) API 文档 -MoviePilot的智能体工具已通过HTTP API暴露,可以通过RESTful API调用所有工具。 +MoviePilot 实现了标准的 **Model Context Protocol (MCP)**,允许 AI 智能体(如 Claude, GPT 等)直接调用 MoviePilot 的功能进行媒体管理、搜索、订阅和下载。 -## API端点 +## 1. 基础信息 +* **基础路径**: `/api/v1/mcp` +* **协议版本**: `2025-11-25, 2025-06-18, 2024-11-05` +* **传输协议**: HTTP (JSON-RPC 2.0) +* **认证方式**: + * Header: `X-API-KEY: <你的API_KEY>` + * Query: `?apikey=<你的API_KEY>` + +## 2. 标准 MCP 协议 (JSON-RPC 2.0) + +### 端点 +**POST** `/api/v1/mcp` + +## 3. 会话管理 + +* **会话维持**: 在标准 MCP 流程中,通过 HTTP Header `MCP-Session-Id` 识别会话。 +* **主动终止**: + **DELETE** `/api/v1/mcp` (携带 `MCP-Session-Id` Header) + +--- + +## 4. 客户端配置示例 + +### Claude Desktop (Anthropic) + +在Claude Desktop的配置文件中添加MoviePilot的MCP服务器配置: + +**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +使用请求头方式: +```json +{ + "mcpServers": { + "moviepilot": { + "url": "http://localhost:3001/api/v1/mcp", + "headers": { + "X-API-KEY": "your_api_key_here" + } + } + } +} +``` + +或使用查询参数方式: +```json +{ + "mcpServers": { + "moviepilot": { + "url": "http://localhost:3001/api/v1/mcp?apikey=your_api_key_here" + } + } +} +``` + +## 5. 错误码说明 + +| 错误码 | 消息 | 说明 | +| :--- | :--- | :--- | +| -32700 | Parse error | JSON 格式错误 | +| -32600 | Invalid Request | 无效的 JSON-RPC 请求 | +| -32601 | Method not found | 方法不存在 | +| -32002 | Session not found | 会话不存在或已过期 | +| -32003 | Not initialized | 会话未完成初始化流程 | +| -32603 | Internal error | 服务器内部错误 | + +## 6. RESTful API 所有工具相关的API端点都在 `/api/v1/mcp` 路径下(保持向后兼容)。 ### 1. 列出所有工具 @@ -138,141 +204,9 @@ MoviePilot的智能体工具已通过HTTP API暴露,可以通过RESTful API调 } ``` -## MCP客户端配置 - -MoviePilot的MCP工具可以通过HTTP协议在支持MCP的客户端中使用。以下是常见MCP客户端的配置方法: - -### Claude Desktop (Anthropic) - -在Claude Desktop的配置文件中添加MoviePilot的MCP服务器配置: - -**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` - -```json -{ - "mcpServers": { - "moviepilot": { - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-http", - "http://localhost:3001/api/v1/mcp" - ], - "env": { - "X-API-KEY": "your_api_key_here" - } - } - } -} -``` - -**注意**: 如果MCP HTTP服务器不支持环境变量传递API Key,可以使用查询参数方式: - -```json -{ - "mcpServers": { - "moviepilot": { - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-http", - "http://localhost:3001/api/v1/mcp?apikey=your_api_key_here" - ] - } - } -} -``` - -### 其他支持MCP的聊天客户端 - -对于其他支持MCP协议的聊天客户端(如其他AI聊天助手、对话机器人等),通常可以通过配置文件或设置界面添加HTTP协议的MCP服务器。配置格式可能因客户端而异,但通常需要以下信息: - -**配置参数**: -1. **服务器类型**: HTTP -2. **服务器地址**: `http://your-moviepilot-host:3001/api/v1/mcp` -3. **认证方式**: - - 在HTTP请求头中添加 `X-API-KEY: ` - - 或在URL查询参数中添加 `apikey=` - -**示例配置**(通用格式): - -使用请求头方式: -```json -{ - "mcpServers": { - "moviepilot": { - "url": "http://localhost:3001/api/v1/mcp", - "headers": { - "X-API-KEY": "your_api_key_here" - } - } - } -} -``` - -或使用查询参数方式: -```json -{ - "mcpServers": { - "moviepilot": { - "url": "http://localhost:3001/api/v1/mcp?apikey=your_api_key_here" - } - } -} -``` - -**支持的端点**: -- `GET /tools` - 列出所有工具 -- `POST /tools/call` - 调用工具 -- `GET /tools/{tool_name}` - 获取工具详情 -- `GET /tools/{tool_name}/schema` - 获取工具参数Schema - -配置完成后,您就可以在聊天对话中使用MoviePilot的各种工具,例如: -- 添加媒体订阅 -- 查询下载历史 -- 搜索媒体资源 -- 管理媒体服务器 -- 等等... - -### 获取API Key - -API Key可以在MoviePilot的系统设置中生成和查看。请妥善保管您的API Key,不要泄露给他人。 - -## 认证 - -所有MCP API端点都需要认证。**仅支持API Key认证方式**: - -- **请求头方式**: 在请求头中添加 `X-API-KEY: ` -- **查询参数方式**: 在URL查询参数中添加 `apikey=` - -**获取API Key**: 在MoviePilot系统设置中生成和查看API Key。请妥善保管您的API Key,不要泄露给他人。 - -## 错误处理 - -API会返回标准的HTTP状态码: - -- `200 OK`: 请求成功 -- `400 Bad Request`: 请求参数错误 -- `401 Unauthorized`: 未认证或API Key无效 -- `404 Not Found`: 工具不存在 -- `500 Internal Server Error`: 服务器内部错误 - -错误响应格式: -```json -{ - "detail": "错误描述信息" -} -``` - -## 架构说明 - -工具API通过FastAPI端点暴露,使用HTTP协议与客户端通信。所有工具共享相同的实现,确保功能一致性。 - -## 注意事项 +## 7. 注意事项 1. **用户上下文**: API调用会使用当前认证用户的ID作为工具执行的用户上下文 2. **会话隔离**: 每个API请求使用独立的会话ID 3. **参数验证**: 工具参数会根据JSON Schema进行验证 4. **错误日志**: 所有工具调用错误都会记录到MoviePilot日志系统 -