This commit is contained in:
jxxghp
2025-11-19 13:19:17 +08:00
parent 5ae7c10a00
commit 6123a1620e
7 changed files with 660 additions and 2 deletions

187
app/agent/tools/manager.py Normal file
View File

@@ -0,0 +1,187 @@
"""MoviePilot工具管理器
用于HTTP API调用工具
"""
import json
from typing import Any, Dict, List, Optional
from app.agent.tools.factory import MoviePilotToolFactory
from app.log import logger
class ToolDefinition:
"""工具定义"""
def __init__(self, name: str, description: str, input_schema: Dict[str, Any]):
self.name = name
self.description = description
self.input_schema = input_schema
class MoviePilotToolsManager:
"""MoviePilot工具管理器用于HTTP API"""
def __init__(self, user_id: str = "api_user", session_id: str = "api_session"):
"""
初始化工具管理器
Args:
user_id: 用户ID
session_id: 会话ID
"""
self.user_id = user_id
self.session_id = session_id
self.tools: List[Any] = []
self._load_tools()
def _load_tools(self):
"""加载所有MoviePilot工具"""
try:
# 创建工具实例
self.tools = MoviePilotToolFactory.create_tools(
session_id=self.session_id,
user_id=self.user_id,
channel=None,
source="api",
username="API Client",
callback_handler=None
)
logger.info(f"成功加载 {len(self.tools)} 个工具")
except Exception as e:
logger.error(f"加载工具失败: {e}", exc_info=True)
self.tools = []
def list_tools(self) -> List[ToolDefinition]:
"""
列出所有可用的工具
Returns:
工具定义列表
"""
tools_list = []
for tool in self.tools:
# 获取工具的输入参数模型
args_schema = getattr(tool, 'args_schema', None)
if args_schema:
# 将Pydantic模型转换为JSON Schema
input_schema = self._convert_to_json_schema(args_schema)
else:
# 如果没有args_schema使用基本信息
input_schema = {
"type": "object",
"properties": {},
"required": []
}
tools_list.append(ToolDefinition(
name=tool.name,
description=tool.description or "",
input_schema=input_schema
))
return tools_list
def get_tool(self, tool_name: str) -> Optional[Any]:
"""
获取指定工具实例
Args:
tool_name: 工具名称
Returns:
工具实例如果未找到返回None
"""
for tool in self.tools:
if tool.name == tool_name:
return tool
return None
async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> str:
"""
调用工具
Args:
tool_name: 工具名称
arguments: 工具参数
Returns:
工具执行结果(字符串)
"""
tool_instance = self.get_tool(tool_name)
if not tool_instance:
error_msg = json.dumps({
"error": f"工具 '{tool_name}' 未找到"
}, ensure_ascii=False)
return error_msg
try:
# 调用工具的run方法
result = await tool_instance.run(**arguments)
# 确保返回字符串
if isinstance(result, str):
return result
else:
return json.dumps(result, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"调用工具 {tool_name} 时发生错误: {e}", exc_info=True)
error_msg = json.dumps({
"error": f"调用工具 '{tool_name}' 时发生错误: {str(e)}"
}, ensure_ascii=False)
return error_msg
@staticmethod
def _convert_to_json_schema(args_schema: Any) -> Dict[str, Any]:
"""
将Pydantic模型转换为JSON Schema
Args:
args_schema: Pydantic模型类
Returns:
JSON Schema字典
"""
# 获取Pydantic模型的字段信息
schema = args_schema.model_json_schema()
# 构建JSON Schema
properties = {}
required = []
if "properties" in schema:
for field_name, field_info in schema["properties"].items():
# 转换字段类型
field_type = field_info.get("type", "string")
field_description = field_info.get("description", "")
# 处理可选字段
if field_name not in schema.get("required", []):
# 可选字段
default_value = field_info.get("default")
properties[field_name] = {
"type": field_type,
"description": field_description
}
if default_value is not None:
properties[field_name]["default"] = default_value
else:
properties[field_name] = {
"type": field_type,
"description": field_description
}
required.append(field_name)
# 处理枚举类型
if "enum" in field_info:
properties[field_name]["enum"] = field_info["enum"]
# 处理数组类型
if field_type == "array" and "items" in field_info:
properties[field_name]["items"] = field_info["items"]
return {
"type": "object",
"properties": properties,
"required": required
}

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter
from app.api.endpoints import login, user, webhook, message, site, subscribe, \ from app.api.endpoints import login, user, webhook, message, site, subscribe, \
media, douban, search, plugin, tmdb, history, system, download, dashboard, \ media, douban, search, plugin, tmdb, history, system, download, dashboard, \
transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, mcp
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(login.router, prefix="/login", tags=["login"]) api_router.include_router(login.router, prefix="/login", tags=["login"])
@@ -28,3 +28,4 @@ api_router.include_router(discover.router, prefix="/discover", tags=["discover"]
api_router.include_router(recommend.router, prefix="/recommend", tags=["recommend"]) api_router.include_router(recommend.router, prefix="/recommend", tags=["recommend"])
api_router.include_router(workflow.router, prefix="/workflow", tags=["workflow"]) api_router.include_router(workflow.router, prefix="/workflow", tags=["workflow"])
api_router.include_router(torrent.router, prefix="/torrent", tags=["torrent"]) api_router.include_router(torrent.router, prefix="/torrent", tags=["torrent"])
api_router.include_router(mcp.router, prefix="/mcp", tags=["mcp"])

161
app/api/endpoints/mcp.py Normal file
View File

@@ -0,0 +1,161 @@
"""工具API端点
通过HTTP API暴露MoviePilot的智能体工具功能
"""
from typing import List, Any, Dict, Annotated
from fastapi import APIRouter, Depends, HTTPException
from app import schemas
from app.agent.tools.manager import MoviePilotToolsManager
from app.core.security import verify_apikey
from app.log import logger
router = APIRouter()
# 全局工具管理器实例单例模式按用户ID缓存
_tools_managers: Dict[str, MoviePilotToolsManager] = {}
def get_tools_manager(user_id: str = "mcp_user", session_id: str = "mcp_session") -> MoviePilotToolsManager:
"""
获取工具管理器实例按用户ID缓存
Args:
user_id: 用户ID
session_id: 会话ID
Returns:
MoviePilotToolsManager实例
"""
global _tools_managers
# 使用用户ID作为缓存键
cache_key = f"{user_id}_{session_id}"
if cache_key not in _tools_managers:
_tools_managers[cache_key] = MoviePilotToolsManager(
user_id=user_id,
session_id=session_id
)
return _tools_managers[cache_key]
@router.get("/tools", summary="列出所有可用工具", response_model=List[Dict[str, Any]])
async def list_tools(
_: Annotated[str, Depends(verify_apikey)]
) -> Any:
"""
获取所有可用的工具列表
返回每个工具的名称、描述和参数定义
"""
try:
manager = get_tools_manager()
# 获取所有工具定义
tools = manager.list_tools()
# 转换为字典格式
tools_list = []
for tool in tools:
tool_dict = {
"name": tool.name,
"description": tool.description,
"inputSchema": tool.input_schema
}
tools_list.append(tool_dict)
return tools_list
except Exception as e:
logger.error(f"获取工具列表失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"获取工具列表失败: {str(e)}")
@router.post("/tools/call", summary="调用工具", response_model=schemas.ToolCallResponse)
async def call_tool(
request: schemas.ToolCallRequest,
) -> Any:
"""
调用指定的工具
Returns:
工具执行结果
"""
try:
# 使用当前用户ID创建管理器实例
manager = get_tools_manager()
# 调用工具
result_text = await manager.call_tool(request.tool_name, request.arguments)
return schemas.ToolCallResponse(
success=True,
result=result_text
)
except Exception as e:
logger.error(f"调用工具 {request.tool_name} 失败: {e}", exc_info=True)
return schemas.ToolCallResponse(
success=False,
error=f"调用工具失败: {str(e)}"
)
@router.get("/tools/{tool_name}", summary="获取工具详情", response_model=Dict[str, Any])
async def get_tool_info(
tool_name: str,
_: Annotated[str, Depends(verify_apikey)]
) -> Any:
"""
获取指定工具的详细信息
Returns:
工具的详细信息,包括名称、描述和参数定义
"""
try:
manager = get_tools_manager()
# 获取所有工具
tools = manager.list_tools()
# 查找指定工具
for tool in tools:
if tool.name == tool_name:
return {
"name": tool.name,
"description": tool.description,
"inputSchema": tool.input_schema
}
raise HTTPException(status_code=404, detail=f"工具 '{tool_name}' 未找到")
except HTTPException:
raise
except Exception as e:
logger.error(f"获取工具信息失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"获取工具信息失败: {str(e)}")
@router.get("/tools/{tool_name}/schema", summary="获取工具参数Schema", response_model=Dict[str, Any])
async def get_tool_schema(
tool_name: str,
_: Annotated[str, Depends(verify_apikey)]
) -> Any:
"""
获取指定工具的参数SchemaJSON Schema格式
Returns:
工具的JSON Schema定义
"""
try:
manager = get_tools_manager()
# 获取所有工具
tools = manager.list_tools()
# 查找指定工具
for tool in tools:
if tool.name == tool_name:
return tool.input_schema
raise HTTPException(status_code=404, detail=f"工具 '{tool_name}' 未找到")
except HTTPException:
raise
except Exception as e:
logger.error(f"获取工具Schema失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"获取工具Schema失败: {str(e)}")

View File

@@ -22,3 +22,5 @@ from .token import *
from .transfer import * from .transfer import *
from .user import * from .user import *
from .workflow import * from .workflow import *
from .mcp import *

16
app/schemas/mcp.py Normal file
View File

@@ -0,0 +1,16 @@
from typing import Any, Dict, Optional
from pydantic import BaseModel, Field
class ToolCallRequest(BaseModel):
"""工具调用请求模型"""
tool_name: str = Field(..., description="工具名称")
arguments: Dict[str, Any] = Field(default_factory=dict, description="工具参数")
class ToolCallResponse(BaseModel):
"""工具调用响应模型"""
success: bool = Field(..., description="是否成功")
result: Optional[str] = Field(None, description="工具执行结果")
error: Optional[str] = Field(None, description="错误信息")

291
docs/mcp-api.md Normal file
View File

@@ -0,0 +1,291 @@
# MoviePilot 工具API文档
MoviePilot的智能体工具已通过HTTP API暴露可以通过RESTful API调用所有工具。
## API端点
所有工具相关的API端点都在 `/api/v1/mcp` 路径下(保持向后兼容)。
### 1. 列出所有工具
**GET** `/api/v1/mcp/tools`
获取所有可用的MCP工具列表。
**认证**: 需要API KEY?api_key=MoviePilot设置中的API Key
**响应示例**:
```json
[
{
"name": "add_subscribe",
"description": "Add media subscription to create automated download rules...",
"inputSchema": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The title of the media to subscribe to"
},
"year": {
"type": "string",
"description": "Release year of the media"
},
...
},
"required": ["title", "year", "media_type"]
}
},
...
]
```
### 2. 调用工具
**POST** `/api/v1/mcp/tools/call`
调用指定的MCP工具。
**认证**: 需要Bearer Token
**请求体**:
```json
{
"tool_name": "add_subscribe",
"arguments": {
"title": "流浪地球",
"year": "2019",
"media_type": "电影"
}
}
```
**响应示例**:
```json
{
"success": true,
"result": "成功添加订阅:流浪地球 (2019)",
"error": null
}
```
**错误响应示例**:
```json
{
"success": false,
"result": null,
"error": "调用工具失败: 参数验证失败"
}
```
### 3. 获取工具详情
**GET** `/api/v1/mcp/tools/{tool_name}`
获取指定工具的详细信息。
**认证**: 需要Bearer Token
**路径参数**:
- `tool_name`: 工具名称
**响应示例**:
```json
{
"name": "add_subscribe",
"description": "Add media subscription to create automated download rules...",
"inputSchema": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The title of the media to subscribe to"
},
...
},
"required": ["title", "year", "media_type"]
}
}
```
### 4. 获取工具参数Schema
**GET** `/api/v1/mcp/tools/{tool_name}/schema`
获取指定工具的参数SchemaJSON Schema格式
**认证**: 需要Bearer Token
**路径参数**:
- `tool_name`: 工具名称
**响应示例**:
```json
{
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The title of the media to subscribe to"
},
"year": {
"type": "string",
"description": "Release year of the media"
},
...
},
"required": ["title", "year", "media_type"]
}
```
## 使用示例
### 使用curl调用工具
```bash
# 1. 获取访问令牌通过登录API
TOKEN=$(curl -X POST "http://localhost:3001/api/v1/login/access-token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=admin&password=your_password" | jq -r '.access_token')
# 2. 列出所有工具
curl -X GET "http://localhost:3001/api/v1/mcp/tools" \
-H "Authorization: Bearer $TOKEN"
# 3. 调用工具
curl -X POST "http://localhost:3001/api/v1/mcp/tools/call" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tool_name": "query_subscribes",
"arguments": {
"status": "all",
"media_type": "all"
}
}'
# 4. 获取工具详情
curl -X GET "http://localhost:3001/api/v1/mcp/tools/add_subscribe" \
-H "Authorization: Bearer $TOKEN"
```
### 使用Python调用
```python
import requests
# 配置
BASE_URL = "http://localhost:3001/api/v1"
TOKEN = "your_access_token"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
# 1. 列出所有工具
response = requests.get(f"{BASE_URL}/mcp/tools", headers=HEADERS)
tools = response.json()
print(f"可用工具数量: {len(tools)}")
# 2. 调用工具
tool_call = {
"tool_name": "add_subscribe",
"arguments": {
"title": "流浪地球",
"year": "2019",
"media_type": "电影"
}
}
response = requests.post(
f"{BASE_URL}/mcp/tools/call",
headers=HEADERS,
json=tool_call
)
result = response.json()
print(f"执行结果: {result['result']}")
# 3. 获取工具Schema
response = requests.get(
f"{BASE_URL}/mcp/tools/add_subscribe/schema",
headers=HEADERS
)
schema = response.json()
print(f"工具Schema: {schema}")
```
### 使用JavaScript/TypeScript调用
```typescript
const BASE_URL = 'http://localhost:3001/api/v1';
const TOKEN = 'your_access_token';
// 列出所有工具
async function listTools() {
const response = await fetch(`${BASE_URL}/mcp/tools`, {
headers: {
'Authorization': `Bearer ${TOKEN}`
}
});
return await response.json();
}
// 调用工具
async function callTool(toolName: string, arguments: Record<string, any>) {
const response = await fetch(`${BASE_URL}/mcp/tools/call`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
tool_name: toolName,
arguments: arguments
})
});
return await response.json();
}
// 使用示例
const result = await callTool('query_subscribes', {
status: 'all',
media_type: 'all'
});
console.log(result);
```
## 认证
所有MCP API端点都需要认证。支持以下认证方式
1. **Bearer Token**: 在请求头中添加 `Authorization: Bearer <token>`
2. **API Key**: 在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>`
获取Token的方式
- 通过登录API: `POST /api/v1/login/access-token`
- 通过API Key: 在系统设置中生成API Key
## 错误处理
API会返回标准的HTTP状态码
- `200 OK`: 请求成功
- `400 Bad Request`: 请求参数错误
- `401 Unauthorized`: 未认证或Token无效
- `404 Not Found`: 工具不存在
- `500 Internal Server Error`: 服务器内部错误
错误响应格式:
```json
{
"detail": "错误描述信息"
}
```
## 架构说明
工具API通过FastAPI端点暴露使用HTTP协议与客户端通信。所有工具共享相同的实现确保功能一致性。
## 注意事项
1. **用户上下文**: API调用会使用当前认证用户的ID作为工具执行的用户上下文
2. **会话隔离**: 每个API请求使用独立的会话ID
3. **参数验证**: 工具参数会根据JSON Schema进行验证
4. **错误日志**: 所有工具调用错误都会记录到MoviePilot日志系统

View File

@@ -87,4 +87,4 @@ langchain-openai==0.3.33
langchain-google-genai==2.0.10 langchain-google-genai==2.0.10
langchain-deepseek==0.1.4 langchain-deepseek==0.1.4
langchain-experimental==0.3.4 langchain-experimental==0.3.4
openai==1.108.2 openai~=2.8.1