dark 039a3e1bdc 支持云下载继承版本参数并调整回滚请求格式
- 新增 PARENT_VERSION_NUMBER 可选配置,默认空值不传
- create-download-task 非空时透传 parentVersionNumber
- 支持 LLM/规则从自然语言和 key=value 中抽取继承版本参数
- 将 rollback 接口参数从表单 body 改为 URL query 拼接
- 同步 README、打包说明和 Skill 文档
- 增加 MCP 参数透传、配置写入和 rollback query 调用测试
2026-06-05 10:33:53 +08:00

190 lines
6.9 KiB
Python
Raw 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.

"""PAM_NODE MCP 工具的 action runner 封装。"""
from __future__ import annotations
import logging
from typing import Any, Protocol
from .logging_utils import json_for_log
from .models import ActionResult
from .output_parser import parse_mcp_result
logger = logging.getLogger(__name__)
class McpToolClient(Protocol):
"""MCP 工具客户端需要实现的最小同步接口。"""
def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
"""调用指定 MCP tool并返回工具原始输出。"""
...
def list_tools(self) -> list[str]:
"""返回 MCP server 暴露的 tool 名称列表。"""
...
DEFAULT_NODE_MCP_TOOLS = {
"get-online-ips": "pam_get_online_ips",
"create-download-task": "pam_create_download_task",
"poll-download-progress": "pam_poll_download_progress",
"upgrade-ip": "pam_upgrade_ip",
"poll-upgrade-progress": "pam_poll_upgrade_progress",
"start-ip": "pam_start_ip",
"stop-ip": "pam_stop_ip",
"verify-ip": "pam_verify_ip",
"download-log": "pam_download_log",
"rollback-ip": "pam_rollback_ip",
}
class McpActionRunner:
"""把 Agent action 转换为 MCP tool 调用。"""
def __init__(
self,
client: McpToolClient | None = None,
tool_names: dict[str, str] | None = None,
) -> None:
"""保存 MCP client 和 action 到 tool name 的映射。"""
self.client = client
self.tool_names = tool_names or {}
self._discovered_tools: list[str] | None = None
logger.info(
"MCP action runner 初始化 client=%s explicit_tool_names=%s",
type(client).__name__ if client else "",
json_for_log(self.tool_names),
)
def run(
self,
action: str,
*,
params: dict[str, Any],
ip: str | None = None,
hash_code: str | None = None,
node_url: str | None = None,
stop_first: bool = False,
**_: Any,
) -> ActionResult:
"""执行一个 PAM_NODE action并归一化为 ActionResult。"""
if self.client is None:
raise RuntimeError("尚未配置 MCP client")
tool_name = self._resolve_tool_name(action)
arguments = self._build_arguments(
action,
params=params,
ip=ip,
hash_code=hash_code,
node_url=node_url,
stop_first=stop_first,
)
logger.info(
"MCP action 调用开始 action=%s tool=%s arguments=%s",
action,
tool_name,
json_for_log(arguments),
)
try:
payload = self.client.call_tool(tool_name, arguments)
except Exception as exc: # pragma: no cover - 防御性异常包装
logger.exception("MCP action 调用异常 action=%s tool=%s", action, tool_name)
return parse_mcp_result(action, {}, ok=False, tool_name=tool_name, error=str(exc))
logger.info("MCP action 原始返回 action=%s tool=%s payload=%s", action, tool_name, json_for_log(payload, max_text_len=1600))
result = parse_mcp_result(action, payload, ok=True, tool_name=tool_name)
logger.info(
"MCP action 解析完成 action=%s tool=%s ok=%s values=%s error=%s",
action,
tool_name,
result.ok,
json_for_log(result.values),
result.error_summary,
)
return result
def _resolve_tool_name(self, action: str) -> str:
"""根据显式映射、server tools 自动发现和默认约定解析 tool name。"""
explicit = self.tool_names.get(action)
if explicit:
logger.info("MCP tool 使用显式映射 action=%s tool=%s", action, explicit)
return explicit
discovered = self._list_discovered_tools()
if discovered:
candidates = _tool_name_candidates(action)
by_lower = {name.lower(): name for name in discovered}
for candidate in candidates:
matched = by_lower.get(candidate.lower())
if matched:
logger.info("MCP tool 自动匹配 action=%s tool=%s candidates=%s", action, matched, candidates)
return matched
available = ", ".join(discovered)
raise ValueError(f"MCP server 未发现 action 对应 tool: {action}; 已发现: {available}")
fallback = DEFAULT_NODE_MCP_TOOLS.get(action)
if fallback:
logger.info("MCP tool 使用默认约定 action=%s tool=%s", action, fallback)
return fallback
raise ValueError(f"action 未映射 MCP tool: {action}")
def _list_discovered_tools(self) -> list[str]:
"""读取并缓存 MCP server 暴露的 tool 名称。"""
if self._discovered_tools is not None:
return self._discovered_tools
if self.client is None or not hasattr(self.client, "list_tools"):
self._discovered_tools = []
return self._discovered_tools
try:
self._discovered_tools = list(self.client.list_tools())
except Exception:
logger.exception("MCP tool 自动发现失败,使用默认 tool name 约定")
self._discovered_tools = []
logger.info("MCP tool 自动发现完成 tools=%s", self._discovered_tools)
return self._discovered_tools
def _build_arguments(
self,
action: str,
*,
params: dict[str, Any],
ip: str | None,
hash_code: str | None,
node_url: str | None,
stop_first: bool,
) -> dict[str, Any]:
"""把 Agent 参数转换为 MCP tool 所需的入参。"""
arguments = {
"homeBaseUrl": params.get("HOME_BASE_URL"),
"airportCode": params.get("AIRPORT_CODE"),
"applicationName": params.get("APP_NAME"),
"moduleName": params.get("MODULE_NAME"),
"versionNumber": params.get("VERSION_NUMBER"),
"actionType": params.get("ACTION_TYPE"),
"timeOut": params.get("TIMEOUT"),
"logName": params.get("LOG_NAME"),
}
if ip:
arguments["targetIp"] = ip
if hash_code:
arguments["hashCode"] = hash_code
if node_url:
arguments["nodeUrl"] = node_url
if action == "create-download-task" and params.get("PARENT_VERSION_NUMBER"):
arguments["parentVersionNumber"] = params.get("PARENT_VERSION_NUMBER")
if action == "rollback-ip":
arguments["stopFirst"] = stop_first
return {key: value for key, value in arguments.items() if value not in (None, "")}
def _tool_name_candidates(action: str) -> list[str]:
"""生成 action 自动匹配 MCP tool 的候选名称。"""
snake = action.replace("-", "_")
return [
action,
snake,
f"pam_{snake}",
f"pam_node_{snake}",
f"pam.node.{snake}",
f"pam-node.{action}",
]