chore: upgrade Pipecat to 1.13.0 and adapt MCP 2.x
This commit is contained in:
@@ -2,7 +2,9 @@
|
|||||||
# webrtc -> SmallWebRTCTransport / SmallWebRTCConnection + aiortc
|
# webrtc -> SmallWebRTCTransport / SmallWebRTCConnection + aiortc
|
||||||
# silero -> 本地 VAD(判断用户说话起止),语音必备
|
# silero -> 本地 VAD(判断用户说话起止),语音必备
|
||||||
# openai -> OpenAI 兼容的 LLM/STT/TTS 客户端(DeepSeek、SenseVoice、CosyVoice 都走它)
|
# openai -> OpenAI 兼容的 LLM/STT/TTS 客户端(DeepSeek、SenseVoice、CosyVoice 都走它)
|
||||||
pipecat-ai[webrtc,websocket,silero,openai,mcp]==1.7.0
|
pipecat-ai[webrtc,websocket,silero,openai,mcp]==1.13.0
|
||||||
|
# MCP 2.x 使用 httpx2;普通业务 HTTP 请求继续使用 httpx。
|
||||||
|
httpx2>=2.10.0,<3
|
||||||
Pillow>=11.1.0,<13
|
Pillow>=11.1.0,<13
|
||||||
|
|
||||||
# FastGPT 类型助手:本地 SDK(包 /api/v1/chat/completions 流式 + chatId 会话)
|
# FastGPT 类型助手:本地 SDK(包 /api/v1/chat/completions 流式 + chatId 会话)
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import timedelta
|
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
import httpx
|
import httpx2 as httpx
|
||||||
from mcp import ClientSession
|
from mcp import ClientSession
|
||||||
from mcp.client.sse import sse_client
|
from mcp.client.sse import sse_client
|
||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
from mcp.types import PaginatedRequestParams
|
||||||
|
|
||||||
from models import RuntimeMcpServer
|
from models import RuntimeMcpServer
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ class McpToolClient:
|
|||||||
server.url,
|
server.url,
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
) as streams:
|
) as streams:
|
||||||
read_stream, write_stream, _ = streams
|
read_stream, write_stream = streams
|
||||||
yield read_stream, write_stream
|
yield read_stream, write_stream
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ class McpToolClient:
|
|||||||
async with ClientSession(
|
async with ClientSession(
|
||||||
read_stream,
|
read_stream,
|
||||||
write_stream,
|
write_stream,
|
||||||
read_timeout_seconds=timedelta(seconds=timeout),
|
read_timeout_seconds=timeout,
|
||||||
) as session:
|
) as session:
|
||||||
await session.initialize()
|
await session.initialize()
|
||||||
yield session
|
yield session
|
||||||
@@ -105,9 +105,11 @@ class McpToolClient:
|
|||||||
async with self._session(server) as session:
|
async with self._session(server) as session:
|
||||||
cursor: str | None = None
|
cursor: str | None = None
|
||||||
while True:
|
while True:
|
||||||
result = await session.list_tools(cursor=cursor)
|
result = await session.list_tools(
|
||||||
|
params=PaginatedRequestParams(cursor=cursor) if cursor else None,
|
||||||
|
)
|
||||||
for tool in result.tools:
|
for tool in result.tools:
|
||||||
input_schema = dict(tool.inputSchema or {})
|
input_schema = dict(tool.input_schema or {})
|
||||||
discovered.append(
|
discovered.append(
|
||||||
{
|
{
|
||||||
"name": tool.name,
|
"name": tool.name,
|
||||||
@@ -116,7 +118,7 @@ class McpToolClient:
|
|||||||
"schema_hash": schema_hash(input_schema),
|
"schema_hash": schema_hash(input_schema),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
cursor = result.nextCursor
|
cursor = result.next_cursor
|
||||||
if not cursor:
|
if not cursor:
|
||||||
break
|
break
|
||||||
return discovered
|
return discovered
|
||||||
@@ -132,14 +134,14 @@ class McpToolClient:
|
|||||||
result = await session.call_tool(
|
result = await session.call_tool(
|
||||||
remote_tool_name,
|
remote_tool_name,
|
||||||
arguments=arguments,
|
arguments=arguments,
|
||||||
read_timeout_seconds=timedelta(seconds=server.timeout_seconds),
|
read_timeout_seconds=server.timeout_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
data = self._normalize_result(result)
|
data = self._normalize_result(result)
|
||||||
encoded = json.dumps(data, ensure_ascii=False, default=str).encode("utf-8")
|
encoded = json.dumps(data, ensure_ascii=False, default=str).encode("utf-8")
|
||||||
if len(encoded) > MAX_MCP_RESULT_BYTES:
|
if len(encoded) > MAX_MCP_RESULT_BYTES:
|
||||||
raise McpClientError("MCP 工具响应超过 1 MB 限制")
|
raise McpClientError("MCP 工具响应超过 1 MB 限制")
|
||||||
if bool(result.isError):
|
if bool(result.is_error):
|
||||||
message = data.get("text") or "MCP 工具返回执行错误"
|
message = data.get("text") or "MCP 工具返回执行错误"
|
||||||
raise McpClientError(str(message)[:2048])
|
raise McpClientError(str(message)[:2048])
|
||||||
return data
|
return data
|
||||||
@@ -171,6 +173,6 @@ class McpToolClient:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"text": "\n".join(text_parts),
|
"text": "\n".join(text_parts),
|
||||||
"structuredContent": result.structuredContent,
|
"structuredContent": result.structured_content,
|
||||||
"content": content,
|
"content": content,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,89 @@
|
|||||||
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import httpx2
|
||||||
|
|
||||||
from models import RuntimeMcpServer
|
from models import RuntimeMcpServer
|
||||||
from schemas import McpServerUpsert
|
from schemas import McpServerUpsert
|
||||||
from services.tools.mcp_client import McpToolClient
|
from services.tools.mcp_client import McpClientError, McpToolClient
|
||||||
|
|
||||||
|
|
||||||
class McpTransportTests(unittest.IsolatedAsyncioTestCase):
|
class McpTransportTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_streamable_http_discovery_and_call_with_real_sdk(self):
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
def respond(request):
|
||||||
|
self.assertEqual(request.headers["Authorization"], "Bearer test")
|
||||||
|
message = json.loads(request.content)
|
||||||
|
requests.append(message)
|
||||||
|
if "id" not in message:
|
||||||
|
return httpx2.Response(202)
|
||||||
|
if message["method"] == "initialize":
|
||||||
|
result = {
|
||||||
|
"protocolVersion": "2025-06-18",
|
||||||
|
"capabilities": {"tools": {}},
|
||||||
|
"serverInfo": {"name": "test", "version": "1"},
|
||||||
|
}
|
||||||
|
elif message["method"] == "tools/list":
|
||||||
|
if (message.get("params") or {}).get("cursor") == "page2":
|
||||||
|
result = {"tools": []}
|
||||||
|
else:
|
||||||
|
result = {
|
||||||
|
"tools": [{
|
||||||
|
"name": "get_order",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
}],
|
||||||
|
"nextCursor": "page2",
|
||||||
|
}
|
||||||
|
elif message["method"] == "tools/call":
|
||||||
|
result = {
|
||||||
|
"content": [{"type": "text", "text": "已发货"}],
|
||||||
|
"structuredContent": {"status": "shipped"},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
self.fail(f"Unexpected MCP method: {message['method']}")
|
||||||
|
return httpx2.Response(200, json={
|
||||||
|
"jsonrpc": "2.0", "id": message["id"], "result": result,
|
||||||
|
})
|
||||||
|
|
||||||
|
client_class = httpx2.AsyncClient
|
||||||
|
|
||||||
|
def client_factory(**kwargs):
|
||||||
|
return client_class(transport=httpx2.MockTransport(respond), **kwargs)
|
||||||
|
|
||||||
|
server = RuntimeMcpServer(
|
||||||
|
id="mcp_http",
|
||||||
|
url="https://mcp.example.com/mcp",
|
||||||
|
secret_headers={"Authorization": "Bearer test"},
|
||||||
|
)
|
||||||
|
client = McpToolClient()
|
||||||
|
with patch("services.tools.mcp_client.httpx.AsyncClient", client_factory):
|
||||||
|
tools = await client.list_tools(server)
|
||||||
|
listings = [item for item in requests if item["method"] == "tools/list"]
|
||||||
|
self.assertEqual(len(listings), 2)
|
||||||
|
self.assertEqual(listings[1]["params"]["cursor"], "page2")
|
||||||
|
result = await client.call_tool(server, "get_order", {"order_id": "A-100"})
|
||||||
|
|
||||||
|
self.assertEqual(tools[0]["name"], "get_order")
|
||||||
|
self.assertEqual(result["text"], "已发货")
|
||||||
|
self.assertEqual(result["structuredContent"], {"status": "shipped"})
|
||||||
|
call = next(item for item in requests if item["method"] == "tools/call")
|
||||||
|
self.assertEqual(call["params"]["arguments"], {"order_id": "A-100"})
|
||||||
|
|
||||||
|
async def test_sdk_timeout_has_readable_error(self):
|
||||||
|
@asynccontextmanager
|
||||||
|
async def timed_out(*args):
|
||||||
|
raise httpx2.ReadTimeout("read timed out")
|
||||||
|
yield
|
||||||
|
|
||||||
|
server = RuntimeMcpServer(id="mcp_timeout", url="https://mcp.example.com/mcp")
|
||||||
|
client = McpToolClient()
|
||||||
|
with patch.object(client, "_transport_streams", timed_out):
|
||||||
|
with self.assertRaisesRegex(McpClientError, "超时"):
|
||||||
|
await client.list_tools(server)
|
||||||
|
|
||||||
def test_server_schema_accepts_sse(self):
|
def test_server_schema_accepts_sse(self):
|
||||||
server = McpServerUpsert(
|
server = McpServerUpsert(
|
||||||
name="旧版 MCP 服务",
|
name="旧版 MCP 服务",
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ from services.pipecat.service_factory import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class LLMServiceFactoryTest(unittest.TestCase):
|
class LLMServiceFactoryTest(unittest.IsolatedAsyncioTestCase):
|
||||||
def test_openai_compatible_llm_converts_async_tool_developer_result(self):
|
async def test_openai_compatible_llm_converts_async_tool_developer_result(self):
|
||||||
service = create_llm(
|
service = create_llm(
|
||||||
AssistantConfig(
|
AssistantConfig(
|
||||||
llm_interface_type="openai-llm",
|
llm_interface_type="openai-llm",
|
||||||
@@ -34,7 +34,7 @@ class LLMServiceFactoryTest(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
params = service.get_llm_adapter().get_llm_invocation_params(
|
params = await service.get_llm_adapter().get_llm_invocation_params(
|
||||||
context,
|
context,
|
||||||
convert_developer_to_user=not service.supports_developer_role,
|
convert_developer_to_user=not service.supports_developer_role,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user