Files
MoviePilot/app/agent/tools/impl/service.py
JontyLee 0ba73f5b87 fix(database-operation): 拦截未显式授权的凭据配置表读取
systemconfig 按 SystemConfigKey 聚合实例级 API Key、Cookie 与口令,托管
config.system.get 默认脱敏,但数据库技能脚本与 MCP database_operation 接受
任意 SQL,一条 SELECT 就能整包取回明文。

现在由脚本本身按表拦截:引用 systemconfig 的语句(含引号标识符、模式限定名、
CTE 与子查询)在读写两种模式下都默认拒绝,只有显式 --show-secrets /
show_secrets=true 才放行,失败信息直接给出可执行的下一步,因此 MCP 工具、
技能调用与命令行入口共享同一道边界。宿主策略把开启该开关的查询判定为
SENSITIVE_READ:要求系统管理员确认,结果标记为 SECRET,回执摘要不再保留内容。

门禁范围收敛为单表:userconfig 只承载界面偏好,plugindata 是文档化的诊断路径,
其余表的凭据残留风险改由 SKILL.md 的窄投影要求约束,避免以不可绕过的方式破坏
既有可运维性。MCP 文档、技能说明与相关契约测试同步更新。
2026-10-26 20:20:16 +08:00

733 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
向 HTTP/MCP 管理入口暴露自描述的服务操作工具。
"""
from __future__ import annotations
import json
import runpy
import subprocess
import sys
from functools import lru_cache
from pathlib import Path
from typing import Any, ClassVar, Dict, Optional, Type, cast
from pydantic import BaseModel, ConfigDict, Field
from app.agent.shell import agent_text_subprocess_kwargs
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.runtime.settings import get_runtime_setting
from app.schemas.mediaserver import MediaServerQueryOptions
class DownloaderOperationInput(BaseModel):
"""
下载器操作工具的运行时输入模型。
"""
client: Optional[str] = Field(
default=None,
description="已配置下载器实例名称;省略时使用默认或唯一启用实例",
)
action: str = Field(
description="精确下载器动作,须从 MCP 输入 schema 选择对应分支",
)
arguments: Dict[str, Any] = Field(
default_factory=dict,
description="所选下载器动作声明的结构化参数",
)
model_config = ConfigDict(extra="forbid")
class MediaServerOperationInput(BaseModel):
"""
媒体服务器操作工具的运行时输入模型。
"""
server: Optional[str] = Field(
default=None,
description="已配置媒体服务器实例名称;省略时使用唯一启用实例",
)
action: str = Field(
description="精确媒体服务器动作,须从 MCP 输入 schema 选择对应分支",
)
arguments: Dict[str, Any] = Field(
default_factory=dict,
description="所选媒体服务器动作声明的结构化参数",
)
model_config = ConfigDict(extra="forbid")
class DatabaseOperationInput(BaseModel):
"""
数据库操作工具的运行时输入模型。
"""
action: str = Field(
description="精确数据库动作,须从 MCP 输入 schema 选择对应分支",
)
arguments: Dict[str, Any] = Field(
default_factory=dict,
description="所选数据库动作声明的结构化参数",
)
model_config = ConfigDict(extra="forbid")
@lru_cache(maxsize=3)
def _load_action_contracts(script_path: str) -> dict[str, Any]:
"""
从固定 Skill 脚本加载无配置副作用的 action 注册表。
"""
namespace = runpy.run_path(script_path)
actions = namespace.get("ACTIONS")
if not isinstance(actions, dict):
raise RuntimeError("服务操作脚本缺少 ACTIONS 合同")
return actions
def _compact_type_schema(declared_type: str) -> dict[str, Any]:
"""
把 Skill 的紧凑参数类型转换为标准 JSON Schema。
"""
variants: list[dict[str, Any]] = []
for candidate in declared_type.split("|"):
if candidate.endswith("[]"):
variants.append(
{
"type": "array",
"items": _compact_type_schema(candidate[:-2]),
}
)
else:
variants.append({"type": candidate})
return variants[0] if len(variants) == 1 else {"anyOf": variants}
def _argument_schema(argument: dict[str, Any]) -> dict[str, Any]:
"""
构建一个包含类型、说明、默认值和枚举的参数 schema。
"""
schema = _compact_type_schema(str(argument["type"]))
name = str(argument["name"])
if name == "query" and argument["type"] == "object":
schema = _media_query_schema()
schema["description"] = str(argument.get("description") or "")
if "default" in argument:
schema["default"] = argument["default"]
if argument.get("enum"):
schema["enum"] = list(argument["enum"])
if name == "offset":
schema["minimum"] = 0
if name == "limit":
schema.update({"minimum": 1, "maximum": 200})
if name in {"upload_limit", "download_limit", "ratio_limit", "seeding_time_limit"}:
schema["minimum"] = 0
if name in {
"task_id",
"item_id",
"content",
"location",
"category",
}:
schema["minLength"] = 1
if name in {
"task_ids",
"wanted_file_ids",
"unwanted_file_ids",
"tags",
"trackers",
"items",
}:
schema["minItems"] = 1
return schema
def _media_query_schema() -> dict[str, Any]:
"""
从统一投影模型生成内联参数结构,避免工具另维护一份字段列表。
:return: 不依赖外层 definitions 路径的投影 JSON Schema
"""
schema: dict[str, Any] = MediaServerQueryOptions.model_json_schema()
definitions = schema.pop("$defs")
for alternative in schema["properties"]["fields"]["anyOf"]:
if "items" in alternative:
alternative["items"] = definitions["MediaServerField"]
return schema
def _metadata_refresh_items_schema() -> dict[str, Any]:
"""
返回 metadata.refresh.items 的完整嵌套条目合同。
"""
return {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "用于媒体识别的标题。"},
"year": {
"anyOf": [{"type": "string"}, {"type": "integer"}],
"description": "发行或首映年份,用于区分同名媒体。",
},
"type": {
"type": "string",
"enum": ["电影", "电视剧", "音乐"],
"description": (
"MoviePilot 媒体类型的精确值:电影、电视剧或音乐。"
),
},
"category": {"type": "string", "description": "可选的 MoviePilot 媒体库分类。"},
"target_path": {
"type": "string",
"description": "需要刷新元数据的媒体文件或目录路径。",
},
},
"additionalProperties": False,
},
}
def _add_action_argument_rules(action: str, schema: dict[str, Any]) -> None:
"""
把跨字段约束补充为机器可校验的 JSON Schema。
"""
task_selector_actions = {
"tasks.start",
"tasks.stop",
"tasks.delete",
"tasks.recheck",
"tasks.reannounce",
"tasks.queue.move",
"tasks.force_start.set",
"tasks.tags.set",
}
if action in task_selector_actions:
schema["oneOf"] = [
{"required": ["task_id"], "not": {"required": ["task_ids"]}},
{"required": ["task_ids"], "not": {"required": ["task_id"]}},
]
if action == "tasks.files.selection.set":
schema["anyOf"] = [
{"required": ["wanted_file_ids"]},
{"required": ["unwanted_file_ids"]},
]
if action == "tasks.properties.set":
schema["anyOf"] = [
{"required": [name]}
for name in ("upload_limit", "download_limit", "ratio_limit", "seeding_time_limit")
]
if action == "session.speed_limits.set":
schema["anyOf"] = [
{"required": ["download_limit"]},
{"required": ["upload_limit"]},
]
if action == "items.music.search":
schema["anyOf"] = [
{"required": ["title"]},
{"required": ["artist"]},
{"required": ["album"]},
]
if action == "items.season_episodes":
schema["anyOf"] = [{"required": ["item_id"]}, {"required": ["title"]}]
if action in {"query", "write"}:
schema["oneOf"] = [
{"required": ["sql"], "not": {"required": ["file"]}},
{"required": ["file"], "not": {"required": ["sql"]}},
]
def _build_arguments_schema(action: str, spec: Any) -> dict[str, Any]:
"""
把指定 action 的脚本参数契约转换为 MCP 参数结构。
"""
contract = spec.to_dict(action)
properties = {
argument["name"]: _argument_schema(argument)
for argument in contract["arguments"]
}
if action == "metadata.refresh":
properties["items"] = _metadata_refresh_items_schema()
properties["items"]["description"] = contract["arguments"][0]["description"]
schema: dict[str, Any] = {
"type": "object",
"description": " ".join(contract.get("argument_rules") or []),
"properties": properties,
"required": list(contract.get("required_arguments") or []),
"additionalProperties": False,
}
_add_action_argument_rules(action, schema)
return schema
def _build_mcp_input_schema(
*,
actions: dict[str, Any],
selector_name: Optional[str],
selector_description: Optional[str],
title: str,
) -> dict[str, Any]:
"""
构建按 action 分支且可由外部 MCP Client 直接发现的输入合同。
"""
action_names = sorted(actions)
branches = []
for action in action_names:
contract = actions[action].to_dict(action)
properties = {
"action": {"type": "string", "const": action},
"arguments": _build_arguments_schema(action, actions[action]),
}
if selector_name:
properties[selector_name] = {
"type": "string",
"description": selector_description or "",
}
branches.append(
{
"type": "object",
"title": action,
"description": (
f"{contract['description']} 操作影响:{contract['effect']}。 "
f"支持的服务:{', '.join(contract['providers'])}。"
),
"properties": properties,
"required": ["action", "arguments"],
"additionalProperties": False,
}
)
properties = {
"action": {"type": "string", "enum": action_names},
"arguments": {
"type": "object",
"description": "arguments 必须匹配 action 选中的 oneOf 分支。",
},
}
if selector_name:
properties[selector_name] = {
"type": "string",
"description": selector_description or "",
}
return {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": title,
"type": "object",
"properties": properties,
"required": ["action", "arguments"],
"oneOf": branches,
}
def _parse_script_payload(stdout: str) -> dict[str, Any]:
"""
从可能带启动日志的 stdout 中提取最后一个完整 JSON 对象。
"""
decoder = json.JSONDecoder()
for index in range(len(stdout) - 1, -1, -1):
if stdout[index] != "{":
continue
try:
payload, end = decoder.raw_decode(stdout[index:])
except json.JSONDecodeError:
continue
if not stdout[index + end :].strip() and isinstance(payload, dict):
return payload
raise RuntimeError("服务操作脚本未返回有效 JSON")
def _run_service_script(
*,
relative_script: str,
selector_flag: Optional[str],
selector_value: Optional[str],
action: str,
arguments: Dict[str, Any],
) -> dict[str, Any]:
"""
不经 shell 调用固定 Skill 脚本,并只返回其结构化 JSON envelope。
"""
root_path = Path(get_runtime_setting("ROOT_PATH"))
script_path = root_path / relative_script
command = [
sys.executable,
str(script_path),
"call",
"--action",
action,
"--arguments",
json.dumps(arguments, ensure_ascii=False, separators=(",", ":")),
]
if selector_value and selector_flag:
command.extend([selector_flag, selector_value])
completed = subprocess.run(
command,
cwd=root_path,
capture_output=True,
text=True,
check=False,
**agent_text_subprocess_kwargs(),
)
return _parse_script_payload(completed.stdout)
class _ServiceOperationTool(MoviePilotTool):
"""
固定 Skill 脚本的 MCP-only 安全包装基类。
:cvar _relative_script: 相对于项目根目录的固定 Skill 脚本路径
:cvar _selector_name: 输入模型中的服务选择字段;不选择实例时为 None
:cvar _selector_flag: 传给固定脚本的实例选择选项;不选择实例时为 None
:cvar _blocking_bucket: 阻塞执行隔离桶的名称,用于限制同类操作并发
"""
require_admin: bool = Field(
default=True,
description="是否仅允许系统管理员调用",
)
tags: list[str] = Field(
default=[ToolTag.Admin],
description="工具的能力和权限分类标签,初始化时会统一归一化",
)
_relative_script: ClassVar[str]
_selector_name: ClassVar[Optional[str]]
_selector_flag: ClassVar[Optional[str]]
_blocking_bucket: ClassVar[str]
def get_mcp_input_schema(self) -> dict[str, Any]:
"""
返回保留 action 条件分支的完整 MCP JSON Schema。
"""
root_path = Path(get_runtime_setting("ROOT_PATH"))
actions = _load_action_contracts(str(root_path / self._relative_script))
selector_description = None
if self._selector_name:
selector_description = cast(type[BaseModel], self.get_input_schema()).model_json_schema()["properties"][self._selector_name][
"description"
]
return _build_mcp_input_schema(
actions=actions,
selector_name=self._selector_name,
selector_description=selector_description,
title=self.name,
)
async def _run_operation(
self,
*,
selector_value: Optional[str],
action: str,
arguments: Dict[str, Any],
) -> str:
"""
在线程池中运行固定脚本并序列化安全结果。
"""
payload = await self.run_blocking(
self._blocking_bucket,
_run_service_script,
relative_script=self._relative_script,
selector_flag=self._selector_flag,
selector_value=selector_value,
action=action,
arguments=arguments,
)
return json.dumps(payload, ensure_ascii=False, indent=2)
class DownloaderOperationTool(_ServiceOperationTool):
"""
外部 HTTP/MCP 调用下载器原生能力的结构化工具。
:cvar _relative_script: 相对于项目根目录的固定 Skill 脚本路径
:cvar _selector_name: 输入模型中的服务选择字段;不选择实例时为 None
:cvar _selector_flag: 传给固定脚本的实例选择选项;不选择实例时为 None
:cvar _blocking_bucket: 阻塞执行隔离桶的名称,用于限制同类操作并发
"""
name: str = Field(
default="downloader_operation",
description="供模型调用的稳定工具名称",
)
description: str = Field(
default=(
"操作已配置的 qBittorrent、Transmission 或 rTorrent 实例。"
"输入 schema 为每个动作提供精确分支,包含支持的服务、操作影响、必填字段、类型、默认值、枚举和跨字段约束。"
),
description="供模型理解工具用途、输入约束和调用边界的说明",
)
tags: list[str] = Field(
default=[ToolTag.Download],
description="工具的能力和权限分类标签,初始化时会统一归一化",
)
args_schema: Type[BaseModel] = Field(
default=DownloaderOperationInput,
description="工具输入参数的验证模型或 JSON Schema",
)
_relative_script: ClassVar[str] = "skills/downloader-operation/scripts/mp-downloader.py"
_selector_name: ClassVar[Optional[str]] = "client"
_selector_flag: ClassVar[Optional[str]] = "--client"
_blocking_bucket: ClassVar[str] = "downloader"
# 具体参数由 args_schema 验证,执行入口保留与工具基类的动态分发兼容。
async def run( # type: ignore[override]
self,
action: str,
arguments: Optional[Dict[str, Any]] = None,
client: Optional[str] = None,
) -> str:
"""
执行一次自描述的下载器操作。
"""
return await self._run_operation(
selector_value=client,
action=action,
arguments=arguments or {},
)
class MediaServerOperationTool(_ServiceOperationTool):
"""
外部 HTTP/MCP 调用媒体服务器原生能力的结构化工具。
:cvar _relative_script: 相对于项目根目录的固定 Skill 脚本路径
:cvar _selector_name: 输入模型中的服务选择字段;不选择实例时为 None
:cvar _selector_flag: 传给固定脚本的实例选择选项;不选择实例时为 None
:cvar _blocking_bucket: 阻塞执行隔离桶的名称,用于限制同类操作并发
"""
name: str = Field(
default="mediaserver_operation",
description="供模型调用的稳定工具名称",
)
description: str = Field(
default=(
"操作已配置的 Emby、Jellyfin、Plex、ZSpace、UGREEN、TrimeMedia、Navidrome 或 Vyo 服务。"
"输入 schema 为每个动作提供精确分支,包含支持的服务、操作影响、必填字段、类型、默认值、枚举、嵌套字段和跨字段约束。"
),
description="供模型理解工具用途、输入约束和调用边界的说明",
)
tags: list[str] = Field(
default=[ToolTag.Media],
description="工具的能力和权限分类标签,初始化时会统一归一化",
)
args_schema: Type[BaseModel] = Field(
default=MediaServerOperationInput,
description="工具输入参数的验证模型或 JSON Schema",
)
_relative_script: ClassVar[str] = "skills/mediaserver-operation/scripts/mp-mediaserver.py"
_selector_name: ClassVar[Optional[str]] = "server"
_selector_flag: ClassVar[Optional[str]] = "--server"
_blocking_bucket: ClassVar[str] = "mediaserver"
# 具体参数由 args_schema 验证,执行入口保留与工具基类的动态分发兼容。
async def run( # type: ignore[override]
self,
action: str,
arguments: Optional[Dict[str, Any]] = None,
server: Optional[str] = None,
) -> str:
"""
执行一次自描述的媒体服务器操作。
"""
return await self._run_operation(
selector_value=server,
action=action,
arguments=arguments or {},
)
def _validate_database_arguments(
action: str,
action_arguments: Dict[str, Any],
contract_data: Dict[str, Any],
) -> None:
"""
按动作合同校验数据库嵌套参数,在连接数据库前拒绝未知、缺失或类型错误的字段。
:param action: 当前数据库动作名称
:param action_arguments: 当前动作的结构化参数
:param contract_data: 当前动作的公开契约字典
"""
declared_arguments = {item["name"]: item for item in contract_data["arguments"]}
unknown_arguments = sorted(set(action_arguments) - set(declared_arguments))
if unknown_arguments:
raise ValueError(f"数据库 action 存在未知参数: {', '.join(unknown_arguments)}")
missing_arguments = [
name
for name in contract_data["required_arguments"]
if name not in action_arguments
]
if missing_arguments:
raise ValueError(f"数据库 action 缺少必填参数: {', '.join(missing_arguments)}")
if action in {"query", "write"}:
has_sql = bool(action_arguments.get("sql"))
has_file = bool(action_arguments.get("file"))
if has_sql == has_file:
raise ValueError("数据库 query/write 必须在 sql 与 file 中二选一")
for field_name in ("sql", "file"):
if field_name in action_arguments and (
not isinstance(action_arguments[field_name], str)
or not action_arguments[field_name].strip()
):
raise ValueError(f"数据库参数 {field_name} 必须是非空 string")
reveal_flag = action_arguments.get("show_secrets", False)
if not isinstance(reveal_flag, bool):
raise ValueError("数据库参数 show_secrets 必须是 boolean")
if action == "schema" and (
not isinstance(action_arguments.get("table_name"), str)
or not action_arguments["table_name"].strip()
):
raise ValueError("数据库参数 table_name 必须是非空 string")
if action == "query":
limit = action_arguments.get("limit", 100)
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 200:
raise ValueError("数据库参数 limit 必须是 1 到 200 的 integer")
write_flag = action_arguments.get("write", False)
if not isinstance(write_flag, bool):
raise ValueError("数据库参数 write 必须是 boolean")
def _build_database_command(
action: str,
action_arguments: Dict[str, Any],
script_path: Path,
) -> list[str]:
"""
组装数据库 Skill 脚本的 argv,不经过 shell。
:param action: 当前数据库动作名称
:param action_arguments: 已通过合同校验的结构化参数
:param script_path: 固定 Skill 脚本的绝对路径
:return: 传给 subprocess 的参数列表
"""
command = [sys.executable, str(script_path), action]
if action == "schema":
command.append(str(action_arguments.get("table_name") or ""))
elif action in {"query", "write"}:
sql = action_arguments.get("sql")
sql_file = action_arguments.get("file")
if sql:
command.append(str(sql))
if sql_file:
command.extend(["--file", str(sql_file)])
if action == "query":
command.extend(["--limit", str(action_arguments.get("limit", 100))])
if action_arguments.get("write") is True:
command.append("--write")
if action_arguments.get("show_secrets") is True:
command.append("--show-secrets")
return command
def _run_database_script(arguments: Dict[str, Any], *, root_path: Path) -> dict[str, Any]:
"""
不经 shell 调用数据库 Skill 脚本,并返回结构化结果。
"""
script_path = root_path / "skills/database-operation/scripts/mp-db.py"
action = str(arguments.get("action") or "")
action_arguments = arguments.get("arguments") or {}
if not isinstance(action_arguments, dict):
raise ValueError("数据库 action arguments 必须是对象")
contracts = _load_action_contracts(str(script_path))
contract = contracts.get(action)
if contract is None:
raise ValueError(f"未知数据库 action: {action}")
_validate_database_arguments(action, action_arguments, contract.to_dict(action))
command = _build_database_command(action, action_arguments, script_path)
completed = subprocess.run(
command,
cwd=root_path,
capture_output=True,
text=True,
check=False,
**agent_text_subprocess_kwargs(),
)
if completed.returncode != 0:
error_text = completed.stderr.strip() or completed.stdout.strip() or "数据库脚本执行失败"
return {"success": False, "error": "database_operation_failed", "message": error_text}
return _parse_script_payload(completed.stdout)
class DatabaseOperationTool(_ServiceOperationTool):
"""
外部 HTTP/MCP 调用 MoviePilot 数据库 Skill 的结构化工具。
:cvar _relative_script: 相对于项目根目录的固定 Skill 脚本路径
:cvar _selector_name: 输入模型中的服务选择字段;不选择实例时为 None
:cvar _selector_flag: 传给固定脚本的实例选择选项;不选择实例时为 None
:cvar _blocking_bucket: 阻塞执行隔离桶的名称,用于限制同类操作并发
"""
name: str = Field(
default="database_operation",
description="供模型调用的稳定工具名称",
)
description: str = Field(
default=(
"检查或显式修改已配置的 MoviePilot 数据库。"
"输入 schema 提供 tables、schema、query 和 write 的精确分支,包含字段类型、默认值、互斥的 SQL 来源及安全规则。"
),
description="供模型理解工具用途、输入约束和调用边界的说明",
)
tags: list[str] = Field(
default=[ToolTag.Admin],
description="工具的能力和权限分类标签,初始化时会统一归一化",
)
args_schema: Type[BaseModel] = Field(
default=DatabaseOperationInput,
description="工具输入参数的验证模型或 JSON Schema",
)
_relative_script: ClassVar[str] = "skills/database-operation/scripts/mp-db.py"
_selector_name: ClassVar[Optional[str]] = None
_selector_flag: ClassVar[Optional[str]] = None
_blocking_bucket: ClassVar[str] = "db"
def get_mcp_input_schema(self) -> dict[str, Any]:
"""
返回数据库 action 的完整 MCP JSON Schema。
"""
root_path = Path(get_runtime_setting("ROOT_PATH"))
actions = _load_action_contracts(str(root_path / self._relative_script))
return _build_mcp_input_schema(
actions=actions,
selector_name=None,
selector_description=None,
title=self.name,
)
# 具体参数由 args_schema 验证,执行入口保留与工具基类的动态分发兼容。
async def run( # type: ignore[override]
self,
action: str,
arguments: Optional[Dict[str, Any]] = None,
) -> str:
"""
执行一次数据库 Skill 操作。
:param action: tables、schema、query 或 write
:param arguments: 当前 action 的结构化参数
:return: 结构化数据库结果
"""
payload = await self.run_blocking(
self._blocking_bucket,
_run_database_script,
root_path=Path(get_runtime_setting("ROOT_PATH")),
arguments={"action": action, "arguments": arguments or {}},
)
return json.dumps(payload, ensure_ascii=False, indent=2)
__all__ = [
"DownloaderOperationInput",
"DownloaderOperationTool",
"DatabaseOperationInput",
"DatabaseOperationTool",
"MediaServerOperationInput",
"MediaServerOperationTool",
]