- 修复脚本配置文件路径处理问题,避免打包后 ZIP_FILE_PATH 等参数未生效并回退默认值 - 在 chat 模式执行前增加参数归一化和预检,提前检查 ZIP_FILE_PATH、脚本入口和 MCP 配置 - 优化 chat 交互体验,问候语不再触发结构化分析,分析前增加提示,执行中播报每步 action 状态 - 修复 action 失败被误判为 LangGraph 不可用的问题,失败后保留 checkpoint 并给出明确续跑提示 - 补齐 MCP 参数传递,支持向 action 传入 hashCode、nodeUrl、targetIp 等上下文 - 增强全局 action、单 IP action、回滚和日志下载的异常处理与进度回调 - 同步 README、打包 README 和 run.sh 帮助文案,更新打包后 chat 的实际使用说明 - 补充回归测试,覆盖 chat 预检、进度播报、问候处理、MCP 传参与配置路径修复
222 lines
6.6 KiB
Python
222 lines
6.6 KiB
Python
from pam_deploy_graph.mcp_client import (
|
|
FunctionMcpToolClient,
|
|
HttpMcpToolClient,
|
|
load_mcp_client_config,
|
|
normalize_mcp_tool_list,
|
|
OAuthTokenProvider,
|
|
SessionMcpToolClient,
|
|
StdioMcpToolClient,
|
|
normalize_mcp_sdk_result,
|
|
)
|
|
from pam_deploy_graph.mcp_factory import build_mcp_runner_from_config
|
|
from pam_deploy_graph.mcp_runner import McpActionRunner
|
|
|
|
|
|
def test_function_mcp_client_wraps_callable():
|
|
client = FunctionMcpToolClient(lambda name, args: {"tool": name, "args": args})
|
|
assert client.call_tool("pam_get_online_ips", {"airportCode": "HET"})["tool"] == "pam_get_online_ips"
|
|
|
|
|
|
def test_normalize_mcp_sdk_result_structured_content():
|
|
result = type("Result", (), {"structuredContent": {"ok": True}})()
|
|
assert normalize_mcp_sdk_result(result) == {"ok": True}
|
|
|
|
|
|
def test_session_mcp_client_normalizes_text_json_content():
|
|
content = [type("Text", (), {"text": '{"ok": true}'})()]
|
|
result = type("Result", (), {"content": content})()
|
|
|
|
class Session:
|
|
def call_tool(self, tool_name, arguments):
|
|
return result
|
|
|
|
client = SessionMcpToolClient(Session())
|
|
assert client.call_tool("tool", {}) == {"ok": True}
|
|
|
|
|
|
def test_normalize_mcp_tool_list():
|
|
result = type(
|
|
"Tools",
|
|
(),
|
|
{"tools": [type("Tool", (), {"name": "pam_get_online_ips"})(), {"name": "verify-ip"}]},
|
|
)()
|
|
|
|
assert normalize_mcp_tool_list(result) == ["pam_get_online_ips", "verify-ip"]
|
|
|
|
|
|
def test_load_mcp_client_config(tmp_path):
|
|
path = tmp_path / "mcp.json"
|
|
path.write_text(
|
|
(
|
|
'{"server_name": "pam-node-prod", "transport": "stdio", '
|
|
'"command": "python", "args": ["-m", "server"], '
|
|
'"env": {"PAM_ENV": "test"}, "cwd": "/tmp", "timeout_seconds": 3, '
|
|
'"tool_names": {"get-online-ips": "custom_ips"}}'
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
config = load_mcp_client_config(path)
|
|
|
|
assert config.server_name == "pam-node-prod"
|
|
assert config.transport == "stdio"
|
|
assert config.command == "python"
|
|
assert config.args == ["-m", "server"]
|
|
assert config.env == {"PAM_ENV": "test"}
|
|
assert config.cwd == "/tmp"
|
|
assert config.timeout_seconds == 3
|
|
assert config.tool_names["get-online-ips"] == "custom_ips"
|
|
|
|
|
|
def test_load_http_mcp_client_config_with_auth(tmp_path):
|
|
path = tmp_path / "mcp.json"
|
|
path.write_text(
|
|
"""
|
|
{
|
|
"server_name": "pam-node-prod",
|
|
"transport": "streamable_http",
|
|
"server_url": "https://pam-node.example.com/mcp",
|
|
"auth": {
|
|
"token_url": "https://pam-node-auth.example.com/oauth/token",
|
|
"client_id": "mcp-client",
|
|
"client_secret": "mcp-secret"
|
|
}
|
|
}
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
config = load_mcp_client_config(path)
|
|
|
|
assert config.transport == "streamable_http"
|
|
assert config.server_url == "https://pam-node.example.com/mcp"
|
|
assert config.auth is not None
|
|
assert config.auth.client_id == "mcp-client"
|
|
assert config.auth.client_secret == "mcp-secret"
|
|
|
|
|
|
def test_build_mcp_runner_from_stdio_config(tmp_path):
|
|
path = tmp_path / "mcp.json"
|
|
path.write_text(
|
|
'{"transport": "stdio", "command": "python", "tool_names": {"verify-ip": "custom_verify"}}',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
runner = build_mcp_runner_from_config(path)
|
|
|
|
assert isinstance(runner.client, StdioMcpToolClient)
|
|
assert runner.tool_names["verify-ip"] == "custom_verify"
|
|
|
|
|
|
def test_build_mcp_runner_from_http_config(tmp_path):
|
|
path = tmp_path / "mcp.json"
|
|
path.write_text(
|
|
"""
|
|
{
|
|
"transport": "sse",
|
|
"server_url": "https://pam-node.example.com/sse",
|
|
"auth": {
|
|
"token_url": "https://pam-node-auth.example.com/oauth/token",
|
|
"client_id": "mcp-client",
|
|
"client_secret": "mcp-secret"
|
|
}
|
|
}
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
runner = build_mcp_runner_from_config(path)
|
|
|
|
assert isinstance(runner.client, HttpMcpToolClient)
|
|
assert runner.client.transport == "sse"
|
|
|
|
|
|
def test_oauth_token_provider_uses_home_style_form(monkeypatch, tmp_path):
|
|
config = load_mcp_client_config(
|
|
_write_json_config(
|
|
tmp_path,
|
|
{
|
|
"transport": "streamable_http",
|
|
"server_url": "https://pam-node.example.com/mcp",
|
|
"auth": {
|
|
"token_url": "https://pam-node-auth.example.com/oauth/token",
|
|
"client_id": "mcp-client",
|
|
"client_secret": "mcp-secret",
|
|
},
|
|
},
|
|
)
|
|
)
|
|
assert config.auth is not None
|
|
calls = []
|
|
|
|
class Response:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def read(self):
|
|
return b'{"access_token": "token-1", "expires_in": 3600}'
|
|
|
|
def fake_urlopen(request, timeout):
|
|
calls.append((request, timeout))
|
|
return Response()
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
|
provider = OAuthTokenProvider(config.auth)
|
|
|
|
headers = provider.authorization_headers()
|
|
|
|
assert headers == {"Authorization": "Bearer token-1"}
|
|
body = calls[0][0].data.decode("utf-8")
|
|
assert "grant_type=client_credentials" in body
|
|
assert "client_id=mcp-client" in body
|
|
assert "client_secret=mcp-secret" in body
|
|
|
|
|
|
def test_mcp_runner_auto_discovers_tool_name():
|
|
class Client:
|
|
def list_tools(self):
|
|
return ["pam_get_online_ips"]
|
|
|
|
def call_tool(self, tool_name, arguments):
|
|
return {"IP": ["192.168.1.10"], "COUNT": 1, "TOOL": tool_name}
|
|
|
|
runner = McpActionRunner(client=Client())
|
|
|
|
result = runner.run("get-online-ips", params={})
|
|
|
|
assert result.ok is True
|
|
assert result.tool_name == "pam_get_online_ips"
|
|
|
|
|
|
def test_mcp_runner_passes_hash_code_and_node_url():
|
|
calls = []
|
|
|
|
class Client:
|
|
def call_tool(self, tool_name, arguments):
|
|
calls.append((tool_name, arguments))
|
|
return {"ACTION": "upgrade-ip", "SUCCESS": "true"}
|
|
|
|
runner = McpActionRunner(client=Client())
|
|
|
|
result = runner.run(
|
|
"upgrade-ip",
|
|
params={"HOME_BASE_URL": "https://pam.home", "AIRPORT_CODE": "HET"},
|
|
ip="192.168.1.10",
|
|
hash_code="hash-1",
|
|
node_url="https://pam.node",
|
|
)
|
|
|
|
assert result.ok is True
|
|
assert calls[0][1]["targetIp"] == "192.168.1.10"
|
|
assert calls[0][1]["hashCode"] == "hash-1"
|
|
assert calls[0][1]["nodeUrl"] == "https://pam.node"
|
|
|
|
|
|
def _write_json_config(tmpdir, payload):
|
|
path = tmpdir / "mcp.json"
|
|
path.write_text(__import__("json").dumps(payload), encoding="utf-8")
|
|
return str(path)
|