Skip to content

Commit 2a3f7f8

Browse files
wangchamRockChinQ
andauthored
fix(mcp): harden remote SSE fallback (#2324)
* fix(mcp): harden remote SSE fallback * fix(mcp): preserve transport errors during fallback --------- Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
1 parent 940234a commit 2a3f7f8

3 files changed

Lines changed: 355 additions & 18 deletions

File tree

src/langbot/pkg/provider/tools/loaders/mcp.py

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import re
77
import time
88
import typing
9-
from contextlib import AsyncExitStack
9+
from contextlib import AsyncExitStack, asynccontextmanager
1010
import traceback
1111
from langbot_plugin.api.entities.events import pipeline_query
1212
import sqlalchemy
@@ -18,6 +18,7 @@
1818
from mcp.client.stdio import stdio_client
1919
from mcp.client.sse import sse_client
2020
from mcp.client.streamable_http import streamable_http_client
21+
from mcp.shared.exceptions import McpError
2122
from pydantic import AnyUrl
2223

2324
from .. import loader
@@ -335,23 +336,34 @@ async def _init_sse_server(self):
335336

336337
await self.session.initialize()
337338

338-
async def _init_streamable_http_server(self):
339-
transport = await self.exit_stack.enter_async_context(
340-
streamable_http_client(
339+
@asynccontextmanager
340+
async def _streamable_http_session(self) -> typing.AsyncIterator[ClientSession]:
341+
"""Enter a fully initialized Streamable HTTP session as one context.
342+
343+
Initialization must happen inside the same context manager that owns the
344+
MCP transport. The SDK reports request failures by cancelling the host
345+
task and raises the real HTTP error from its TaskGroup during context
346+
exit. Keeping these nested contexts together guarantees a failed
347+
``__aenter__`` unwinds immediately, so callers see the HTTPStatusError
348+
instead of a detached CancelledError. It also owns the injected HTTPX
349+
client, which the MCP SDK deliberately does not close for callers.
350+
"""
351+
async with httpx.AsyncClient(
352+
headers=self.server_config.get('headers', {}),
353+
timeout=self.server_config.get('timeout', 10),
354+
follow_redirects=True,
355+
) as http_client:
356+
async with streamable_http_client(
341357
self.server_config['url'],
342-
http_client=httpx.AsyncClient(
343-
headers=self.server_config.get('headers', {}),
344-
timeout=self.server_config.get('timeout', 10),
345-
follow_redirects=True,
346-
),
347-
)
348-
)
349-
350-
read, write, _ = transport
358+
http_client=http_client,
359+
) as transport:
360+
read, write, _ = transport
361+
async with ClientSession(read, write) as session:
362+
await session.initialize()
363+
yield session
351364

352-
self.session = await self.exit_stack.enter_async_context(ClientSession(read, write))
353-
354-
await self.session.initialize()
365+
async def _init_streamable_http_server(self):
366+
self.session = await self.exit_stack.enter_async_context(self._streamable_http_session())
355367

356368
async def _init_remote_server(self):
357369
"""Connect to a remote MCP server, auto-detecting the transport.
@@ -366,9 +378,15 @@ async def _init_remote_server(self):
366378
await self._init_streamable_http_server()
367379
return
368380
except Exception as e:
381+
if not self._should_fallback_to_sse(e):
382+
self.ap.logger.info(
383+
f'MCP server {self.server_name}: Streamable HTTP transport failed '
384+
f'({self._describe_exception(e)}); not falling back to SSE'
385+
)
386+
raise
369387
self.ap.logger.info(
370-
f'MCP server {self.server_name}: Streamable HTTP transport failed '
371-
f'({self._describe_exception(e)}), falling back to SSE'
388+
f'MCP server {self.server_name}: Streamable HTTP initialize failed with a compatible HTTP status '
389+
f'({self._describe_exception(e)}), falling back to legacy SSE'
372390
)
373391

374392
# The Streamable HTTP attempt may have partially entered the transport /
@@ -575,6 +593,36 @@ def visit(e: BaseException) -> None:
575593
unique = [m for m in leaves if not (m in seen or seen.add(m))]
576594
return '; '.join(unique) if unique else f'{type(exc).__name__}: {exc}'
577595

596+
@staticmethod
597+
def _iter_exception_leaves(exc: BaseException) -> typing.Iterator[BaseException]:
598+
sub = getattr(exc, 'exceptions', None)
599+
if sub: # ExceptionGroup / BaseExceptionGroup
600+
for child in sub:
601+
yield from RuntimeMCPSession._iter_exception_leaves(child)
602+
else:
603+
yield exc
604+
605+
@staticmethod
606+
def _should_fallback_to_sse(exc: BaseException) -> bool:
607+
"""Whether a Streamable HTTP failure matches legacy-SSE fallback.
608+
609+
Only protocol-compatibility responses trigger fallback. Authentication,
610+
authorization, throttling, and server failures must remain visible
611+
instead of being retried against a different transport.
612+
613+
MCP SDK 1.26 translates an HTTP 404 initialize response into a synthetic
614+
``McpError(32600, 'Session terminated')`` rather than preserving the
615+
HTTPStatusError, so recognize that exact SDK sentinel as 404-compatible.
616+
"""
617+
fallback_statuses = {400, 404, 405}
618+
for leaf in RuntimeMCPSession._iter_exception_leaves(exc):
619+
if isinstance(leaf, httpx.HTTPStatusError):
620+
if leaf.response.status_code in fallback_statuses:
621+
return True
622+
elif isinstance(leaf, McpError) and leaf.error.code == 32600 and leaf.error.message == 'Session terminated':
623+
return True
624+
return False
625+
578626
_MONITOR_POLL_INTERVAL = 5
579627
_MONITOR_MAX_CONSECUTIVE_ERRORS = 3
580628

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import json
5+
from contextlib import asynccontextmanager
6+
from types import SimpleNamespace
7+
from typing import Any, cast
8+
from unittest.mock import AsyncMock, Mock
9+
10+
import httpx
11+
import pytest
12+
from aiohttp import web
13+
from mcp import types as mcp_types
14+
15+
from langbot.pkg.provider.tools.loaders.mcp import RuntimeMCPSession
16+
17+
18+
class _TransportProbe:
19+
def __init__(self, streamable_status: int | None) -> None:
20+
self.streamable_status = streamable_status
21+
self.streamable_posts = 0
22+
self.streamable_messages: list[str] = []
23+
self.sse_gets = 0
24+
self.sse_messages: list[str] = []
25+
self.streamable_request_started = asyncio.Event()
26+
self.release_streamable_request = asyncio.Event()
27+
self._sse_response: web.StreamResponse | None = None
28+
29+
async def handle_mcp_endpoint(self, request: web.Request) -> web.StreamResponse:
30+
if request.method == 'POST':
31+
self.streamable_posts += 1
32+
self.streamable_request_started.set()
33+
if self.streamable_status is None:
34+
await self.release_streamable_request.wait()
35+
return web.Response(status=204)
36+
if self.streamable_status == 200:
37+
message = await request.json()
38+
method = message.get('method', '')
39+
self.streamable_messages.append(method)
40+
if method == 'initialize':
41+
return web.json_response(
42+
{
43+
'jsonrpc': '2.0',
44+
'id': message['id'],
45+
'result': {
46+
'protocolVersion': mcp_types.LATEST_PROTOCOL_VERSION,
47+
'capabilities': {'tools': {}},
48+
'serverInfo': {'name': 'streamable-test', 'version': '1.0.0'},
49+
},
50+
}
51+
)
52+
if method == 'tools/list':
53+
return web.json_response(
54+
{
55+
'jsonrpc': '2.0',
56+
'id': message['id'],
57+
'result': {
58+
'tools': [
59+
{
60+
'name': 'echo',
61+
'description': 'Echo test input',
62+
'inputSchema': {'type': 'object'},
63+
}
64+
]
65+
},
66+
}
67+
)
68+
return web.Response(status=202)
69+
return web.Response(status=self.streamable_status)
70+
71+
self.sse_gets += 1
72+
response = web.StreamResponse(
73+
status=200,
74+
headers={
75+
'Content-Type': 'text/event-stream',
76+
'Cache-Control': 'no-cache',
77+
},
78+
)
79+
await response.prepare(request)
80+
self._sse_response = response
81+
await response.write(b'event: endpoint\ndata: /messages?session_id=test-session\n\n')
82+
try:
83+
while request.transport is not None and not request.transport.is_closing():
84+
await asyncio.sleep(0.05)
85+
except asyncio.CancelledError:
86+
raise
87+
return response
88+
89+
async def handle_sse_message(self, request: web.Request) -> web.Response:
90+
message = await request.json()
91+
method = message.get('method', '')
92+
self.sse_messages.append(method)
93+
94+
if method == 'initialize':
95+
response_message = {
96+
'jsonrpc': '2.0',
97+
'id': message['id'],
98+
'result': {
99+
'protocolVersion': mcp_types.LATEST_PROTOCOL_VERSION,
100+
'capabilities': {},
101+
'serverInfo': {'name': 'legacy-sse-test', 'version': '1.0.0'},
102+
},
103+
}
104+
assert self._sse_response is not None
105+
payload = json.dumps(response_message, separators=(',', ':'))
106+
await self._sse_response.write(f'event: message\ndata: {payload}\n\n'.encode())
107+
108+
return web.Response(status=202)
109+
110+
111+
@asynccontextmanager
112+
async def _transport_server(streamable_status: int | None):
113+
probe = _TransportProbe(streamable_status)
114+
application = web.Application()
115+
application.router.add_route('*', '/mcp', probe.handle_mcp_endpoint)
116+
application.router.add_post('/messages', probe.handle_sse_message)
117+
runner = web.AppRunner(application, shutdown_timeout=0.1)
118+
await runner.setup()
119+
site = web.TCPSite(runner, '127.0.0.1', 0)
120+
await site.start()
121+
server = cast(asyncio.Server, site._server)
122+
port = server.sockets[0].getsockname()[1]
123+
try:
124+
yield probe, f'http://127.0.0.1:{port}/mcp'
125+
finally:
126+
await runner.cleanup()
127+
128+
129+
def _session(url: str, *, timeout: float = 2) -> RuntimeMCPSession:
130+
app = cast(Any, SimpleNamespace(logger=Mock()))
131+
return RuntimeMCPSession(
132+
'remote-transport-test',
133+
{'uuid': 'srv-1', 'mode': 'remote', 'url': url, 'timeout': timeout},
134+
True,
135+
app,
136+
)
137+
138+
139+
def _contains_http_status(exc: BaseException, status_code: int) -> bool:
140+
return any(
141+
isinstance(leaf, httpx.HTTPStatusError) and leaf.response.status_code == status_code
142+
for leaf in RuntimeMCPSession._iter_exception_leaves(exc)
143+
)
144+
145+
146+
async def _close_session(session: RuntimeMCPSession) -> None:
147+
await session.exit_stack.aclose()
148+
149+
150+
@pytest.mark.asyncio
151+
async def test_remote_transport_real_streamable_http_success_keeps_session_usable():
152+
async with _transport_server(200) as (probe, url):
153+
session = _session(url)
154+
try:
155+
await session._init_remote_server()
156+
assert session.session is not None
157+
tools = await session.session.list_tools()
158+
assert [tool.name for tool in tools.tools] == ['echo']
159+
assert probe.streamable_posts >= 2
160+
assert probe.streamable_messages[:2] == ['initialize', 'notifications/initialized']
161+
assert 'tools/list' in probe.streamable_messages
162+
assert probe.sse_gets == 0
163+
finally:
164+
await _close_session(session)
165+
166+
167+
@pytest.mark.asyncio
168+
@pytest.mark.parametrize('status_code', [400, 404, 405])
169+
async def test_remote_transport_real_streamable_http_error_falls_back_to_legacy_sse(status_code: int):
170+
async with _transport_server(status_code) as (probe, url):
171+
session = _session(url)
172+
try:
173+
await session._init_remote_server()
174+
assert session.session is not None
175+
assert probe.streamable_posts == 1
176+
assert probe.sse_gets == 1
177+
assert 'initialize' in probe.sse_messages
178+
finally:
179+
await _close_session(session)
180+
181+
182+
@pytest.mark.asyncio
183+
@pytest.mark.parametrize('status_code', [401, 403, 406, 415, 429, 500])
184+
async def test_remote_transport_real_non_compatibility_error_does_not_fallback(status_code: int):
185+
async with _transport_server(status_code) as (probe, url):
186+
session = _session(url)
187+
try:
188+
with pytest.raises(BaseException) as exc_info:
189+
await session._init_remote_server()
190+
assert _contains_http_status(exc_info.value, status_code)
191+
assert probe.streamable_posts == 1
192+
assert probe.sse_gets == 0
193+
finally:
194+
await _close_session(session)
195+
196+
197+
@pytest.mark.asyncio
198+
async def test_remote_transport_real_timeout_does_not_fallback():
199+
async with _transport_server(None) as (probe, url):
200+
session = _session(url, timeout=0.05)
201+
try:
202+
with pytest.raises(BaseException) as exc_info:
203+
await session._init_remote_server()
204+
assert any(
205+
isinstance(leaf, httpx.TimeoutException)
206+
for leaf in RuntimeMCPSession._iter_exception_leaves(exc_info.value)
207+
)
208+
assert probe.streamable_posts == 1
209+
assert probe.sse_gets == 0
210+
finally:
211+
probe.release_streamable_request.set()
212+
await _close_session(session)
213+
214+
215+
@pytest.mark.asyncio
216+
@pytest.mark.parametrize('error_type', [httpx.ConnectError, httpx.ConnectTimeout])
217+
async def test_remote_transport_connection_errors_do_not_fallback(error_type: type[httpx.RequestError]):
218+
request = httpx.Request('POST', 'https://unreachable.invalid/mcp')
219+
error = error_type('connection failed', request=request)
220+
session = _session(str(request.url))
221+
session._init_streamable_http_server = AsyncMock(side_effect=error)
222+
session._init_sse_server = AsyncMock()
223+
224+
with pytest.raises(type(error)) as exc_info:
225+
await session._init_remote_server()
226+
227+
assert exc_info.value is error
228+
session._init_sse_server.assert_not_awaited()
229+
230+
231+
@pytest.mark.asyncio
232+
async def test_remote_transport_external_cancellation_is_not_converted_to_sse_fallback():
233+
async with _transport_server(None) as (probe, url):
234+
session = _session(url)
235+
task = asyncio.create_task(session._init_remote_server())
236+
await asyncio.wait_for(probe.streamable_request_started.wait(), timeout=2)
237+
task.cancel()
238+
try:
239+
with pytest.raises(asyncio.CancelledError):
240+
await task
241+
assert probe.sse_gets == 0
242+
finally:
243+
probe.release_streamable_request.set()
244+
await _close_session(session)

0 commit comments

Comments
 (0)