mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
feat(mcp): detect OAuth-protected remote MCP servers (#2363)
* feat(mcp): surface OAuth-required server tests * fix(mcp): show connection failure details in status cards --------- Co-authored-by: RockChinQ <rockchinq@gmail.com>
This commit is contained in:
@@ -446,15 +446,19 @@ class MCPService:
|
||||
persisted_session = runtime_mcp_session
|
||||
|
||||
async def _refresh_and_report() -> None:
|
||||
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
|
||||
if needs_start:
|
||||
await persisted_session.start()
|
||||
else:
|
||||
try:
|
||||
await persisted_session.refresh()
|
||||
except Exception:
|
||||
try:
|
||||
needs_start = (
|
||||
persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
|
||||
)
|
||||
if needs_start:
|
||||
await persisted_session.start()
|
||||
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
||||
else:
|
||||
try:
|
||||
await persisted_session.refresh()
|
||||
except Exception:
|
||||
await persisted_session.start()
|
||||
finally:
|
||||
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
||||
|
||||
coroutine = _refresh_and_report()
|
||||
else:
|
||||
@@ -471,8 +475,11 @@ class MCPService:
|
||||
async def _run_and_cleanup() -> None:
|
||||
try:
|
||||
await test_session.start()
|
||||
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
|
||||
finally:
|
||||
# start() raises for a failed connection. Preserve the
|
||||
# terminal runtime state so the UI can render actionable
|
||||
# failure phases such as OAuth-required.
|
||||
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
|
||||
try:
|
||||
await test_session.shutdown()
|
||||
except Exception as exc:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import enum
|
||||
import json
|
||||
import math
|
||||
@@ -206,6 +207,13 @@ class MCPSessionStatus(enum.Enum):
|
||||
ERROR = 'error'
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MCPOAuthChallenge:
|
||||
"""Bearer challenge metadata returned by an OAuth-protected MCP server."""
|
||||
|
||||
resource_metadata_url: str | None
|
||||
|
||||
|
||||
class _TransportReconnect(Exception):
|
||||
"""Internal signal: the Box stdio WS transport dropped but the managed
|
||||
process is still alive. Triggers a lightweight transport reconnect that
|
||||
@@ -265,6 +273,7 @@ class RuntimeMCPSession:
|
||||
_ready_event: asyncio.Event
|
||||
|
||||
error_message: str | None = None
|
||||
_public_error_code: str = 'runtime_error'
|
||||
|
||||
error_phase: MCPSessionErrorPhase | None = None
|
||||
|
||||
@@ -510,6 +519,13 @@ class RuntimeMCPSession:
|
||||
await self._init_streamable_http_server()
|
||||
return
|
||||
except Exception as e:
|
||||
if self._extract_oauth_challenge(e) is not None:
|
||||
self.error_phase = MCPSessionErrorPhase.OAUTH_REQUIRED
|
||||
self.ap.logger.info(
|
||||
f'MCP server {self.server_name}: remote server requires OAuth authorization; '
|
||||
'not falling back to SSE'
|
||||
)
|
||||
raise
|
||||
if not self._should_fallback_to_sse(e):
|
||||
self.ap.logger.info(
|
||||
f'MCP server {self.server_name}: Streamable HTTP transport failed '
|
||||
@@ -630,6 +646,7 @@ class RuntimeMCPSession:
|
||||
except Exception as e:
|
||||
self.status = MCPSessionStatus.ERROR
|
||||
self.error_message = str(e)
|
||||
self._public_error_code = self._classify_public_error(e)
|
||||
self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}')
|
||||
# Do NOT set _ready_event here — let _lifecycle_loop_with_retry
|
||||
# handle retries first. It will set the event when all retries
|
||||
@@ -752,6 +769,11 @@ class RuntimeMCPSession:
|
||||
except Exception as e:
|
||||
if self._shutdown_event.is_set():
|
||||
return # Shutdown requested, don't retry
|
||||
if self.error_phase == MCPSessionErrorPhase.OAUTH_REQUIRED:
|
||||
self.retry_count = attempt + 1
|
||||
self.status = MCPSessionStatus.ERROR
|
||||
self._ready_event.set()
|
||||
return
|
||||
if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE:
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is not None and getattr(box_service, 'enabled', True):
|
||||
@@ -832,6 +854,39 @@ class RuntimeMCPSession:
|
||||
else:
|
||||
yield exc
|
||||
|
||||
@staticmethod
|
||||
def _classify_public_error(exc: BaseException) -> str:
|
||||
"""Expose a safe category without transport URLs, headers, or arguments."""
|
||||
for leaf in RuntimeMCPSession._iter_exception_leaves(exc):
|
||||
if isinstance(leaf, httpx.HTTPStatusError):
|
||||
return f'http_{leaf.response.status_code}'
|
||||
if isinstance(leaf, (httpx.TimeoutException, TimeoutError)):
|
||||
return 'connection_timeout'
|
||||
if isinstance(leaf, httpx.ConnectError):
|
||||
return 'connection_unreachable'
|
||||
return 'runtime_error'
|
||||
|
||||
@staticmethod
|
||||
def _extract_oauth_challenge(exc: BaseException) -> MCPOAuthChallenge | None:
|
||||
"""Extract an OAuth Bearer challenge from a remote MCP connection failure."""
|
||||
for leaf in RuntimeMCPSession._iter_exception_leaves(exc):
|
||||
if not isinstance(leaf, httpx.HTTPStatusError) or leaf.response.status_code != 401:
|
||||
continue
|
||||
for header in leaf.response.headers.get_list('www-authenticate'):
|
||||
bearer_match = re.search(r'(?:^|,)\s*Bearer(?:\s|,|$)', header, flags=re.IGNORECASE)
|
||||
if bearer_match is None:
|
||||
continue
|
||||
metadata_match = re.search(
|
||||
r'(?:^|,)\s*resource_metadata\s*=\s*(?:"([^"]+)"|([^,\s]+))',
|
||||
header[bearer_match.end() :],
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if metadata_match is None:
|
||||
continue
|
||||
resource_metadata_url = metadata_match.group(1) or metadata_match.group(2)
|
||||
return MCPOAuthChallenge(resource_metadata_url=resource_metadata_url)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _should_fallback_to_sse(exc: BaseException) -> bool:
|
||||
"""Whether a Streamable HTTP failure matches legacy-SSE fallback.
|
||||
@@ -1374,7 +1429,7 @@ class RuntimeMCPSession:
|
||||
# environment values. Detailed diagnostics belong in AUDIT_VIEW
|
||||
# logs; resource-list responses expose only a stable status.
|
||||
'error_message': 'MCP runtime failed' if self.error_message else None,
|
||||
'error_code': 'runtime_error' if self.error_message else None,
|
||||
'error_code': self._public_error_code if self.error_message else None,
|
||||
'error_phase': self.error_phase.value if self.error_phase else None,
|
||||
'retry_count': self.retry_count,
|
||||
'tool_count': len(self.get_tools()),
|
||||
|
||||
@@ -52,6 +52,7 @@ class MCPSessionErrorPhase(enum.Enum):
|
||||
MCP_INIT = 'mcp_init'
|
||||
RUNTIME = 'runtime'
|
||||
TOOL_CALL = 'tool_call'
|
||||
OAUTH_REQUIRED = 'oauth_required'
|
||||
# Stdio MCP refused because Box is disabled in config or currently
|
||||
# unavailable. Not transient — retries would be pointless. The frontend
|
||||
# uses this phase to render a localized actionable message instead of
|
||||
|
||||
Reference in New Issue
Block a user