- 新增 OpenAI-compatible LLM client,支持 base_url、api_key、model 配置 - 固化意图识别、参数抽取、部署计划生成的结构化 JSON 提示词 - 增加 MCP client 配置读取和真实 session 接入说明 - 实现 checkpoint 自动保存、resume 断点续跑和已完成步骤跳过 - 实现人工确认流程,支持失败 IP 回滚 approve/reject - 新增 chat 常驻式 CLI 对话框,支持自然语言分析、参数设置、执行确认、状态查看、回滚确认和续跑 - 同步 README,补充 LLM、MCP、checkpoint、confirm/resume、chat 使用方式 - 增加相关单元测试,覆盖 LLM client、MCP 配置、确认/续跑和交互式 CLI
93 lines
3.0 KiB
Python
93 lines
3.0 KiB
Python
"""MCP client adapters.
|
|
|
|
The Agent only needs a synchronous `call_tool(name, arguments)` surface. This
|
|
module adapts simple callables or SDK-like sessions to that surface without
|
|
forcing the rest of the codebase to import a concrete MCP SDK.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class McpClientConfig:
|
|
"""Configuration needed after a real MCP session has been created."""
|
|
|
|
server_name: str = "pam-node"
|
|
tool_names: dict[str, str] = field(default_factory=dict)
|
|
|
|
@classmethod
|
|
def from_mapping(cls, payload: dict[str, Any]) -> "McpClientConfig":
|
|
tool_names = payload.get("tool_names") or payload.get("tools") or {}
|
|
if not isinstance(tool_names, dict):
|
|
raise ValueError("MCP tool_names must be an object")
|
|
return cls(
|
|
server_name=str(payload.get("server_name", "pam-node")),
|
|
tool_names={str(key): str(value) for key, value in tool_names.items()},
|
|
)
|
|
|
|
|
|
def load_mcp_client_config(path: str | Path) -> McpClientConfig:
|
|
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("MCP client config must be a JSON object")
|
|
return McpClientConfig.from_mapping(payload)
|
|
|
|
|
|
class FunctionMcpToolClient:
|
|
"""Wrap a plain Python callable as an MCP tool client."""
|
|
|
|
def __init__(self, caller: Callable[[str, dict[str, Any]], Any]) -> None:
|
|
self.caller = caller
|
|
|
|
def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
|
|
return self.caller(tool_name, arguments)
|
|
|
|
|
|
class SessionMcpToolClient:
|
|
"""Adapt SDK-like sessions exposing `call_tool`.
|
|
|
|
The adapter accepts common result shapes:
|
|
|
|
- raw dict/list/string
|
|
- object with `structuredContent`
|
|
- object with `content`, where text content may contain JSON
|
|
"""
|
|
|
|
def __init__(self, session: Any) -> None:
|
|
if not hasattr(session, "call_tool"):
|
|
raise TypeError("MCP session must expose call_tool")
|
|
self.session = session
|
|
|
|
def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
|
|
result = self.session.call_tool(tool_name, arguments)
|
|
return normalize_mcp_sdk_result(result)
|
|
|
|
|
|
def normalize_mcp_sdk_result(result: Any) -> Any:
|
|
if hasattr(result, "structuredContent"):
|
|
structured = getattr(result, "structuredContent")
|
|
if structured is not None:
|
|
return structured
|
|
|
|
if hasattr(result, "content"):
|
|
content = getattr(result, "content")
|
|
text_parts: list[str] = []
|
|
for item in content or []:
|
|
text = getattr(item, "text", None)
|
|
if text is not None:
|
|
text_parts.append(text)
|
|
if text_parts:
|
|
joined = "\n".join(text_parts)
|
|
try:
|
|
return json.loads(joined)
|
|
except json.JSONDecodeError:
|
|
return joined
|
|
|
|
return result
|