refactor(module11): restructure MCP client implementation and migrate to chapter 11
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
from typing import Protocol,List,Dict,Any
|
||||
|
||||
class MCPTransport(Protocol):
|
||||
"""MCP 传输层协议 —— 所有 transport必须实现如下方法"""
|
||||
|
||||
async def connect(self):
|
||||
"""建立连接"""
|
||||
...
|
||||
|
||||
async def list_tools(self):
|
||||
"""获取工具列表"""
|
||||
...
|
||||
|
||||
async def call_tool(self,name:str,args:dict):
|
||||
"""调用工具并返回文本结果"""
|
||||
...
|
||||
|
||||
async def cleanup(self):
|
||||
"""清理资源"""
|
||||
...
|
||||
@@ -0,0 +1,130 @@
|
||||
import json
|
||||
import uuid
|
||||
from typing import Optional,Literal
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class HttpMCPTransport:
|
||||
def __init__(self,url:str=None):
|
||||
"""
|
||||
MCP 客户端 - 支持 stdio 与 HTTP 两种传输方式
|
||||
|
||||
:param url: http模式的端点 URL
|
||||
"""
|
||||
if not url:
|
||||
raise ValueError("HTTP模式必须提供url参数")
|
||||
self.url = url
|
||||
self.session_id:Optional[str] = None
|
||||
self.http_client:Optional[httpx.AsyncClient] = None
|
||||
|
||||
|
||||
|
||||
async def connect(self):
|
||||
"""建立MCP长连接(一次连接,多次调用)"""
|
||||
if self.http_client:
|
||||
return
|
||||
self.http_client = httpx.AsyncClient(timeout=60.0)
|
||||
|
||||
# 发送 initialize 请求
|
||||
response = await self._http_request("initialize",{
|
||||
"protocolVersion":"2024-11-05",
|
||||
"capabilities":{},
|
||||
"clientInfo":{"name":"mcp-client","version":"1.0"}
|
||||
})
|
||||
|
||||
# 保存session ID
|
||||
if response and "result" in response:
|
||||
# Session ID在响应头内
|
||||
pass # 已在_http_request中处理
|
||||
|
||||
|
||||
async def list_tools(self):
|
||||
"""查询工具列表,为LLM建立上下文用"""
|
||||
if not self.http_client:
|
||||
raise RuntimeError("未连接,请先 connect()")
|
||||
|
||||
result = await self._http_request("tools/list")
|
||||
if result and "result" in result:
|
||||
tools = result["result"].get("tools",[])
|
||||
return [
|
||||
{
|
||||
"name":tool["name"],
|
||||
"description":tool["description"],
|
||||
"input_schema":tool.get("inputSchema",{})
|
||||
}
|
||||
for tool in tools
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
async def call_tool(self,name:str,args:dict):
|
||||
"""调用工具(工程化:加上防御性处理)"""
|
||||
if not self.http_client:
|
||||
raise RuntimeError("未连接,请先 connect()")
|
||||
|
||||
result = await self._http_request("tools/call",{
|
||||
"name":name,
|
||||
"arguments":args
|
||||
})
|
||||
if result and "result" in result:
|
||||
content = result["result"].get("content",{})
|
||||
if content and len(content):
|
||||
return content[0].get("text",str(content[0]))
|
||||
|
||||
return "工具执行成功,但无文本返回"
|
||||
|
||||
|
||||
async def cleanup(self):
|
||||
"""关闭MCP服务、会话和transport"""
|
||||
if self.http_client:
|
||||
await self.http_client.aclose()
|
||||
self.http_client = None
|
||||
self.session_id = None
|
||||
|
||||
|
||||
async def _http_request(self, method: str, params: dict = None):
|
||||
""" 发送 HTTP JSON-RPC 请求 """
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": str(uuid.uuid4()),
|
||||
"method": method,
|
||||
}
|
||||
if params:
|
||||
payload["params"] = params
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream"
|
||||
}
|
||||
if self.session_id:
|
||||
headers["Mcp-Session-Id"] = self.session_id
|
||||
|
||||
response = await self.http_client.post(
|
||||
self.url,
|
||||
json=payload,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if "Mcp-Session-Id" in response.headers:
|
||||
self.session_id = response.headers["Mcp-Session-Id"]
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
# 判断响应类型
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
|
||||
if "text/event-stream" in content_type:
|
||||
# SSE 流式响应(本地服务器)
|
||||
for line in response.text.split('\n'):
|
||||
line = line.strip()
|
||||
if line.startswith("data:"):
|
||||
data_str = line[5:].strip()
|
||||
try:
|
||||
return json.loads(data_str)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None
|
||||
else:
|
||||
# 普通 JSON 响应(云端服务)
|
||||
return response.json()
|
||||
@@ -0,0 +1,89 @@
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Optional,Literal
|
||||
|
||||
from mcp import ClientSession,StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
|
||||
class StdioMCPTransport:
|
||||
def __init__(self,command:str=None,args:list[str]=None,env:dict=None):
|
||||
"""
|
||||
MCP 客户端 - 支持 stdio 与 HTTP 两种传输方式
|
||||
|
||||
:param transport: 传输模式 "stdio" / "http"
|
||||
:param command: stdio 模式的命令 (如npx)
|
||||
:param args: stdio 模式的参数
|
||||
:param env: stdio 模式的环境变量
|
||||
"""
|
||||
# MCP启动方式(npx/uvx/python -m xxx)
|
||||
self.params = StdioServerParameters(command=command,args=args,env=env)
|
||||
# 工程核心:资源栈
|
||||
self.exit_stack = AsyncExitStack()
|
||||
# 连接会话(长连接)
|
||||
self.session:Optional[ClientSession]=None
|
||||
|
||||
|
||||
|
||||
async def connect(self):
|
||||
"""建立MCP长连接(一次连接,多次调用)"""
|
||||
if self.session:
|
||||
return # 已连接无需重复
|
||||
# 进入transport(读/写管道)
|
||||
transport = await self.exit_stack.enter_async_context(
|
||||
stdio_client(self.params)
|
||||
)
|
||||
# 创建JSON-RPC对话
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
ClientSession(transport[0],transport[1])
|
||||
)
|
||||
# 等待MCP服务器返回工具清单
|
||||
await self.session.initialize()
|
||||
|
||||
|
||||
|
||||
async def list_tools(self):
|
||||
"""查询工具列表,为LLM建立上下文用"""
|
||||
if not self.session:
|
||||
raise RuntimeError("未连接,请先 connect()")
|
||||
|
||||
result = await self.session.list_tools()
|
||||
|
||||
# 🔍 调试:打印工具的完整信息,确认工具是否被正确封装
|
||||
# if result.tools:
|
||||
# import json
|
||||
# # 使用 model_dump() (Pydantic v2) 或 dict() (v1) 查看原始数据
|
||||
# first_tool = result.tools[0]
|
||||
# print(f"\n🔍 [DEBUG] 原始工具数据: {first_tool}\n")
|
||||
|
||||
# 转为纯字典,LLM能读
|
||||
return[
|
||||
{
|
||||
"name":tool.name,
|
||||
"description":tool.description,
|
||||
"input_schema":tool.inputSchema
|
||||
}
|
||||
for tool in result.tools
|
||||
]
|
||||
|
||||
|
||||
|
||||
async def call_tool(self,name:str,args:dict):
|
||||
"""调用工具(工程化:加上防御性处理)"""
|
||||
if not self.session:
|
||||
raise RuntimeError("未连接,请先connect()")
|
||||
|
||||
result = await self.session.call_tool(name,args)
|
||||
|
||||
# 有些工具可能执行成功但无文本返回
|
||||
if hasattr(result,"content") and result.content:
|
||||
return result.content[0].text
|
||||
|
||||
return "工具执行成功,但无文本返回"
|
||||
|
||||
|
||||
|
||||
async def cleanup(self):
|
||||
"""关闭MCP服务、会话和transport"""
|
||||
if self.session:
|
||||
await self.exit_stack.aclose()
|
||||
self.session = None
|
||||
Reference in New Issue
Block a user