mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 20:14:36 +00:00
feat(mcp): add streamable HTTP and stdio (#1911)
* feat(mcp): add streamable HTTP alongside with frontend UI change, w/ support for stdio * fix(mcp): address copilot reviews * Update src/langbot/pkg/provider/tools/loaders/mcp.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: resolve copilot reviews * fix: Message -> MessageChunk * feat: upgrade mcp module * feat: add i18n * feat(mcp): enhance MCPCardComponent with mode badge and reorder select items in MCPFormDialog --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: WangCham <651122857@qq.com> Co-authored-by: Junyan Qin (Chin) <rockchinq@gmail.com>
This commit is contained in:
@@ -9,7 +9,7 @@ class MCPServer(Base):
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse
|
||||
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http
|
||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
|
||||
@@ -215,16 +215,24 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
parameters = json.loads(func.arguments)
|
||||
|
||||
func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query)
|
||||
|
||||
# Handle return value content
|
||||
tool_content = None
|
||||
if isinstance(func_ret, list) and len(func_ret) > 0 and isinstance(func_ret[0], provider_message.ContentElement):
|
||||
tool_content = func_ret
|
||||
else:
|
||||
tool_content = json.dumps(func_ret, ensure_ascii=False)
|
||||
|
||||
if is_stream:
|
||||
msg = provider_message.MessageChunk(
|
||||
role='tool',
|
||||
content=json.dumps(func_ret, ensure_ascii=False),
|
||||
content=tool_content,
|
||||
tool_call_id=tool_call.id,
|
||||
)
|
||||
else:
|
||||
msg = provider_message.Message(
|
||||
role='tool',
|
||||
content=json.dumps(func_ret, ensure_ascii=False),
|
||||
content=tool_content,
|
||||
tool_call_id=tool_call.id,
|
||||
)
|
||||
|
||||
|
||||
@@ -7,14 +7,18 @@ import traceback
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
import sqlalchemy
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
import uuid as uuid_module
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
from .. import loader
|
||||
from ....core import app
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
from ....entity.persistence import mcp as persistence_mcp
|
||||
|
||||
|
||||
@@ -35,7 +39,7 @@ class RuntimeMCPSession:
|
||||
|
||||
server_config: dict
|
||||
|
||||
session: ClientSession
|
||||
session: ClientSession | None
|
||||
|
||||
exit_stack: AsyncExitStack
|
||||
|
||||
@@ -52,6 +56,8 @@ class RuntimeMCPSession:
|
||||
|
||||
_ready_event: asyncio.Event
|
||||
|
||||
error_message: str | None = None
|
||||
|
||||
def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application):
|
||||
self.server_name = server_name
|
||||
self.server_uuid = server_config.get('uuid', '')
|
||||
@@ -100,6 +106,24 @@ class RuntimeMCPSession:
|
||||
|
||||
await self.session.initialize()
|
||||
|
||||
async def _init_streamable_http_server(self):
|
||||
transport = await self.exit_stack.enter_async_context(
|
||||
streamable_http_client(
|
||||
self.server_config['url'],
|
||||
http_client=httpx.AsyncClient(
|
||||
headers=self.server_config.get('headers', {}),
|
||||
timeout=self.server_config.get('timeout', 10),
|
||||
follow_redirects=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
read, write, _ = transport
|
||||
|
||||
self.session = await self.exit_stack.enter_async_context(ClientSession(read, write))
|
||||
|
||||
await self.session.initialize()
|
||||
|
||||
async def _lifecycle_loop(self):
|
||||
"""在后台任务中管理整个MCP会话的生命周期"""
|
||||
try:
|
||||
@@ -107,6 +131,8 @@ class RuntimeMCPSession:
|
||||
await self._init_stdio_python_server()
|
||||
elif self.server_config['mode'] == 'sse':
|
||||
await self._init_sse_server()
|
||||
elif self.server_config['mode'] == 'http':
|
||||
await self._init_streamable_http_server()
|
||||
else:
|
||||
raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}')
|
||||
|
||||
@@ -122,6 +148,7 @@ class RuntimeMCPSession:
|
||||
|
||||
except Exception as e:
|
||||
self.status = MCPSessionStatus.ERROR
|
||||
self.error_message = str(e)
|
||||
self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}')
|
||||
# 即使出错也要设置ready事件,让start()方法知道初始化已完成
|
||||
self._ready_event.set()
|
||||
@@ -154,6 +181,9 @@ class RuntimeMCPSession:
|
||||
raise Exception('Connection failed, please check URL')
|
||||
|
||||
async def refresh(self):
|
||||
if not self.session:
|
||||
return
|
||||
|
||||
self.functions.clear()
|
||||
|
||||
tools = await self.session.list_tools()
|
||||
@@ -163,18 +193,36 @@ class RuntimeMCPSession:
|
||||
for tool in tools.tools:
|
||||
|
||||
async def func(*, _tool=tool, **kwargs):
|
||||
if not self.session:
|
||||
raise Exception("MCP session is not connected")
|
||||
|
||||
result = await self.session.call_tool(_tool.name, kwargs)
|
||||
if result.isError:
|
||||
raise Exception(result.content[0].text)
|
||||
return result.content[0].text
|
||||
error_texts = []
|
||||
for content in result.content:
|
||||
if content.type == 'text':
|
||||
error_texts.append(content.text)
|
||||
raise Exception("\n".join(error_texts) if error_texts else "Unknown error from MCP tool")
|
||||
|
||||
result_contents: list[provider_message.ContentElement] = []
|
||||
for content in result.content:
|
||||
if content.type == 'text':
|
||||
result_contents.append(provider_message.ContentElement.from_text(content.text))
|
||||
elif content.type == 'image':
|
||||
result_contents.append(provider_message.ContentElement.from_image_base64(content.image_base64))
|
||||
elif content.type == 'resource':
|
||||
# TODO: Handle resource content
|
||||
pass
|
||||
|
||||
return result_contents
|
||||
|
||||
func.__name__ = tool.name
|
||||
|
||||
self.functions.append(
|
||||
resource_tool.LLMTool(
|
||||
name=tool.name,
|
||||
human_desc=tool.description,
|
||||
description=tool.description,
|
||||
human_desc=tool.description or "",
|
||||
description=tool.description or "",
|
||||
parameters=tool.inputSchema,
|
||||
func=func,
|
||||
)
|
||||
@@ -186,6 +234,7 @@ class RuntimeMCPSession:
|
||||
def get_runtime_info_dict(self) -> dict:
|
||||
return {
|
||||
'status': self.status.value,
|
||||
'error_message': self.error_message,
|
||||
'tool_count': len(self.get_tools()),
|
||||
'tools': [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user