- Split server/chat.py, server/status.py, server/tasks.py into sub-packages
- Split utils/context_manager.py, utils/conversation_manager.py into mixin packages
- Split modules/file_manager.py, persistent_terminal.py, terminal_ops.py, mcp_client_manager.py into packages
- Split core/main_terminal_parts/context.py into base + mixins
- Split static/src/app/methods/{ui,message,upload,taskPolling,conversation} into sub-modules
- Fix flattened method exports, dynamic import paths, missing cross-module imports
- Add missing permission/network/execution mode constants and _PERMISSION_MODE_LABEL
- Update AGENTS.md, CLAUDE.md and project memories to reflect new structure
447 lines
16 KiB
Python
447 lines
16 KiB
Python
"""MCP 客户端管理:工具发现、调用与 OpenAI 工具映射。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import select
|
||
import shutil
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING
|
||
|
||
import httpx
|
||
|
||
from config import MCP_PROTOCOL_VERSION, MCP_DEFAULT_TIMEOUT_SECONDS
|
||
from modules.mcp_server_registry import MCPServerRegistry
|
||
|
||
if TYPE_CHECKING:
|
||
from modules.user_container_manager import ContainerHandle
|
||
|
||
|
||
class _StdioMCPClient:
|
||
"""最小可用 stdio MCP 客户端(JSON-RPC over newline-delimited JSON)。"""
|
||
|
||
CONTAINER_PREFIXES = (
|
||
"/workspace",
|
||
"/usr/",
|
||
"/bin/",
|
||
"/sbin/",
|
||
"/lib/",
|
||
"/lib64/",
|
||
"/opt/",
|
||
"/etc/",
|
||
"/tmp/",
|
||
"/var/",
|
||
"/home/",
|
||
"/root/",
|
||
)
|
||
PATH_OPTION_KEYS = {
|
||
"--path",
|
||
"--dir",
|
||
"--directory",
|
||
"--root",
|
||
"--cwd",
|
||
"--workspace",
|
||
}
|
||
|
||
def __init__(
|
||
self,
|
||
server: Dict[str, Any],
|
||
timeout_seconds: int,
|
||
protocol_version: str,
|
||
container_session: Optional["ContainerHandle"] = None,
|
||
):
|
||
self.server = server
|
||
self.timeout_seconds = max(3, int(timeout_seconds or MCP_DEFAULT_TIMEOUT_SECONDS))
|
||
self.protocol_version = protocol_version
|
||
self.container_session = container_session
|
||
self.process: Optional[subprocess.Popen] = None
|
||
self._seq = 0
|
||
self._initialized = False
|
||
self._initialize_result: Dict[str, Any] = {}
|
||
self._negotiated_protocol_version: Optional[str] = None
|
||
|
||
def _next_id(self) -> int:
|
||
self._seq += 1
|
||
return self._seq
|
||
|
||
@staticmethod
|
||
def _is_windows_abs_path(value: str) -> bool:
|
||
return len(value) > 2 and value[1] == ":" and value[0].isalpha()
|
||
|
||
@staticmethod
|
||
def _normalize_mount_path(value: str) -> str:
|
||
mount = str(value or "/workspace").strip()
|
||
mount = mount.rstrip("/")
|
||
return mount or "/workspace"
|
||
|
||
def _normalize_workspace_path(self, value: Any) -> str:
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return ""
|
||
try:
|
||
return str(Path(raw).expanduser().resolve())
|
||
except Exception:
|
||
return raw
|
||
|
||
def _map_workspace_path(self, raw: str, workspace_host: str, mount_path: str) -> str:
|
||
if not raw or not workspace_host:
|
||
return raw
|
||
if raw.startswith(mount_path):
|
||
return raw
|
||
if self._is_windows_abs_path(raw):
|
||
return raw
|
||
if not raw.startswith("/"):
|
||
return raw
|
||
try:
|
||
normalized = str(Path(raw).expanduser().resolve())
|
||
except Exception:
|
||
normalized = raw
|
||
if normalized == workspace_host:
|
||
return mount_path
|
||
prefix = workspace_host + os.sep
|
||
if normalized.startswith(prefix):
|
||
rel = normalized[len(prefix):].replace("\\", "/")
|
||
return f"{mount_path}/{rel}" if rel else mount_path
|
||
return raw
|
||
|
||
def _rewrite_path_like_value_for_container(
|
||
self,
|
||
value: str,
|
||
workspace_host: str,
|
||
mount_path: str,
|
||
) -> str:
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return raw
|
||
mapped = self._map_workspace_path(raw, workspace_host, mount_path)
|
||
return mapped if mapped != raw else raw
|
||
|
||
@staticmethod
|
||
def _command_basename(value: str) -> str:
|
||
text = str(value or "").strip().replace("\\", "/").rstrip("/")
|
||
if not text:
|
||
return ""
|
||
if "/" in text:
|
||
return text.split("/")[-1]
|
||
return text
|
||
|
||
def _rewrite_command_for_container(self, command: str, workspace_host: str, mount_path: str) -> str:
|
||
raw = str(command or "").strip()
|
||
if not raw:
|
||
return raw
|
||
mapped = self._map_workspace_path(raw, workspace_host, mount_path)
|
||
if mapped != raw:
|
||
return mapped
|
||
# command 若是宿主机绝对路径(如 /opt/homebrew/bin/npx),
|
||
# 在容器内通常应退化为命令名(npx/python3),避免无意义路径校验阻断。
|
||
if raw.startswith("/") or self._is_windows_abs_path(raw) or "\\" in raw:
|
||
candidate = self._command_basename(raw)
|
||
return candidate or raw
|
||
return raw
|
||
|
||
def _rewrite_arg_for_container(
|
||
self,
|
||
arg: str,
|
||
*,
|
||
index: int,
|
||
workspace_host: str,
|
||
mount_path: str,
|
||
) -> str:
|
||
raw = str(arg or "")
|
||
stripped = raw.strip()
|
||
if not stripped:
|
||
return raw
|
||
if "=" in stripped:
|
||
left, right = stripped.split("=", 1)
|
||
if left in self.PATH_OPTION_KEYS and right:
|
||
rewritten = self._rewrite_path_like_value_for_container(
|
||
right,
|
||
workspace_host=workspace_host,
|
||
mount_path=mount_path,
|
||
)
|
||
return f"{left}={rewritten}"
|
||
return self._rewrite_path_like_value_for_container(
|
||
stripped,
|
||
workspace_host=workspace_host,
|
||
mount_path=mount_path,
|
||
)
|
||
|
||
def _prepare_launch(self) -> Tuple[List[str], Optional[str], Dict[str, str]]:
|
||
command_raw = str(self.server.get("command") or "").strip()
|
||
args_raw = [str(item) for item in (self.server.get("args") or [])]
|
||
env_raw = dict(self.server.get("env") or {})
|
||
cwd_raw = str(self.server.get("cwd") or "").strip() or None
|
||
if not command_raw:
|
||
raise MCPClientError("stdio 服务缺少 command")
|
||
|
||
session = self.container_session
|
||
use_container = bool(session and getattr(session, "mode", None) == "docker")
|
||
if not use_container:
|
||
env = dict(os.environ)
|
||
env.update(env_raw)
|
||
resolved_cwd = cwd_raw
|
||
if not resolved_cwd and session is not None:
|
||
workspace_hint = self._normalize_workspace_path(getattr(session, "workspace_path", ""))
|
||
if workspace_hint:
|
||
resolved_cwd = workspace_hint
|
||
return [command_raw, *args_raw], resolved_cwd, env
|
||
|
||
container_name = str(getattr(session, "container_name", "") or "").strip()
|
||
if not container_name:
|
||
raise MCPClientError("docker 模式下 stdio MCP 缺少 container_name")
|
||
docker_bin = str(getattr(session, "sandbox_bin", "") or "docker").strip() or "docker"
|
||
docker_path = shutil.which(docker_bin) or docker_bin
|
||
mount_path = self._normalize_mount_path(getattr(session, "mount_path", "/workspace"))
|
||
workspace_host = self._normalize_workspace_path(getattr(session, "workspace_path", ""))
|
||
|
||
command = self._rewrite_command_for_container(
|
||
command_raw,
|
||
workspace_host=workspace_host,
|
||
mount_path=mount_path,
|
||
)
|
||
args: List[str] = []
|
||
for idx, item in enumerate(args_raw):
|
||
args.append(
|
||
self._rewrite_arg_for_container(
|
||
item,
|
||
index=idx,
|
||
workspace_host=workspace_host,
|
||
mount_path=mount_path,
|
||
)
|
||
)
|
||
cwd = (
|
||
self._rewrite_path_like_value_for_container(
|
||
cwd_raw,
|
||
workspace_host=workspace_host,
|
||
mount_path=mount_path,
|
||
)
|
||
if cwd_raw
|
||
else None
|
||
)
|
||
|
||
cmd: List[str] = [docker_path, "exec", "-i"]
|
||
if cwd:
|
||
cmd += ["-w", cwd]
|
||
|
||
exec_env = {
|
||
"PYTHONIOENCODING": "utf-8",
|
||
"TERM": "xterm-256color",
|
||
}
|
||
for key, value in env_raw.items():
|
||
if value is not None:
|
||
exec_env[str(key)] = str(value)
|
||
for key, value in exec_env.items():
|
||
cmd += ["-e", f"{key}={value}"]
|
||
cmd += [container_name, command, *args]
|
||
|
||
return cmd, None, dict(os.environ)
|
||
|
||
def start(self) -> None:
|
||
if self.process and self.process.poll() is None:
|
||
return
|
||
launch_cmd, cwd, env = self._prepare_launch()
|
||
try:
|
||
self.process = subprocess.Popen(
|
||
launch_cmd,
|
||
stdin=subprocess.PIPE,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
bufsize=1,
|
||
cwd=cwd or None,
|
||
env=env,
|
||
)
|
||
except Exception as exc:
|
||
raise MCPClientError(f"启动 stdio MCP 服务失败: {exc}") from exc
|
||
self._initialized = False
|
||
self._initialize_result = {}
|
||
self._negotiated_protocol_version = None
|
||
|
||
def _send(self, payload: Dict[str, Any]) -> None:
|
||
if not self.process or not self.process.stdin:
|
||
raise MCPClientError("stdio 进程未启动")
|
||
raw = json.dumps(payload, ensure_ascii=False)
|
||
try:
|
||
self.process.stdin.write(raw + "\n")
|
||
self.process.stdin.flush()
|
||
except Exception as exc:
|
||
raise MCPClientError(f"写入 stdio 消息失败: {exc}") from exc
|
||
|
||
def _read_message(self, timeout_seconds: Optional[float] = None) -> Dict[str, Any]:
|
||
if not self.process or not self.process.stdout:
|
||
raise MCPClientError("stdio 进程未启动")
|
||
timeout = self.timeout_seconds if timeout_seconds is None else max(0.1, float(timeout_seconds))
|
||
deadline = time.monotonic() + timeout
|
||
stdout = self.process.stdout
|
||
last_text_line = ""
|
||
while True:
|
||
if self.process.poll() is not None:
|
||
stderr_text = ""
|
||
stdout_text = ""
|
||
try:
|
||
if self.process.stderr:
|
||
stderr_text = self.process.stderr.read() or ""
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if self.process.stdout:
|
||
stdout_text = self.process.stdout.read() or ""
|
||
except Exception:
|
||
pass
|
||
detail = (stderr_text or stdout_text or last_text_line or "")[:400]
|
||
raise MCPClientError(f"stdio 进程已退出(code={self.process.returncode}){detail}")
|
||
|
||
remain = deadline - time.monotonic()
|
||
if remain <= 0:
|
||
raise MCPClientError("读取 stdio MCP 响应超时")
|
||
|
||
readable, _, _ = select.select([stdout], [], [], min(0.3, remain))
|
||
if not readable:
|
||
continue
|
||
line = stdout.readline()
|
||
if not line:
|
||
continue
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
return json.loads(line)
|
||
except Exception:
|
||
last_text_line = line
|
||
continue
|
||
|
||
@staticmethod
|
||
def _build_server_request_result(method: str, params: Dict[str, Any]) -> Tuple[bool, Dict[str, Any]]:
|
||
"""处理服务端发起的请求(Server -> Client Request)。"""
|
||
name = str(method or "").strip()
|
||
if name == "ping":
|
||
return True, {}
|
||
if name == "roots/list":
|
||
return True, {"roots": []}
|
||
if name == "completion/complete":
|
||
return False, {"code": -32601, "message": "completion/complete not supported by client"}
|
||
if name == "sampling/createMessage":
|
||
return False, {"code": -32601, "message": "sampling not supported by client"}
|
||
if name == "elicitation/create":
|
||
return False, {"code": -32601, "message": "elicitation not supported by client"}
|
||
return False, {"code": -32601, "message": f"Client method not implemented: {name}"}
|
||
|
||
@staticmethod
|
||
def _handle_server_notification(method: str, params: Dict[str, Any]) -> None:
|
||
"""处理服务端通知(Server -> Client Notification)。当前策略:静默接收。"""
|
||
_ = method, params
|
||
|
||
def _respond_server_request(self, msg: Dict[str, Any]) -> None:
|
||
msg_id = msg.get("id")
|
||
method = str(msg.get("method") or "").strip()
|
||
params = msg.get("params")
|
||
if not isinstance(params, dict):
|
||
params = {}
|
||
try:
|
||
ok, payload = self._build_server_request_result(method, params)
|
||
if ok:
|
||
self._send({"jsonrpc": "2.0", "id": msg_id, "result": payload})
|
||
else:
|
||
self._send({"jsonrpc": "2.0", "id": msg_id, "error": payload})
|
||
except Exception as exc:
|
||
self._send(
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"id": msg_id,
|
||
"error": {"code": -32603, "message": f"Client internal error: {exc}"},
|
||
}
|
||
)
|
||
|
||
def request(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||
if not self.process or self.process.poll() is not None:
|
||
self.start()
|
||
req_id = self._next_id()
|
||
payload = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params or {}}
|
||
self._send(payload)
|
||
while True:
|
||
msg = self._read_message()
|
||
|
||
# 收到目标响应
|
||
if msg.get("id") == req_id and "method" not in msg:
|
||
if "error" in msg:
|
||
error = msg.get("error") or {}
|
||
raise MCPClientError(f"{method} 调用失败: {error}")
|
||
return msg.get("result") or {}
|
||
|
||
# 服务端请求:回包后继续等待目标响应
|
||
if "id" in msg and "method" in msg:
|
||
self._respond_server_request(msg)
|
||
continue
|
||
|
||
# 服务端通知:消费后继续
|
||
if "method" in msg:
|
||
self._handle_server_notification(str(msg.get("method") or ""), msg.get("params") or {})
|
||
continue
|
||
|
||
def notify(self, method: str, params: Optional[Dict[str, Any]] = None) -> None:
|
||
if not self.process or self.process.poll() is not None:
|
||
self.start()
|
||
self._send({"jsonrpc": "2.0", "method": method, "params": params or {}})
|
||
|
||
def initialize(self) -> Dict[str, Any]:
|
||
if self._initialized:
|
||
return dict(self._initialize_result)
|
||
if not self.process or self.process.poll() is not None:
|
||
self.start()
|
||
result = self.request(
|
||
"initialize",
|
||
{
|
||
"protocolVersion": self.protocol_version,
|
||
"capabilities": {
|
||
"roots": {"listChanged": False},
|
||
},
|
||
"clientInfo": {"name": "agents-mcp-bridge", "version": "1.0.0"},
|
||
},
|
||
)
|
||
self.notify("notifications/initialized")
|
||
self._initialize_result = dict(result or {})
|
||
self._initialized = True
|
||
negotiated = result.get("protocolVersion") if isinstance(result, dict) else None
|
||
if negotiated:
|
||
self._negotiated_protocol_version = str(negotiated)
|
||
return dict(self._initialize_result)
|
||
|
||
def close(self) -> None:
|
||
if not self.process:
|
||
return
|
||
try:
|
||
if self.process.stdin:
|
||
self.process.stdin.close()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if self.process.poll() is None:
|
||
self.process.terminate()
|
||
self.process.wait(timeout=1.5)
|
||
except Exception:
|
||
try:
|
||
self.process.kill()
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
try:
|
||
if self.process.stdout:
|
||
self.process.stdout.close()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if self.process.stderr:
|
||
self.process.stderr.close()
|
||
except Exception:
|
||
pass
|
||
self.process = None
|
||
self._initialized = False
|
||
self._initialize_result = {}
|
||
self._negotiated_protocol_version = None
|