feat: 实现MCP工具调用链路(streamable_http) - chat中MCP作为function calling真实调用tools/call

This commit is contained in:
QihuAI Design committed 2026-07-16 20:59:42 +08:00
1 parent ab90ac9473
commit 24ed907ba5
2 files changed
+10095 -2435

No files matched your search

File diff suppressed because it is too large. Load diff
+187
View File
@@ -0,0 +1,187 @@
"""
MCP (Model Context Protocol) 客户端 —— 异步实现 streamable_http 传输。
支持:
- initialize / notifications/initialized / tools/list / tools/call 标准流程
- streamable_http:POST JSON-RPC,响应可能是 SSE(event: message / data: {...})或纯 JSON
- SSE 解析兼容
- 直连外部端点(trust_env=False,不绕容器代理)
供 chat.py 在 AI 对话中把 MCP 工具暴露为 function calling,并代理执行 tools/call。
"""
import json
import logging
import asyncio
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
MCP_PROTOCOL_VERSION = "2024-11-05"
# 工具列表缓存(url -> (timestamp, tools)),避免同一请求内 / 短时间重复握手
_TOOLS_CACHE: dict = {}
_TOOLS_CACHE_TTL = 120 # 秒
def _parse_sse(text: str):
"""从响应文本解析 JSON-RPC 结果。兼容 SSE(data: 行)与纯 JSON。"""
if not text:
return None
text = text.strip()
# SSE 格式:多行 "event: message" / "data: {...}"
data_lines = []
for line in text.splitlines():
ls = line.strip()
if ls.startswith("data:"):
data_lines.append(ls[5:].strip())
if data_lines:
last = data_lines[-1]
try:
return json.loads(last)
except Exception:
pass
# 纯 JSON
try:
return json.loads(text)
except Exception:
return None
async def _handshake_and_request(
url: str,
headers: dict,
method: str,
params: dict,
timeout: float = 30.0,
):
"""
完成一次完整 MCP 会话(initialize -> initialized -> 目标请求)。
返回解析后的 JSON-RPC 响应 dict,或 None。
"""
base_headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
base_headers.update(headers or {})
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
# 1) initialize
r = await client.post(
url,
headers=base_headers,
json={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "aiflow", "version": "1.0"},
},
},
)
if r.status_code >= 400:
logger.warning(f"[MCP] initialize 失败 {url} -> HTTP {r.status_code}")
return None
sid = r.headers.get("mcp-session-id") or r.headers.get("Mcp-Session-Id")
req_headers = dict(base_headers)
if sid:
req_headers["Mcp-Session-Id"] = sid
# 2) initialized 通知(不需要响应)
try:
await client.post(
url,
headers=req_headers,
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
)
except Exception:
pass
# 3) 目标请求
rid = 2 if method != "tools/call" else 3
r2 = await client.post(
url,
headers=req_headers,
json={
"jsonrpc": "2.0",
"id": rid,
"method": method,
"params": params,
},
)
if r2.status_code >= 400:
logger.warning(f"[MCP] {method} 失败 {url} -> HTTP {r2.status_code}")
return None
return _parse_sse(r2.text)
async def list_mcp_tools(server: dict, timeout: float = 30.0) -> list:
"""
拉取 MCP 服务器的工具列表。
server: {url, transport_type, headers}
返回 MCP tools 数组(每个含 name/description/inputSchema)。
"""
url = (server.get("url") or "").strip()
if not url:
return []
# 缓存
now = asyncio.get_event_loop().time()
cached = _TOOLS_CACHE.get(url)
if cached and (now - cached[0]) < _TOOLS_CACHE_TTL:
return cached[1]
try:
data = await _handshake_and_request(
url, server.get("headers") or {}, "tools/list", {}, timeout
)
tools = []
if data and "result" in data:
tools = data["result"].get("tools", []) or []
_TOOLS_CACHE[url] = (now, tools)
return tools
except Exception as e:
logger.warning(f"[MCP] 拉取工具失败 {url}: {e}")
return []
async def call_mcp_tool(
server: dict, tool_name: str, arguments: dict, timeout: float = 60.0
) -> str:
"""
在 MCP 服务器上执行一个工具,返回可供 LLM 消费的文本结果。
"""
url = (server.get("url") or "").strip()
if not url:
return f"[MCP 工具 {tool_name} 执行失败:服务端地址为空]"
logger.info(f"[MCP] 执行工具调用 tool={tool_name} server={server.get('name')} url={url}")
try:
data = await _handshake_and_request(
url,
server.get("headers") or {},
"tools/call",
{"name": tool_name, "arguments": arguments or {}},
timeout,
)
if not data:
return f"[MCP 工具 {tool_name} 调用失败:无响应]"
if "error" in data:
err = data["error"]
return f"[MCP 工具 {tool_name} 返回错误] {json.dumps(err, ensure_ascii=False)}"
result = data.get("result", {})
content = result.get("content", []) or []
parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(item.get("text", ""))
else:
parts.append(json.dumps(item, ensure_ascii=False))
text = "\n".join(parts).strip()
if result.get("isError"):
return f"[MCP 工具 {tool_name} 执行报错]\n{text}"
return text or f"[MCP 工具 {tool_name} 返回空结果]"
except Exception as e:
logger.warning(f"[MCP] 工具执行异常 {tool_name} @ {url}: {e}")
return f"[MCP 工具 {tool_name} 执行异常:{e}]"