mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-23 14:04:19 +00:00
feat(mcp): simplify external MCP server config to local/remote modes
Replace the three-way transport choice (stdio / sse / httpstream) for connecting LangBot to external MCP servers with two modes: local (stdio) and remote. Remote servers only require a URL; the runtime auto-detects the transport (tries Streamable HTTP, falls back to SSE). - provider/tools/loaders/mcp.py: add _init_remote_server() with Streamable-HTTP-then-SSE probing; dispatch 'remote' lifecycle, keep legacy sse/http branches for back-compat - plugin/connector.py: normalize legacy http/sse marketplace modes to 'remote' on Space install, preserving connection params - entity/persistence/mcp.py: document mode as stdio, remote (legacy: sse, http) - alembic 0006: idempotent data migration mapping existing sse/http rows to remote (downgrade maps back to http) - api/http/service/mcp.py: stash runtime_info (status + tool list) into test task metadata before tearing down the temp session - web: collapse mode dropdown to local/remote, remote renders URL+timeout only, edit auto-maps legacy sse/http to remote; show tools after test in create mode from task metadata; remove dead plugins/mcp-server/ tree - i18n: local/remote labels + mode/url hints across 8 locales
This commit is contained in:
@@ -141,15 +141,25 @@ class MCPService:
|
||||
|
||||
runtime_mcp_session: RuntimeMCPSession | None = None
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
|
||||
if server_name != '_':
|
||||
runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
|
||||
if runtime_mcp_session is None:
|
||||
raise ValueError(f'Server not found: {server_name}')
|
||||
|
||||
if runtime_mcp_session.status == MCPSessionStatus.ERROR:
|
||||
coroutine = runtime_mcp_session.start()
|
||||
else:
|
||||
coroutine = runtime_mcp_session.refresh()
|
||||
persisted_session = runtime_mcp_session
|
||||
|
||||
async def _refresh_and_report() -> None:
|
||||
if persisted_session.status == MCPSessionStatus.ERROR:
|
||||
await persisted_session.start()
|
||||
else:
|
||||
await persisted_session.refresh()
|
||||
# Surface the discovered tools so the config page can render them
|
||||
# even for an already-hosted server.
|
||||
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
||||
|
||||
coroutine = _refresh_and_report()
|
||||
else:
|
||||
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data)
|
||||
|
||||
@@ -160,6 +170,12 @@ class MCPService:
|
||||
async def _run_and_cleanup() -> None:
|
||||
try:
|
||||
await test_session.start()
|
||||
# Capture the runtime info (status + discovered tools) BEFORE
|
||||
# shutting the transient session down. The create/edit config
|
||||
# page has no persisted server to reload from, so without this
|
||||
# a successful test could only show "no tools found". The
|
||||
# frontend reads ctx.metadata.runtime_info to render the tools.
|
||||
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
|
||||
finally:
|
||||
try:
|
||||
await test_session.shutdown()
|
||||
@@ -171,7 +187,6 @@ class MCPService:
|
||||
|
||||
coroutine = _run_and_cleanup()
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
coroutine,
|
||||
kind='mcp-operation',
|
||||
|
||||
@@ -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, http
|
||||
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, remote (legacy: sse, http)
|
||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
# Markdown documentation captured from LangBot Space at install time so the
|
||||
# detail page can show docs even when the server is offline / has no tools.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""normalize mcp_servers transport mode to local/remote
|
||||
|
||||
The MCP transport selection for servers LangBot connects to was simplified
|
||||
from three persisted modes (``stdio`` / ``sse`` / ``http``) down to two:
|
||||
``stdio`` (local, Box-sandboxed) and ``remote`` (the runtime auto-detects
|
||||
Streamable HTTP vs. legacy SSE from the URL). This migration rewrites any
|
||||
existing ``sse`` / ``http`` rows to ``remote`` so the stored value matches the
|
||||
new two-option UI. The connection args (url / headers / timeout /
|
||||
ssereadtimeout) live in ``extra_args`` and are left untouched — the
|
||||
auto-detecting remote transport consumes them regardless.
|
||||
|
||||
Revision ID: 0006_normalize_mcp_remote_mode
|
||||
Revises: 0005_add_llm_context_length
|
||||
Create Date: 2026-06-21
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0006_normalize_mcp_remote_mode'
|
||||
down_revision = '0005_add_llm_context_length'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Idempotent data migration: collapse legacy remote transports into the
|
||||
# unified ``remote`` mode. Guard against the table being absent (truly empty
|
||||
# DB migrated before create_all()).
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'mcp_servers' not in inspector.get_table_names():
|
||||
return
|
||||
conn.execute(
|
||||
sa.text("UPDATE mcp_servers SET mode = 'remote' WHERE mode IN ('sse', 'http')")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The legacy distinction between ``sse`` and ``http`` cannot be recovered
|
||||
# from ``remote`` alone (the transport is auto-detected at runtime, not
|
||||
# stored). Map everything that is not ``stdio`` back to ``http`` as a
|
||||
# best-effort reversal — both legacy modes still route correctly in the
|
||||
# backend lifecycle dispatch.
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'mcp_servers' not in inspector.get_table_names():
|
||||
return
|
||||
conn.execute(
|
||||
sa.text("UPDATE mcp_servers SET mode = 'http' WHERE mode = 'remote'")
|
||||
)
|
||||
@@ -248,6 +248,15 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
|
||||
mode = mcp_data.get('mode') or 'stdio'
|
||||
extra_args = mcp_data.get('extra_args') or {}
|
||||
# The MCP transport selection was simplified to two modes: 'stdio'
|
||||
# (local, Box-sandboxed) and 'remote' (the runtime auto-detects
|
||||
# Streamable HTTP vs. legacy SSE from the URL). Marketplace records may
|
||||
# still carry the older 'http'/'sse' modes — normalize them to 'remote'
|
||||
# so the installed server shows up correctly in the two-option UI. The
|
||||
# connection args (url/headers/timeout/ssereadtimeout) are preserved and
|
||||
# consumed by the auto-detecting remote transport regardless.
|
||||
if mode in ('http', 'sse'):
|
||||
mode = 'remote'
|
||||
# Marketplace records carry the rendered README markdown; persist it so
|
||||
# the detail page Docs tab works offline and without a marketplace round-trip.
|
||||
readme = mcp_data.get('readme') or ''
|
||||
|
||||
@@ -167,6 +167,38 @@ class RuntimeMCPSession:
|
||||
|
||||
await self.session.initialize()
|
||||
|
||||
async def _init_remote_server(self):
|
||||
"""Connect to a remote MCP server, auto-detecting the transport.
|
||||
|
||||
The user only supplies a URL ("remote" mode); they should not have to
|
||||
know whether the server speaks the modern Streamable HTTP transport or
|
||||
the legacy HTTP+SSE transport. Following the MCP backwards-compatibility
|
||||
guidance, we try Streamable HTTP first and fall back to SSE when it
|
||||
fails (e.g. the endpoint returns 4xx to the initialize POST).
|
||||
"""
|
||||
try:
|
||||
await self._init_streamable_http_server()
|
||||
return
|
||||
except Exception as e:
|
||||
self.ap.logger.info(
|
||||
f'MCP server {self.server_name}: Streamable HTTP transport failed '
|
||||
f'({self._describe_exception(e)}), falling back to SSE'
|
||||
)
|
||||
|
||||
# The Streamable HTTP attempt may have partially entered the transport /
|
||||
# session into the exit stack before failing. Tear it down and start
|
||||
# from a clean stack before trying SSE so we do not leak connections.
|
||||
try:
|
||||
await self.exit_stack.aclose()
|
||||
except Exception as cleanup_err:
|
||||
self.ap.logger.debug(
|
||||
f'MCP server {self.server_name}: error cleaning up before SSE fallback: {cleanup_err}'
|
||||
)
|
||||
self.exit_stack = AsyncExitStack()
|
||||
self.session = None
|
||||
|
||||
await self._init_sse_server()
|
||||
|
||||
_MAX_RETRIES = 3
|
||||
_RETRY_DELAYS = [2, 4, 8]
|
||||
|
||||
@@ -175,6 +207,8 @@ class RuntimeMCPSession:
|
||||
try:
|
||||
if self.server_config['mode'] == 'stdio':
|
||||
await self._init_stdio_python_server()
|
||||
elif self.server_config['mode'] == 'remote':
|
||||
await self._init_remote_server()
|
||||
elif self.server_config['mode'] == 'sse':
|
||||
await self._init_sse_server()
|
||||
elif self.server_config['mode'] == 'http':
|
||||
|
||||
Reference in New Issue
Block a user