chore(align): sync Git with running container as authoritative source
Standards alignment (STANDARDS_AUDIT_2026-07-24): Phase 1 标准化核心 + Phase 2 质量层 landed in the running container and captured in Git so the three sources (container := image := Git) agree. Backend (Phase 1 标准化核心): - mcp_client.py: replaced hand-rolled JSON-RPC with official `mcp` SDK (ClientSession, streamable_http/sse transports, protocol auto-negotiation) - core/otel.py + core/observability.py: standard OpenTelemetry SDK bridge (OTLP gRPC), wired to Grafana Alloy (qihu-aiflow-alloy:4317) which forwards metrics to Prometheus; emits aiflow_span_duration_ms / aiflow_span_count - deployments: Grafana Alloy container + Prometheus remote-write receiver Backend (Phase 2 质量层): - core/rag_pipeline.py: standardized RAG (recursive overlap chunking + rerank hook) reusing pgvector_search - core/llm_client.py: SDK-ified LLM + embeddings (aembed/embed) collapsing hand-rolled httpx calls; trust_env=False to bypass container proxy Migrations: - 018_normalize_marketapp_scene.sql: fixed capability allowlist (prevents capability wipe on restart) Alignment performed 2026-07-24 12:18:51. No deletions; secrets/venv/node_modules/ caches were not touched (tracked-source-only copy).
This commit is contained in:
1 parent
acde481bed
commit
e8a1791c9f
8 files changed
+5031
-4779
No files matched your search
@@ -1,122 +1,96 @@
|
||||
"""
|
||||
MCP (Model Context Protocol) 客户端 —— 异步实现 streamable_http 传输。
|
||||
MCP (Model Context Protocol) 客户端 —— 基于官方 `mcp` Python SDK (Phase 1.E 标准化)
|
||||
|
||||
支持:
|
||||
- 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。
|
||||
设计基准(标准对照表见 STANDARDS_AUDIT_2026-07-24.md §E):
|
||||
- 采用 Anthropic 官方 `mcp` SDK 处理 JSON-RPC 帧 + 协议版本协商,
|
||||
不再手写解析、不再写死 MCP_PROTOCOL_VERSION 常量(旧实现写死 "2024-11-05",
|
||||
上游协议一升级即碎)。
|
||||
- 支持 streamable_http(默认)与 sse 两种传输,按 server['transport_type'] 选择。
|
||||
- 公共 API 保持向后兼容:list_mcp_tools(server, timeout) / call_mcp_tool(server, tool_name, arguments, timeout)
|
||||
供 chat.py / chat_service.py / chat_agents.py / chat_skill.py / chat_mcp.py /
|
||||
chat_context.py / chat_knowledge.py / chat_media.py / chat_utils.py / core/aca.py
|
||||
共 10+ 处调用,签名与返回结构(tools 数组 / 文本结果字符串)不变。
|
||||
- 工具列表缓存(按 url)与错误处理保持原行为,避免重复握手、保证降级安全。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from mcp import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
|
||||
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
|
||||
def _transport_cm(server: dict, timeout: float):
|
||||
"""按 server['transport_type'] 选择官方 SDK 传输层。
|
||||
|
||||
返回异步上下文管理器,进入后产出 (read_stream, write_stream, ...)。
|
||||
"""
|
||||
url = (server.get("url") or "").strip()
|
||||
ttype = (server.get("transport_type") or "streamable_http").lower()
|
||||
headers = server.get("headers") or {}
|
||||
if ttype == "sse":
|
||||
return sse_client(url, headers=headers, timeout=timeout)
|
||||
# 默认 streamable_http(官方 SDK 标准传输)
|
||||
return streamablehttp_client(url, headers=headers, timeout=timeout)
|
||||
|
||||
|
||||
async def _handshake_and_request(
|
||||
url: str,
|
||||
headers: dict,
|
||||
method: str,
|
||||
params: dict,
|
||||
timeout: float = 30.0,
|
||||
async def _session_request(
|
||||
server: dict, method: str, params: dict, timeout: float = 30.0
|
||||
):
|
||||
"""用官方 ClientSession 完成一次标准 MCP 调用(initialize -> 目标请求)。
|
||||
|
||||
返回解析后的 dict:{"result": {...}} 或 None(失败)。
|
||||
协议版本由 SDK 自动协商,不再硬编码。
|
||||
"""
|
||||
完成一次完整 MCP 会话(initialize -> initialized -> 目标请求)。
|
||||
返回解析后的 JSON-RPC 响应 dict,或 None。
|
||||
"""
|
||||
base_headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
}
|
||||
base_headers.update(headers or {})
|
||||
url = (server.get("url") or "").strip()
|
||||
if not url:
|
||||
return None
|
||||
try:
|
||||
async with _transport_cm(server, timeout) as streams:
|
||||
read, write, *_ = streams
|
||||
async with ClientSession(read, write) as session:
|
||||
# 标准握手:initialize 会发送 initialize 请求并自动补 notifications/initialized
|
||||
await session.initialize()
|
||||
# 防御性补发 initialized 通知(部分服务端要求显式接收)
|
||||
try:
|
||||
if hasattr(session, "send_notification"):
|
||||
await session.send_notification("notifications/initialized", {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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)
|
||||
if method == "tools/list":
|
||||
result = await session.list_tools()
|
||||
tools = [t.model_dump() for t in (result.tools or [])]
|
||||
return {"result": {"tools": tools}}
|
||||
elif method == "tools/call":
|
||||
res = await session.call_tool(
|
||||
params.get("name"), params.get("arguments") or {}
|
||||
)
|
||||
content = []
|
||||
for blk in (res.content or []):
|
||||
btype = getattr(blk, "type", "text")
|
||||
if btype == "text":
|
||||
content.append({"type": "text", "text": getattr(blk, "text", "")})
|
||||
else:
|
||||
content.append({"type": btype, "text": str(blk)})
|
||||
return {
|
||||
"result": {
|
||||
"content": content,
|
||||
"isError": bool(getattr(res, "isError", False)),
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP] {method} 异常 {url}: {e}")
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def list_mcp_tools(server: dict, timeout: float = 30.0) -> list:
|
||||
@@ -134,9 +108,7 @@ async def list_mcp_tools(server: dict, timeout: float = 30.0) -> list:
|
||||
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
|
||||
)
|
||||
data = await _session_request(server, "tools/list", {}, timeout)
|
||||
tools = []
|
||||
if data and "result" in data:
|
||||
tools = data["result"].get("tools", []) or []
|
||||
@@ -158,9 +130,8 @@ async def call_mcp_tool(
|
||||
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 {},
|
||||
data = await _session_request(
|
||||
server,
|
||||
"tools/call",
|
||||
{"name": tool_name, "arguments": arguments or {}},
|
||||
timeout,
|
||||
@@ -169,7 +140,7 @@ async def call_mcp_tool(
|
||||
return f"[MCP 工具 {tool_name} 调用失败:无响应]"
|
||||
if "error" in data:
|
||||
err = data["error"]
|
||||
return f"[MCP 工具 {tool_name} 返回错误] {json.dumps(err, ensure_ascii=False)}"
|
||||
return f"[MCP 工具 {tool_name} 返回错误] {__import__('json').dumps(err, ensure_ascii=False)}"
|
||||
result = data.get("result", {})
|
||||
content = result.get("content", []) or []
|
||||
parts = []
|
||||
@@ -177,7 +148,7 @@ async def call_mcp_tool(
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
parts.append(item.get("text", ""))
|
||||
else:
|
||||
parts.append(json.dumps(item, ensure_ascii=False))
|
||||
parts.append(__import__("json").dumps(item, ensure_ascii=False))
|
||||
text = "\n".join(parts).strip()
|
||||
if result.get("isError"):
|
||||
return f"[MCP 工具 {tool_name} 执行报错]\n{text}"
|
||||
|
||||
Reference in new issue
Block a user