feat: add agent MCP support

This commit is contained in:
jxxghp
2026-07-08 08:44:33 +08:00
parent 6f6ed998bb
commit b51971ee7d
10 changed files with 1044 additions and 2 deletions

View File

@@ -51,7 +51,9 @@ from app.agent.middleware.tool_selection import ToolSelectorMiddleware
from app.agent.middleware.usage import UsageMiddleware
from app.agent.prompt import prompt_manager
from app.agent.runtime import agent_runtime_manager
from app.agent.mcp import agent_mcp_manager
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.mcp import create_external_mcp_tools
from app.chain import ChainBase
from app.core.config import settings
from app.core.event import eventmanager
@@ -1041,6 +1043,7 @@ class MoviePilotAgent:
settings.LLM_MAX_ITERATIONS,
self._public_runtime_config_signature(runtime_config),
agent_runtime_manager.current_signature(),
agent_mcp_manager.config_signature(),
)
def _get_cached_agent(
@@ -1097,6 +1100,39 @@ class MoviePilotAgent:
allow_message_tools=False,
)
async def _initialize_mcp_tools(self) -> List:
"""
初始化外部 MCP 工具列表。
"""
return await create_external_mcp_tools(
session_id=self.session_id,
user_id=self.user_id,
channel=self.channel,
source=self.source,
username=self.username,
stream_handler=self.stream_handler,
agent_context=self._tool_context,
)
async def _initialize_subagent_mcp_tools(self) -> List:
"""
初始化子代理可用的外部 MCP 工具列表。
"""
return await create_external_mcp_tools(
session_id=self.session_id,
user_id=self.user_id,
channel=self.channel,
source=self.source,
username=self.username,
stream_handler=None,
agent_context={
"user_reply_sent": False,
"reply_mode": None,
"should_dispatch_reply": False,
"is_admin": bool(self._tool_context.get("is_admin")),
},
)
async def _create_agent(self, streaming: bool = False):
"""
创建 LangGraph Agent使用 create_agent + SummarizationMiddleware
@@ -1126,6 +1162,7 @@ class MoviePilotAgent:
# 工具列表
tools = self._initialize_tools()
tools.extend(await self._initialize_mcp_tools())
skills_middleware = SkillsMiddleware(
sources=[str(agent_runtime_manager.skills_dir)],
bundled_skills_dir=str(settings.ROOT_PATH / "skills"),
@@ -1142,9 +1179,11 @@ class MoviePilotAgent:
activity_log_tools = list(
getattr(activity_log_middleware, "tools", []) or []
)
subagent_tools = self._initialize_subagent_tools()
subagent_tools.extend(await self._initialize_subagent_mcp_tools())
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
model=non_streaming_model,
tools=self._initialize_subagent_tools(),
tools=subagent_tools,
stream_handler=self.stream_handler,
)
max_tools = settings.LLM_MAX_TOOLS

600
app/agent/mcp.py Normal file
View File

@@ -0,0 +1,600 @@
"""Agent 外部 MCP 客户端与配置管理。"""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import re
import uuid
from dataclasses import dataclass
from typing import Any, Optional
from urllib.parse import urljoin
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.agent import (
AgentMcpServerConfig,
AgentMcpServerTestResult,
AgentMcpServerToolInfo,
)
from app.schemas.types import SystemConfigKey
from app.utils.http import AsyncRequestUtils
MCP_PROTOCOL_VERSION = "2025-11-25"
MCP_CLIENT_NAME = "MoviePilot Agent"
DEFAULT_MCP_TIMEOUT = 30
MCP_TOOL_NAME_PATTERN = re.compile(r"[^a-zA-Z0-9_]+")
@dataclass(frozen=True)
class AgentMcpToolSpec:
"""已发现的外部 MCP 工具定义。"""
server: AgentMcpServerConfig
name: str
agent_tool_name: str
description: str
input_schema: dict[str, Any]
def _normalize_identifier(value: str, fallback: str = "mcp") -> str:
"""把服务器或工具名称转换为 Agent 工具可用的标识片段。"""
normalized = MCP_TOOL_NAME_PATTERN.sub("_", str(value or "").strip())
normalized = re.sub(r"_+", "_", normalized).strip("_").lower()
if not normalized:
normalized = fallback
if normalized[0].isdigit():
normalized = f"{fallback}_{normalized}"
return normalized[:64]
def _normalize_timeout(value: Any) -> int:
"""规范化 MCP 连接和调用超时时间。"""
try:
timeout = int(value or DEFAULT_MCP_TIMEOUT)
except (TypeError, ValueError):
timeout = DEFAULT_MCP_TIMEOUT
return min(max(timeout, 1), 600)
def _normalize_string_dict(value: Any) -> dict[str, str]:
"""规范化请求头和环境变量字典,移除空键。"""
if not isinstance(value, dict):
return {}
normalized: dict[str, str] = {}
for key, item in value.items():
normalized_key = str(key or "").strip()
if not normalized_key:
continue
normalized[normalized_key] = str(item or "")
return normalized
def _normalize_input_schema(value: Any) -> dict[str, Any]:
"""规范化 MCP 工具参数 Schema保证至少是 object schema。"""
if not isinstance(value, dict):
return {"type": "object", "properties": {}, "required": []}
schema = dict(value)
schema.setdefault("type", "object")
schema.setdefault("properties", {})
schema.setdefault("required", [])
return schema
def _build_agent_tool_name(server: AgentMcpServerConfig, tool_name: str) -> str:
"""构造注入 Agent 的外部 MCP 工具名。"""
prefix = server.tool_prefix or f"mcp_{server.name or server.id}"
normalized_prefix = _normalize_identifier(prefix, fallback="mcp")
normalized_tool_name = _normalize_identifier(tool_name, fallback="tool")
if normalized_tool_name.startswith(f"{normalized_prefix}_"):
return normalized_tool_name
return f"{normalized_prefix}_{normalized_tool_name}"[:128]
def _jsonrpc_message(method: str, params: Optional[dict[str, Any]] = None, *, request_id: Optional[str] = None) -> dict:
"""构造 JSON-RPC 2.0 消息。"""
payload = {"jsonrpc": "2.0", "method": method}
if request_id is not None:
payload["id"] = request_id
if params is not None:
payload["params"] = params
return payload
def _raise_for_jsonrpc_error(payload: Any) -> None:
"""检查 JSON-RPC 响应错误并转换为运行时异常。"""
if isinstance(payload, dict) and payload.get("error"):
error = payload["error"]
if isinstance(error, dict):
message = error.get("message") or error
else:
message = error
raise RuntimeError(f"MCP JSON-RPC 错误: {message}")
def _extract_jsonrpc_result(payload: Any, request_id: str) -> Any:
"""从 JSON-RPC 响应中提取 result 字段。"""
if not isinstance(payload, dict):
raise RuntimeError("MCP 响应不是有效 JSON 对象")
if payload.get("id") != request_id:
raise RuntimeError("MCP 响应 ID 与请求不匹配")
_raise_for_jsonrpc_error(payload)
return payload.get("result")
async def _iter_sse_events(response) -> Any:
"""按 SSE 事件格式迭代响应流。"""
event_name = "message"
data_lines: list[str] = []
async for raw_line in response.aiter_lines():
line = raw_line.rstrip("\r")
if not line:
if data_lines:
yield {"event": event_name, "data": "\n".join(data_lines)}
event_name = "message"
data_lines = []
continue
if line.startswith(":"):
continue
field, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:]
if field == "event":
event_name = value or "message"
elif field == "data":
data_lines.append(value)
if data_lines:
yield {"event": event_name, "data": "\n".join(data_lines)}
def _parse_sse_text_response(text: str, request_id: str) -> Any:
"""从非流式 SSE 文本响应中提取匹配请求的 JSON-RPC 结果。"""
event_name = "message"
data_lines: list[str] = []
for raw_line in str(text or "").splitlines():
line = raw_line.rstrip("\r")
if not line:
if data_lines:
payload = _load_sse_json_payload(event_name, "\n".join(data_lines))
if isinstance(payload, dict) and payload.get("id") == request_id:
return _extract_jsonrpc_result(payload, request_id)
event_name = "message"
data_lines = []
continue
if line.startswith(":"):
continue
field, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:]
if field == "event":
event_name = value or "message"
elif field == "data":
data_lines.append(value)
if data_lines:
payload = _load_sse_json_payload(event_name, "\n".join(data_lines))
if isinstance(payload, dict) and payload.get("id") == request_id:
return _extract_jsonrpc_result(payload, request_id)
raise RuntimeError("MCP SSE 响应中未找到匹配请求")
def _load_sse_json_payload(event_name: str, data: str) -> Optional[dict]:
"""解析 SSE data 中的 JSON-RPC 消息。"""
if event_name not in {"message", "messages"}:
return None
try:
payload = json.loads(data)
except (TypeError, ValueError):
return None
return payload if isinstance(payload, dict) else None
class _StdioMcpSession:
"""stdio MCP 会话,按一次操作生命周期启动外部进程。"""
def __init__(self, server: AgentMcpServerConfig) -> None:
self.server = server
self.process: Optional[asyncio.subprocess.Process] = None
self.stderr_task: Optional[asyncio.Task] = None
async def __aenter__(self) -> "_StdioMcpSession":
"""启动 stdio MCP 子进程。"""
if not self.server.command:
raise RuntimeError("stdio MCP 服务器缺少启动命令")
env = os.environ.copy()
env.update(self.server.env or {})
self.process = await asyncio.create_subprocess_exec(
self.server.command,
*(self.server.args or []),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
self.stderr_task = asyncio.create_task(self._drain_stderr())
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
"""结束 stdio MCP 子进程。"""
if self.stderr_task:
self.stderr_task.cancel()
if not self.process:
return
if self.process.returncode is None:
self.process.terminate()
try:
await asyncio.wait_for(self.process.wait(), timeout=2)
except asyncio.TimeoutError:
self.process.kill()
await self.process.wait()
async def _drain_stderr(self) -> None:
"""持续读取子进程 stderr避免缓冲区阻塞。"""
if not self.process or not self.process.stderr:
return
try:
while True:
line = await self.process.stderr.readline()
if not line:
break
logger.debug(f"MCP stdio[{self.server.name}] stderr: {line.decode(errors='replace').strip()}")
except asyncio.CancelledError:
return
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
"""发送不需要响应的 JSON-RPC 通知。"""
await self._write_json(_jsonrpc_message(method, params))
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
"""发送 JSON-RPC 请求并等待响应。"""
request_id = uuid.uuid4().hex
await self._write_json(_jsonrpc_message(method, params, request_id=request_id))
while True:
payload = await self._read_json()
if payload.get("id") == request_id:
return _extract_jsonrpc_result(payload, request_id)
async def _write_json(self, payload: dict) -> None:
"""写入一行 JSON-RPC 消息。"""
if not self.process or not self.process.stdin:
raise RuntimeError("stdio MCP 进程未启动")
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
self.process.stdin.write(data.encode("utf-8"))
await self.process.stdin.drain()
async def _read_json(self) -> dict:
"""从 stdout 读取一行 JSON-RPC 消息。"""
if not self.process or not self.process.stdout:
raise RuntimeError("stdio MCP 进程未启动")
timeout = _normalize_timeout(self.server.timeout)
while True:
line = await asyncio.wait_for(self.process.stdout.readline(), timeout=timeout)
if not line:
raise RuntimeError("stdio MCP 进程已退出")
try:
payload = json.loads(line.decode("utf-8"))
except ValueError:
logger.debug(f"忽略非 JSON MCP stdout 行: {line!r}")
continue
if isinstance(payload, dict):
return payload
class _HttpMcpSession:
"""Streamable HTTP MCP 会话。"""
def __init__(self, server: AgentMcpServerConfig) -> None:
self.server = server
self.session_id: Optional[str] = None
async def __aenter__(self) -> "_HttpMcpSession":
"""进入 HTTP MCP 会话。"""
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
"""退出 HTTP MCP 会话。"""
return None
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
"""发送不需要响应的 JSON-RPC 通知。"""
await self._post(_jsonrpc_message(method, params), expect_response=False)
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
"""发送 JSON-RPC 请求并等待响应。"""
request_id = uuid.uuid4().hex
return await self._post(
_jsonrpc_message(method, params, request_id=request_id),
expect_response=True,
request_id=request_id,
)
async def _post(
self,
payload: dict,
*,
expect_response: bool,
request_id: Optional[str] = None,
) -> Any:
"""向 Streamable HTTP MCP 服务发送一条 JSON-RPC 消息。"""
if not self.server.url:
raise RuntimeError("HTTP MCP 服务器缺少 URL")
headers = {
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
**(self.server.headers or {}),
}
if self.session_id:
headers["Mcp-Session-Id"] = self.session_id
response = await AsyncRequestUtils(
headers=headers,
timeout=_normalize_timeout(self.server.timeout),
content_type="application/json",
accept_type="application/json, text/event-stream",
http2=False,
).post_res(self.server.url, json=payload, raise_exception=True)
try:
if not response:
raise RuntimeError("HTTP MCP 请求无响应")
response.raise_for_status()
session_id = response.headers.get("Mcp-Session-Id")
if session_id:
self.session_id = session_id
if not expect_response:
return None
content_type = response.headers.get("content-type", "").lower()
if "text/event-stream" in content_type:
return _parse_sse_text_response(response.text, request_id or "")
data = response.json()
return _extract_jsonrpc_result(data, request_id or "")
finally:
if response is not None:
await response.aclose()
class _SseMcpSession:
"""旧版 HTTP+SSE MCP 会话。"""
def __init__(self, server: AgentMcpServerConfig) -> None:
self.server = server
self.response = None
self.endpoint: Optional[str] = None
self._stream_manager = None
self._event_iterator = None
async def __aenter__(self) -> "_SseMcpSession":
"""打开 SSE 流并读取服务端回传的 POST endpoint。"""
if not self.server.url:
raise RuntimeError("SSE MCP 服务器缺少 URL")
self._stream_manager = AsyncRequestUtils(
headers={"Accept": "text/event-stream", **(self.server.headers or {})},
timeout=_normalize_timeout(self.server.timeout),
accept_type="text/event-stream",
http2=False,
).get_stream(self.server.url, raise_exception=True)
self.response = await self._stream_manager.__aenter__()
if not self.response:
raise RuntimeError("SSE MCP 连接无响应")
self.response.raise_for_status()
self._event_iterator = _iter_sse_events(self.response).__aiter__()
self.endpoint = await self._read_endpoint()
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
"""关闭 SSE 流。"""
if self._stream_manager:
await self._stream_manager.__aexit__(exc_type, exc, tb)
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
"""发送不需要响应的 JSON-RPC 通知。"""
await self._post(_jsonrpc_message(method, params))
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
"""发送 JSON-RPC 请求并等待 SSE 流上的响应。"""
request_id = uuid.uuid4().hex
await self._post(_jsonrpc_message(method, params, request_id=request_id))
timeout = _normalize_timeout(self.server.timeout)
while True:
event = await asyncio.wait_for(self._event_iterator.__anext__(), timeout=timeout)
payload = _load_sse_json_payload(event.get("event", ""), event.get("data", ""))
if isinstance(payload, dict) and payload.get("id") == request_id:
return _extract_jsonrpc_result(payload, request_id)
async def _read_endpoint(self) -> str:
"""读取 SSE endpoint 事件中的 POST 地址。"""
timeout = _normalize_timeout(self.server.timeout)
while True:
event = await asyncio.wait_for(self._event_iterator.__anext__(), timeout=timeout)
if event.get("event") != "endpoint":
continue
endpoint = str(event.get("data") or "").strip()
if not endpoint:
continue
return urljoin(self.server.url, endpoint)
async def _post(self, payload: dict) -> None:
"""向 SSE 握手返回的 endpoint 发送 JSON-RPC 消息。"""
if not self.endpoint:
raise RuntimeError("SSE MCP endpoint 未初始化")
response = await AsyncRequestUtils(
headers={
"Accept": "application/json",
"Content-Type": "application/json",
**(self.server.headers or {}),
},
timeout=_normalize_timeout(self.server.timeout),
content_type="application/json",
accept_type="application/json",
http2=False,
).post_res(self.endpoint, json=payload, raise_exception=True)
try:
if not response:
raise RuntimeError("SSE MCP POST 请求无响应")
response.raise_for_status()
finally:
if response is not None:
await response.aclose()
async def _open_mcp_session(server: AgentMcpServerConfig):
"""根据配置创建对应的 MCP 传输会话。"""
transport = "http" if server.transport == "streamable_http" else server.transport
if transport == "stdio":
return _StdioMcpSession(server)
if transport == "sse":
return _SseMcpSession(server)
if transport == "http":
return _HttpMcpSession(server)
raise RuntimeError(f"不支持的 MCP 传输协议: {server.transport}")
class AgentMcpManager:
"""管理 Agent 外部 MCP 服务器配置、工具发现和工具调用。"""
def get_servers(self) -> list[AgentMcpServerConfig]:
"""读取已保存的外部 MCP 服务器配置。"""
raw_servers = SystemConfigOper().get(SystemConfigKey.AIAgentMcpServers) or []
if not isinstance(raw_servers, list):
return []
servers: list[AgentMcpServerConfig] = []
for raw_server in raw_servers:
try:
servers.append(self.normalize_server(raw_server))
except Exception as err:
logger.warning(f"忽略无效的 Agent MCP 配置: {err}")
return servers
async def save_servers(self, servers: list[AgentMcpServerConfig]) -> bool:
"""保存外部 MCP 服务器配置。"""
normalized_servers = [self.normalize_server(server).model_dump() for server in servers]
return await SystemConfigOper().async_set(
SystemConfigKey.AIAgentMcpServers,
normalized_servers or None,
)
def normalize_server(self, value: Any) -> AgentMcpServerConfig:
"""规范化单个 MCP 服务器配置。"""
if isinstance(value, AgentMcpServerConfig):
raw_server = value.model_dump()
elif isinstance(value, dict):
raw_server = dict(value)
else:
raise ValueError("MCP 服务器配置必须是对象")
raw_server["id"] = str(raw_server.get("id") or uuid.uuid4().hex[:12]).strip()
raw_server["name"] = str(raw_server.get("name") or raw_server["id"]).strip()
raw_server["transport"] = str(raw_server.get("transport") or "stdio").strip()
raw_server["description"] = str(raw_server.get("description") or "").strip() or None
raw_server["command"] = str(raw_server.get("command") or "").strip() or None
raw_server["args"] = [str(item) for item in raw_server.get("args") or []]
raw_server["env"] = _normalize_string_dict(raw_server.get("env"))
raw_server["url"] = str(raw_server.get("url") or "").strip() or None
raw_server["headers"] = _normalize_string_dict(raw_server.get("headers"))
raw_server["timeout"] = _normalize_timeout(raw_server.get("timeout"))
raw_server["tool_prefix"] = str(raw_server.get("tool_prefix") or "").strip() or None
raw_server["require_admin"] = bool(raw_server.get("require_admin", True))
return AgentMcpServerConfig.model_validate(raw_server)
def config_signature(self) -> str:
"""生成外部 MCP 配置签名,用于 Agent 图缓存失效。"""
payload = [server.model_dump() for server in self.get_servers()]
raw_text = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)
return hashlib.sha256(raw_text.encode("utf-8")).hexdigest()
async def initialize_session(self, session) -> None:
"""完成 MCP initialize 和 initialized 通知流程。"""
await session.request(
"initialize",
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {
"name": MCP_CLIENT_NAME,
"version": "1.0.0",
},
},
)
await session.notify("notifications/initialized")
async def list_server_tools(self, server: AgentMcpServerConfig) -> list[AgentMcpToolSpec]:
"""连接单个 MCP 服务器并读取工具列表。"""
normalized_server = self.normalize_server(server)
session_manager = await _open_mcp_session(normalized_server)
async with session_manager as session:
await self.initialize_session(session)
result = await session.request("tools/list")
tools_payload = result.get("tools", []) if isinstance(result, dict) else []
tool_specs: list[AgentMcpToolSpec] = []
for item in tools_payload:
if not isinstance(item, dict) or not item.get("name"):
continue
tool_name = str(item["name"])
tool_specs.append(
AgentMcpToolSpec(
server=normalized_server,
name=tool_name,
agent_tool_name=_build_agent_tool_name(normalized_server, tool_name),
description=str(item.get("description") or ""),
input_schema=_normalize_input_schema(item.get("inputSchema")),
)
)
return tool_specs
async def list_enabled_tool_specs(self) -> list[AgentMcpToolSpec]:
"""读取所有启用 MCP 服务器暴露的工具定义。"""
tool_specs: list[AgentMcpToolSpec] = []
seen_names: set[str] = set()
for server in self.get_servers():
if not server.enabled:
continue
try:
for spec in await self.list_server_tools(server):
if spec.agent_tool_name in seen_names:
logger.warning(f"跳过重复的 MCP Agent 工具名: {spec.agent_tool_name}")
continue
tool_specs.append(spec)
seen_names.add(spec.agent_tool_name)
except Exception as err:
logger.warning(f"读取 MCP 服务器 {server.name} 工具失败: {err}")
return tool_specs
async def call_server_tool(
self,
server: AgentMcpServerConfig,
tool_name: str,
arguments: Optional[dict[str, Any]] = None,
) -> Any:
"""调用单个 MCP 服务器上的指定工具。"""
normalized_server = self.normalize_server(server)
session_manager = await _open_mcp_session(normalized_server)
async with session_manager as session:
await self.initialize_session(session)
return await session.request(
"tools/call",
{
"name": tool_name,
"arguments": arguments or {},
},
)
async def test_server(self, server: AgentMcpServerConfig) -> AgentMcpServerTestResult:
"""测试 MCP 服务器连接并返回工具列表。"""
tool_specs = await self.list_server_tools(server)
tools = [
AgentMcpServerToolInfo(
name=spec.name,
agent_tool_name=spec.agent_tool_name,
description=spec.description,
input_schema=spec.input_schema,
)
for spec in tool_specs
]
return AgentMcpServerTestResult(
success=True,
message=f"连接成功,发现 {len(tools)} 个工具",
tools=tools,
tool_count=len(tools),
)
agent_mcp_manager = AgentMcpManager()

View File

@@ -59,6 +59,10 @@ SYSTEMCONFIG_SETTING_METADATA = {
"group": "ai_agent",
"label": "AI 智能体配置",
},
SystemConfigKey.AIAgentMcpServers.value: {
"group": "ai_agent",
"label": "AI 智能体外部 MCP 服务器",
},
SystemConfigKey.CustomIdentifiers.value: {
"group": "custom_identifiers",
"label": "自定义识别词",

View File

@@ -0,0 +1,98 @@
"""外部 MCP 工具适配器。"""
import json
from typing import Any, Optional
from pydantic import PrivateAttr
from app.agent.mcp import AgentMcpToolSpec, agent_mcp_manager
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
class McpExternalTool(MoviePilotTool):
"""将外部 MCP 工具包装为 MoviePilot Agent 工具。"""
name: str = "mcp_external_tool"
tags: list[str] = [
ToolTag.Read,
ToolTag.Admin,
]
description: str = "Call an external MCP tool configured for MoviePilot Agent."
args_schema: dict[str, Any] = {"type": "object", "properties": {}, "required": []}
require_admin: bool = True
_spec: AgentMcpToolSpec = PrivateAttr()
def __init__(self, spec: AgentMcpToolSpec, session_id: str, user_id: str) -> None:
super().__init__(
session_id=session_id,
user_id=user_id,
name=spec.agent_tool_name,
description=spec.description
or f"Call external MCP tool {spec.name} on {spec.server.name}.",
args_schema=spec.input_schema,
require_admin=spec.server.require_admin,
)
self._spec = spec
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据 MCP 工具信息生成友好的提示消息。"""
return f"调用 MCP 工具: {self._spec.server.name}/{self._spec.name}"
async def run(self, **kwargs) -> str:
"""
调用外部 MCP 工具。
:param kwargs: 传递给外部 MCP 工具的参数
:return: MCP 工具返回内容
"""
result = await agent_mcp_manager.call_server_tool(
server=self._spec.server,
tool_name=self._spec.name,
arguments=kwargs,
)
return self._format_mcp_result(result)
@staticmethod
def _format_mcp_result(result: Any) -> str:
"""将 MCP tools/call 返回结构转换为 Agent 可读文本。"""
if isinstance(result, dict):
content = result.get("content")
if isinstance(content, list):
parts = []
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") == "text" and item.get("text") is not None:
parts.append(str(item["text"]))
elif item:
parts.append(json.dumps(item, ensure_ascii=False, default=str))
if parts:
return "\n".join(parts)
if result.get("isError"):
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
if isinstance(result, str):
return result
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
async def create_external_mcp_tools(
*,
session_id: str,
user_id: str,
channel: Optional[str] = None,
source: Optional[str] = None,
username: Optional[str] = None,
stream_handler=None,
agent_context: Optional[dict] = None,
) -> list[McpExternalTool]:
"""创建当前已启用的外部 MCP Agent 工具列表。"""
tools = []
for spec in await agent_mcp_manager.list_enabled_tool_specs():
tool = McpExternalTool(spec=spec, session_id=session_id, user_id=user_id)
tool.set_message_attr(channel=channel, source=source, username=username)
tool.set_stream_handler(stream_handler=stream_handler)
tool.set_agent_context(agent_context=agent_context)
tools.append(tool)
return tools

