agent-Specialization/modules/mcp_client_manager/http_client.py
JOJO f661dc6456 refactor(server,modules,utils,frontend): split oversized modules into packages and fix regressions
- 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
2026-06-20 15:54:48 +08:00

276 lines
10 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.

"""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 _StreamableHTTPMCPClient:
"""最小可用 streamable HTTP MCP 客户端。"""
def __init__(self, server: Dict[str, Any], timeout_seconds: int, protocol_version: str):
self.server = server
self.url = str(server.get("url") or "").strip()
self.timeout_seconds = max(3, int(timeout_seconds or MCP_DEFAULT_TIMEOUT_SECONDS))
self.protocol_version = protocol_version
self._session_id: Optional[str] = None
self._seq = 0
self._client = httpx.Client(timeout=self.timeout_seconds)
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 _header_pick(headers: httpx.Headers, key: str) -> Optional[str]:
for hk, hv in headers.items():
if hk.lower() == key.lower():
return hv
return None
@staticmethod
def _infer_mcp_name(method: str, params: Optional[Dict[str, Any]]) -> Optional[str]:
if not isinstance(params, dict):
params = {}
method = str(method or "").strip()
if method == "tools/call":
value = params.get("name")
return str(value).strip() if value is not None else None
if method == "resources/read":
value = params.get("uri")
return str(value).strip() if value is not None else None
if method == "prompts/get":
value = params.get("name")
return str(value).strip() if value is not None else None
return None
def _build_headers(self, *, method: Optional[str] = None, params: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
headers = {
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
"MCP-Protocol-Version": self._negotiated_protocol_version or self.protocol_version,
}
if self._session_id:
headers["Mcp-Session-Id"] = self._session_id
if method:
headers["Mcp-Method"] = str(method)
name = self._infer_mcp_name(str(method), params)
if name:
headers["Mcp-Name"] = name
headers.update(self.server.get("headers") or {})
return headers
@staticmethod
def _parse_sse_json_events(raw_text: str) -> List[Dict[str, Any]]:
events: List[Dict[str, Any]] = []
blocks = raw_text.split("\n\n")
for block in blocks:
data_lines: List[str] = []
for line in block.splitlines():
if line.startswith("data:"):
data_lines.append(line[5:].lstrip())
if not data_lines:
continue
payload = "\n".join(data_lines).strip()
if not payload or payload == "[DONE]":
continue
try:
parsed = json.loads(payload)
except Exception:
continue
if isinstance(parsed, dict):
events.append(parsed)
return events
@staticmethod
def _build_server_request_result(method: str, params: Dict[str, Any]) -> Tuple[bool, Dict[str, Any]]:
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}"}
def _post_jsonrpc_message(self, payload: Dict[str, Any]) -> None:
try:
resp = self._client.post(
self.url,
headers=self._build_headers(),
json=payload,
)
except Exception as exc:
raise MCPClientError(f"HTTP MCP 回写失败: {exc}") from exc
if resp.status_code >= 400:
raise MCPClientError(f"HTTP MCP 回写状态异常: {resp.status_code} {resp.text[:400]}")
def _handle_server_request_message(self, message: Dict[str, Any]) -> None:
msg_id = message.get("id")
method = str(message.get("method") or "").strip()
params = message.get("params")
if not isinstance(params, dict):
params = {}
try:
ok, payload = self._build_server_request_result(method, params)
if ok:
self._post_jsonrpc_message({"jsonrpc": "2.0", "id": msg_id, "result": payload})
else:
self._post_jsonrpc_message({"jsonrpc": "2.0", "id": msg_id, "error": payload})
except Exception as exc:
self._post_jsonrpc_message(
{
"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,
notification: bool = False,
*,
_allow_session_retry: bool = True,
) -> Dict[str, Any]:
if not self.url:
raise MCPClientError("streamable_http 服务缺少 url")
req_id = None if notification else self._next_id()
payload: Dict[str, Any] = {"jsonrpc": "2.0", "method": method, "params": params or {}}
if req_id is not None:
payload["id"] = req_id
try:
resp = self._client.post(
self.url,
headers=self._build_headers(method=method, params=params or {}),
json=payload,
)
except Exception as exc:
raise MCPClientError(f"HTTP MCP 请求失败: {exc}") from exc
session_id = self._header_pick(resp.headers, "Mcp-Session-Id")
if session_id:
self._session_id = session_id
# 会话失效(服务端返回 404按规范重新建立 session 并重试一次
if (
resp.status_code == 404
and self._session_id
and method != "initialize"
and _allow_session_retry
):
self._session_id = None
self._initialized = False
self._initialize_result = {}
self._negotiated_protocol_version = None
self.initialize()
return self._request(
method,
params,
notification=notification,
_allow_session_retry=False,
)
if resp.status_code >= 400:
raise MCPClientError(f"HTTP MCP 状态异常: {resp.status_code} {resp.text[:400]}")
if notification:
return {}
content_type = (resp.headers.get("content-type") or "").lower()
message: Optional[Dict[str, Any]] = None
if "application/json" in content_type:
parsed = resp.json()
if isinstance(parsed, dict):
message = parsed
elif "text/event-stream" in content_type:
for event in self._parse_sse_json_events(resp.text):
if not isinstance(event, dict):
continue
if "method" in event:
if "id" in event:
self._handle_server_request_message(event)
# 通知直接消费
continue
if event.get("id") == req_id:
message = event
break
else:
try:
parsed = resp.json()
if isinstance(parsed, dict):
message = parsed
except Exception:
pass
if not message:
raise MCPClientError(f"HTTP MCP 未返回可解析响应method={method}")
if "error" in message:
raise MCPClientError(f"{method} 调用失败: {message.get('error')}")
return message.get("result") or {}
def initialize(self) -> Dict[str, Any]:
if self._initialized:
return dict(self._initialize_result)
result = self._request(
"initialize",
{
"protocolVersion": self.protocol_version,
"capabilities": {
"roots": {"listChanged": False},
},
"clientInfo": {"name": "agents-mcp-bridge", "version": "1.0.0"},
},
)
self._request("notifications/initialized", {}, notification=True)
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 request(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self._request(method, params, notification=False)
def close(self) -> None:
try:
if self._session_id and self.url:
self._client.delete(self.url, headers=self._build_headers())
except Exception:
pass
try:
self._client.close()
except Exception:
pass
self._session_id = None
self._initialized = False
self._initialize_result = {}
self._negotiated_protocol_version = None