View File

@@ -20,6 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.agent import MoviePilotAgent, ReplyMode, StreamingHandler, agent_manager
from app.agent.llm.capability import AgentCapabilityManager
from app.agent.mcp import agent_mcp_manager
from app.chain.message import MessageChain
from app.chain.site import site_interaction_manager
from app.chain.skills import skills_interaction_manager
@@ -56,6 +57,78 @@ _WEB_AGENT_NOTICE_LISTENER_REGISTERED = False
_WEB_AGENT_BACKGROUND_TASKS: set[asyncio.Task] = set()
def _ensure_superuser(user: User) -> None:
"""校验当前用户是否为超级管理员。"""
if not getattr(user, "is_superuser", False):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
@router.get("/mcp/servers", summary="查询 Agent MCP 服务器配置", response_model=schemas.Response)
async def list_agent_mcp_servers(
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
"""
查询 Agent 外部 MCP 服务器配置。
"""
_ensure_superuser(current_user)
servers = agent_mcp_manager.get_servers()
enabled_count = len([server for server in servers if server.enabled])
return schemas.Response(
success=True,
data={
"servers": [server.model_dump() for server in servers],
"enabled_count": enabled_count,
"total_count": len(servers),
},
)
@router.post("/mcp/servers", summary="保存 Agent MCP 服务器配置", response_model=schemas.Response)
async def save_agent_mcp_servers(
request: schemas.AgentMcpServersSaveRequest,
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
"""
保存 Agent 外部 MCP 服务器配置。
"""
_ensure_superuser(current_user)
success = await agent_mcp_manager.save_servers(request.servers)
return schemas.Response(
success=success,
message="保存MCP配置成功" if success else "保存MCP配置失败",
)
@router.post("/mcp/servers/test", summary="测试 Agent MCP 服务器", response_model=schemas.Response)
async def test_agent_mcp_server(
request: schemas.AgentMcpServerTestRequest,
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
"""
测试 Agent 外部 MCP 服务器连接并读取工具列表。
"""
_ensure_superuser(current_user)
try:
result = await agent_mcp_manager.test_server(request.server)
return schemas.Response(
success=result.success,
message=result.message,
data=result.model_dump(),
)
except Exception as err:
logger.warning(f"测试 Agent MCP 服务器失败: {err}")
return schemas.Response(
success=False,
message=f"测试MCP服务器失败: {str(err)}",
data={
"success": False,
"message": str(err),
"tools": [],
"tool_count": 0,
},
)
class _WebAgentStreamingHandler(StreamingHandler):
"""
Web 前端专用流式处理器,将工具提示和文本统一回调给 SSE。

View File

@@ -1,7 +1,7 @@
"""AI智能体相关数据模型"""
from datetime import datetime
from typing import List, Optional, Union
from typing import Any, List, Literal, Optional, Union
from langchain_core.messages import BaseMessage
from pydantic import BaseModel, Field, ConfigDict, field_serializer
@@ -57,6 +57,58 @@ class ToolResult(BaseModel):
error: Optional[str] = Field(default=None, description="错误信息")
class AgentMcpServerConfig(BaseModel):
"""Agent 外部 MCP 服务器配置。"""
id: str = Field(..., description="服务器唯一 ID")
name: str = Field(..., description="服务器显示名称")
enabled: bool = Field(default=True, description="是否启用")
transport: Literal["stdio", "sse", "http", "streamable_http"] = Field(
default="stdio", description="MCP 传输协议"
)
description: Optional[str] = Field(None, description="服务器说明")
command: Optional[str] = Field(None, description="stdio 启动命令")
args: list[str] = Field(default_factory=list, description="stdio 启动参数")
env: dict[str, str] = Field(default_factory=dict, description="stdio 环境变量")
url: Optional[str] = Field(None, description="HTTP/SSE MCP 入口地址")
headers: dict[str, str] = Field(default_factory=dict, description="HTTP 请求头")
timeout: int = Field(default=30, description="连接和调用超时时间(秒)")
tool_prefix: Optional[str] = Field(None, description="注入 Agent 的工具名前缀")
require_admin: bool = Field(default=True, description="是否仅管理员可调用")
class AgentMcpServersSaveRequest(BaseModel):
"""Agent 外部 MCP 服务器保存请求。"""
servers: list[AgentMcpServerConfig] = Field(
default_factory=list, description="MCP 服务器配置列表"
)
class AgentMcpServerTestRequest(BaseModel):
"""Agent 外部 MCP 服务器测试请求。"""
server: AgentMcpServerConfig = Field(..., description="待测试的 MCP 服务器配置")
class AgentMcpServerToolInfo(BaseModel):
"""Agent 外部 MCP 工具摘要。"""
name: str = Field(..., description="原始 MCP 工具名称")
agent_tool_name: str = Field(..., description="注入 Agent 后的工具名称")
description: str = Field(default="", description="工具说明")
input_schema: dict[str, Any] = Field(default_factory=dict, description="工具参数 Schema")
class AgentMcpServerTestResult(BaseModel):
"""Agent 外部 MCP 服务器测试结果。"""
success: bool = Field(..., description="测试是否成功")
message: str = Field(default="", description="测试消息")
tools: list[AgentMcpServerToolInfo] = Field(default_factory=list, description="工具列表")
tool_count: int = Field(default=0, description="工具数量")
class AgentChatAttachment(BaseModel):
"""
Agent 会话展示附件。

View File

@@ -265,6 +265,8 @@ class SystemConfigKey(Enum):
NotificationSendTime = "NotificationSendTime"
# AI智能体配置
AIAgentConfig = "AIAgentConfig"
# AI智能体外部MCP服务器配置
AIAgentMcpServers = "AIAgentMcpServers"
# 通知消息格式模板
NotificationTemplates = "NotificationTemplates"
# 通知中心清理时间

View File

@@ -67,6 +67,26 @@ MCP 使用系统配置中的 `API_TOKEN` 作为认证密钥,文档中的 API K
}
```
## 4.1 Agent 外部 MCP Client 配置
MoviePilot 的内置 Agent 也可以作为 MCP Client 连接外部 MCP 服务器,将外部工具注入到智能助手工具列表中。当前支持:
- `stdio`:按配置的命令和参数启动本地 MCP 进程,通过标准输入输出交换 JSON-RPC 消息。
- `sse`:连接旧版 HTTP+SSE MCP 服务,先读取 `endpoint` 事件,再向返回的 endpoint POST JSON-RPC 消息。
- `http` / `streamable_http`:连接 Streamable HTTP MCP 服务,直接向配置 URL POST JSON-RPC 消息。
这些配置是管理员级 Agent 运行时配置,保存在 `SystemConfigKey.AIAgentMcpServers` 中。外部 MCP 工具默认要求管理员上下文调用,避免普通用户触发高权限外部工具。
### Agent MCP 配置接口
这些接口使用登录态鉴权,并要求当前用户为超级管理员。
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |
| GET | `/api/v1/message/agent/mcp/servers` | 查询已配置的外部 MCP 服务器列表 |
| POST | `/api/v1/message/agent/mcp/servers` | 保存外部 MCP 服务器列表 |
| POST | `/api/v1/message/agent/mcp/servers/test` | 测试单个外部 MCP 服务器,返回发现的工具列表 |
## 5. 错误码说明
| 错误码 | 消息 | 说明 |
@@ -133,6 +153,9 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返
| POST | `/api/v1/system/setting/PLUGIN_MARKET/sync-wiki` | 管理员从 MoviePilot Wiki 的插件文档同步公开插件仓库清单,和本地 `PLUGIN_MARKET` 合并去重后写入配置 |
| GET | `/api/v1/system/modulelist` | 查询已加载模块,保留 `name` 原始中文字段,并提供 `name_i18n``name_key` 给多语言前端展示 |
| GET | `/api/v1/system/moduletest/{moduleid}` | 测试指定模块可用性,保留原 `message`,并在标准响应顶层返回 `message_i18n` |
| GET | `/api/v1/message/agent/mcp/servers` | 管理员查询 Agent 外部 MCP 服务器配置 |
| POST | `/api/v1/message/agent/mcp/servers` | 管理员保存 Agent 外部 MCP 服务器配置 |
| POST | `/api/v1/message/agent/mcp/servers/test` | 管理员测试单个 Agent 外部 MCP 服务器并读取工具列表 |
### 插件补充接口

View File

@@ -470,6 +470,14 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| GET | `/api/v1/mcp/tools/{tool_name}` | Get tool definition |
| GET | `/api/v1/mcp/tools/{tool_name}/schema` | Get tool input schema |
### Agent MCP Client (3 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/message/agent/mcp/servers` | List external MCP servers configured for the built-in Agent. Superuser login required |
| POST | `/api/v1/message/agent/mcp/servers` | Save external MCP servers for the built-in Agent. Body: `{"servers":[...]}` |
| POST | `/api/v1/message/agent/mcp/servers/test` | Test one external MCP server and return discovered tools. Body: `{"server":{...}}` |
### Webhook (2 endpoints)
| Method | Path | Description |

143
tests/test_agent_mcp.py Normal file
View File

@@ -0,0 +1,143 @@
import sys
import textwrap
import pytest
from app.agent.mcp import AgentMcpManager, AgentMcpToolSpec
from app.agent.tools.impl.mcp import McpExternalTool
from app.schemas.agent import AgentMcpServerConfig
def _write_stdio_mcp_server(tmp_path):
"""写入一个用于测试的最小 stdio MCP 服务。"""
server_path = tmp_path / "stdio_mcp_server.py"
server_path.write_text(
textwrap.dedent(
"""
import json
import sys
TOOLS = [
{
"name": "echo",
"description": "Echo input text.",
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Text to echo"}
},
"required": ["text"],
},
}
]
for line in sys.stdin:
request = json.loads(line)
request_id = request.get("id")
method = request.get("method")
if request_id is None:
continue
if method == "initialize":
result = {
"protocolVersion": request["params"]["protocolVersion"],
"capabilities": {"tools": {}},
"serverInfo": {"name": "Fake MCP", "version": "1.0.0"},
}
elif method == "tools/list":
result = {"tools": TOOLS}
elif method == "tools/call":
args = request.get("params", {}).get("arguments", {})
result = {"content": [{"type": "text", "text": args.get("text", "")}]}
else:
result = {}
print(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}), flush=True)
"""
),
encoding="utf-8",
)
return server_path
@pytest.mark.anyio
async def test_stdio_mcp_server_lists_tools(tmp_path):
"""stdio MCP 服务器应能被初始化并读取工具列表。"""
server_path = _write_stdio_mcp_server(tmp_path)
manager = AgentMcpManager()
server = AgentMcpServerConfig(
id="fake",
name="Fake MCP",
transport="stdio",
command=sys.executable,
args=[str(server_path)],
timeout=5,
)
tools = await manager.list_server_tools(server)
assert len(tools) == 1
assert tools[0].name == "echo"
assert tools[0].agent_tool_name == "mcp_fake_mcp_echo"
assert tools[0].input_schema["properties"]["text"]["type"] == "string"
@pytest.mark.anyio
async def test_stdio_mcp_server_calls_tool(tmp_path):
"""stdio MCP 工具应能通过 tools/call 返回内容。"""
server_path = _write_stdio_mcp_server(tmp_path)
manager = AgentMcpManager()
server = AgentMcpServerConfig(
id="fake",
name="Fake MCP",
transport="stdio",
command=sys.executable,
args=[str(server_path)],
timeout=5,
)
result = await manager.call_server_tool(server, "echo", {"text": "hello"})
assert result == {"content": [{"type": "text", "text": "hello"}]}
def test_normalize_server_generates_runtime_defaults():
"""MCP 配置规范化应补齐默认值并清理空字段。"""
manager = AgentMcpManager()
server = manager.normalize_server(
{
"id": "demo",
"name": "Demo",
"transport": "http",
"url": " https://example.com/mcp ",
"headers": {" Authorization ": "Bearer token", "": "ignored"},
"timeout": "bad",
}
)
assert server.id == "demo"
assert server.url == "https://example.com/mcp"
assert server.headers == {"Authorization": "Bearer token"}
assert server.timeout == 30
assert server.require_admin is True
def test_mcp_external_tool_uses_discovered_schema():
"""外部 MCP 工具应保留发现到的 JSON Schema。"""
server = AgentMcpServerConfig(id="fake", name="Fake MCP")
spec = AgentMcpToolSpec(
server=server,
name="echo",
agent_tool_name="mcp_fake_echo",
description="Echo input text.",
input_schema={
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
)
tool = McpExternalTool(spec=spec, session_id="session-1", user_id="10001")
assert tool.name == "mcp_fake_echo"
assert tool.args_schema["properties"]["text"]["type"] == "string"
assert tool.require_admin is True