mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-10 11:57:20 +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:
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
@@ -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
|
||||
|
||||
@@ -1009,6 +1009,37 @@ class TestMCPServiceTestMCPServer:
|
||||
# Verify - returns task ID
|
||||
assert task_id == 123
|
||||
|
||||
@pytest.mark.parametrize('refresh_first', [False, True])
|
||||
async def test_persisted_test_preserves_failure_details(self, refresh_first):
|
||||
from langbot.pkg.provider.tools.loaders.mcp import MCPSessionStatus
|
||||
|
||||
runtime_info = {'status': 'error', 'error_message': 'HTTP 403: access denied'}
|
||||
session = SimpleNamespace(
|
||||
status=MCPSessionStatus.CONNECTED if refresh_first else MCPSessionStatus.ERROR,
|
||||
session=object(),
|
||||
refresh=AsyncMock(side_effect=RuntimeError('refresh failed')),
|
||||
start=AsyncMock(side_effect=RuntimeError('Connection failed, please check URL')),
|
||||
get_runtime_info_dict=Mock(return_value=runtime_info),
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def create_user_task(coroutine, **kwargs):
|
||||
captured.update(coroutine=coroutine, context=kwargs['context'])
|
||||
return SimpleNamespace(id=123)
|
||||
|
||||
ap = SimpleNamespace(
|
||||
tool_mgr=SimpleNamespace(mcp_tool_loader=SimpleNamespace(get_session=Mock(return_value=session))),
|
||||
task_mgr=SimpleNamespace(create_user_task=Mock(side_effect=create_user_task)),
|
||||
)
|
||||
service = _service(ap)
|
||||
service._require_server = AsyncMock(return_value=(_CONTEXT, {'name': 'existing-server'}))
|
||||
await service.test_mcp_server(_CONTEXT, 'existing-server', {})
|
||||
with pytest.raises(RuntimeError, match='Connection failed'):
|
||||
await captured['coroutine']
|
||||
assert captured['context'].metadata['runtime_info'] == runtime_info
|
||||
session.start.assert_awaited_once()
|
||||
assert session.refresh.await_count == int(refresh_first)
|
||||
|
||||
async def test_test_mcp_server_not_found_raises(self):
|
||||
"""Raises ValueError when server not found."""
|
||||
# Setup
|
||||
@@ -1052,6 +1083,45 @@ class TestMCPServiceTestMCPServer:
|
||||
ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once()
|
||||
assert task_id == 456
|
||||
|
||||
async def test_transient_test_preserves_runtime_info_after_connection_failure(self):
|
||||
runtime_info = {
|
||||
'status': 'error',
|
||||
'error_phase': 'oauth_required',
|
||||
'retry_count': 1,
|
||||
}
|
||||
mock_session = SimpleNamespace(
|
||||
server_name='oauth-server',
|
||||
start=AsyncMock(side_effect=RuntimeError('connection failed')),
|
||||
get_runtime_info_dict=Mock(return_value=runtime_info),
|
||||
shutdown=AsyncMock(),
|
||||
)
|
||||
ap = SimpleNamespace(
|
||||
tool_mgr=SimpleNamespace(
|
||||
mcp_tool_loader=SimpleNamespace(load_mcp_server=AsyncMock(return_value=mock_session))
|
||||
)
|
||||
)
|
||||
captured: dict = {}
|
||||
|
||||
def create_user_task(coroutine, **kwargs):
|
||||
captured['coroutine'] = coroutine
|
||||
captured['context'] = kwargs['context']
|
||||
return SimpleNamespace(id=457)
|
||||
|
||||
ap.task_mgr = SimpleNamespace(create_user_task=Mock(side_effect=create_user_task))
|
||||
service = _service(ap)
|
||||
|
||||
task_id = await service.test_mcp_server(
|
||||
_CONTEXT,
|
||||
'_',
|
||||
{'name': 'OAuth server', 'mode': 'remote', 'enable': True, 'extra_args': {}},
|
||||
)
|
||||
|
||||
assert task_id == 457
|
||||
with pytest.raises(RuntimeError, match='connection failed'):
|
||||
await captured['coroutine']
|
||||
assert captured['context'].metadata['runtime_info'] == runtime_info
|
||||
mock_session.shutdown.assert_awaited_once_with()
|
||||
|
||||
async def test_rejected_transient_test_session_is_shut_down(self):
|
||||
ap = SimpleNamespace()
|
||||
mock_session = MagicMock()
|
||||
|
||||
@@ -13,7 +13,15 @@ from aiohttp import web
|
||||
from mcp import types as mcp_types
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.provider.tools.loaders.mcp import MCPToolCallTimeoutError, RuntimeMCPSession
|
||||
from langbot.pkg.provider.tools.loaders.mcp import MCPSessionStatus, MCPToolCallTimeoutError, RuntimeMCPSession
|
||||
from langbot.pkg.provider.tools.loaders.mcp_stdio import MCPSessionErrorPhase
|
||||
|
||||
|
||||
TEST_EXECUTION_CONTEXT = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
|
||||
|
||||
TEST_EXECUTION_CONTEXT = ExecutionContext(
|
||||
@@ -24,8 +32,9 @@ TEST_EXECUTION_CONTEXT = ExecutionContext(
|
||||
|
||||
|
||||
class _TransportProbe:
|
||||
def __init__(self, streamable_status: int | None) -> None:
|
||||
def __init__(self, streamable_status: int | None, streamable_headers: dict[str, str] | None = None) -> None:
|
||||
self.streamable_status = streamable_status
|
||||
self.streamable_headers = streamable_headers or {}
|
||||
self.streamable_posts = 0
|
||||
self.streamable_messages: list[str] = []
|
||||
self.sse_gets = 0
|
||||
@@ -93,7 +102,7 @@ class _TransportProbe:
|
||||
}
|
||||
)
|
||||
return web.Response(status=202)
|
||||
return web.Response(status=self.streamable_status)
|
||||
return web.Response(status=self.streamable_status, headers=self.streamable_headers)
|
||||
|
||||
self.sse_gets += 1
|
||||
response = web.StreamResponse(
|
||||
@@ -136,8 +145,8 @@ class _TransportProbe:
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _transport_server(streamable_status: int | None):
|
||||
probe = _TransportProbe(streamable_status)
|
||||
async def _transport_server(streamable_status: int | None, streamable_headers: dict[str, str] | None = None):
|
||||
probe = _TransportProbe(streamable_status, streamable_headers)
|
||||
application = web.Application()
|
||||
application.router.add_route('*', '/mcp', probe.handle_mcp_endpoint)
|
||||
application.router.add_post('/messages', probe.handle_sse_message)
|
||||
@@ -265,6 +274,45 @@ async def test_remote_transport_real_non_compatibility_error_does_not_fallback(s
|
||||
await _close_session(session)
|
||||
|
||||
|
||||
def test_remote_transport_extracts_oauth_resource_metadata_from_bearer_challenge():
|
||||
request = httpx.Request('POST', 'https://mcp.example/mcp')
|
||||
response = httpx.Response(
|
||||
401,
|
||||
headers={
|
||||
'WWW-Authenticate': (
|
||||
'Basic realm="MCP", Bearer resource_metadata="https://mcp.example/.well-known/oauth-protected-resource"'
|
||||
)
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||
response.raise_for_status()
|
||||
|
||||
challenge = RuntimeMCPSession._extract_oauth_challenge(exc_info.value)
|
||||
|
||||
assert challenge is not None
|
||||
assert challenge.resource_metadata_url == 'https://mcp.example/.well-known/oauth-protected-resource'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_transport_oauth_challenge_sets_non_retryable_authorization_state():
|
||||
headers = {
|
||||
'WWW-Authenticate': 'Bearer resource_metadata="https://mcp.example/.well-known/oauth-protected-resource"'
|
||||
}
|
||||
async with _transport_server(401, headers) as (probe, url):
|
||||
session = _session(url)
|
||||
|
||||
await session._lifecycle_loop_with_retry()
|
||||
|
||||
assert session.status == MCPSessionStatus.ERROR
|
||||
assert session.error_phase == MCPSessionErrorPhase.OAUTH_REQUIRED
|
||||
assert session.retry_count == 1
|
||||
assert session._ready_event.is_set()
|
||||
assert probe.streamable_posts == 1
|
||||
assert probe.sse_gets == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_transport_real_timeout_does_not_fallback():
|
||||
async with _transport_server(None) as (probe, url):
|
||||
@@ -313,3 +361,25 @@ async def test_remote_transport_external_cancellation_is_not_converted_to_sse_fa
|
||||
finally:
|
||||
probe.release_streamable_request.set()
|
||||
await _close_session(session)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('error', 'expected'),
|
||||
[
|
||||
(httpx.ConnectError('secret host'), 'connection_unreachable'),
|
||||
(httpx.ReadTimeout('secret URL'), 'connection_timeout'),
|
||||
(TimeoutError('secret command'), 'connection_timeout'),
|
||||
(RuntimeError('secret environment'), 'runtime_error'),
|
||||
(
|
||||
httpx.HTTPStatusError(
|
||||
'secret response',
|
||||
request=httpx.Request('POST', 'https://example.test/?token=secret'),
|
||||
response=httpx.Response(403),
|
||||
),
|
||||
'http_403',
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_public_error_category_does_not_expose_exception_details(error, expected):
|
||||
grouped = ExceptionGroup('secret outer exception', [error])
|
||||
assert RuntimeMCPSession._classify_public_error(grouped) == expected
|
||||
|
||||
@@ -17,7 +17,7 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'pnpm exec vite --host 127.0.0.1 --port 4173',
|
||||
command: 'corepack pnpm@8.9.2 exec vite --host 127.0.0.1 --port 4173',
|
||||
url: 'http://127.0.0.1:4173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
|
||||
@@ -8,7 +8,14 @@ import React, {
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Braces, Loader2, Trash2, Wrench, XCircle } from 'lucide-react';
|
||||
import {
|
||||
Braces,
|
||||
Loader2,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
Wrench,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -101,7 +108,7 @@ function StatusDisplay({
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<XCircle className="size-5" />
|
||||
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
||||
<span className="font-medium">{t('mcp.connectionFailedStatus')}</span>
|
||||
</div>
|
||||
<div className="pl-7 text-sm text-red-500 space-y-0.5">
|
||||
<div>
|
||||
@@ -117,15 +124,41 @@ function StatusDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
if (runtimeInfo.error_phase === 'oauth_required') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-400">
|
||||
<ShieldAlert className="size-5" />
|
||||
<span className="font-medium">
|
||||
{t('mcp.oauthAuthorizationRequired')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pl-7 text-sm text-muted-foreground">
|
||||
{t('mcp.oauthAuthorizationRequiredSuggestion')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const httpStatus = runtimeInfo.error_code?.match(/^http_(\d{3})$/)?.[1];
|
||||
const errorDetail =
|
||||
runtimeInfo.error_code === 'connection_unreachable'
|
||||
? t('mcp.connectionUnreachable')
|
||||
: runtimeInfo.error_code === 'connection_timeout'
|
||||
? t('mcp.connectionTimeout')
|
||||
: httpStatus
|
||||
? t('mcp.connectionHttpError', { status: httpStatus })
|
||||
: runtimeInfo.error_message || t('mcp.unknownError');
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<XCircle className="size-5" />
|
||||
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
||||
<span className="font-medium">{t('mcp.connectionFailedStatus')}</span>
|
||||
</div>
|
||||
{runtimeInfo.error_message && (
|
||||
<div className="pl-7 text-sm text-red-500">
|
||||
{runtimeInfo.error_message}
|
||||
{errorDetail && (
|
||||
<div className="pl-7 whitespace-pre-wrap break-words text-sm text-muted-foreground">
|
||||
{errorDetail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -835,15 +868,31 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
async function testMcp() {
|
||||
setMcpTesting(true);
|
||||
|
||||
const showConnectionFailure = (
|
||||
message: string,
|
||||
info?: MCPServerRuntimeInfo,
|
||||
) => {
|
||||
toast.error(t('mcp.connectionFailedStatus'));
|
||||
setRuntimeInfo({
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
resource_count: 0,
|
||||
resources: [],
|
||||
...info,
|
||||
status: MCPSessionStatus.ERROR,
|
||||
error_message: info?.error_message || message,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const mode = form.getValues('mode');
|
||||
if (mode === 'stdio' && !mcpStdioEnabled) {
|
||||
toast.error(t('mcp.stdioDisabledByPolicy'));
|
||||
showConnectionFailure(t('mcp.stdioDisabledByPolicy'));
|
||||
setMcpTesting(false);
|
||||
return;
|
||||
}
|
||||
if (mode === 'stdio' && !boxAvailable) {
|
||||
toast.error(t('mcp.stdioBlockedByBoxToast'));
|
||||
showConnectionFailure(t('mcp.stdioBlockedByBoxToast'));
|
||||
setMcpTesting(false);
|
||||
return;
|
||||
}
|
||||
@@ -914,15 +963,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
if (taskResp.runtime.exception) {
|
||||
const errorMsg =
|
||||
taskResp.runtime.exception || t('mcp.unknownError');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
setRuntimeInfo({
|
||||
status: MCPSessionStatus.ERROR,
|
||||
error_message: errorMsg,
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
resource_count: 0,
|
||||
resources: [],
|
||||
});
|
||||
const runtimeInfoFromTest = taskResp.task_context?.metadata
|
||||
?.runtime_info as MCPServerRuntimeInfo | undefined;
|
||||
showConnectionFailure(errorMsg, runtimeInfoFromTest);
|
||||
if (shouldTestPersistedServer) {
|
||||
await onPersistedTestComplete?.(serverName);
|
||||
}
|
||||
@@ -949,14 +992,19 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
clearInterval(interval);
|
||||
setMcpTesting(false);
|
||||
const errorMsg =
|
||||
(err as CustomApiError).msg || t('mcp.getTaskFailed');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
(err as CustomApiError).msg ||
|
||||
(err as Error).message ||
|
||||
t('mcp.getTaskFailed');
|
||||
showConnectionFailure(errorMsg);
|
||||
}
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setMcpTesting(false);
|
||||
const errorMsg = (err as Error).message || t('mcp.unknownError');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
const errorMsg =
|
||||
(err as CustomApiError).msg ||
|
||||
(err as Error).message ||
|
||||
t('mcp.unknownError');
|
||||
showConnectionFailure(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -586,6 +586,7 @@ export enum MCPSessionStatus {
|
||||
}
|
||||
|
||||
export interface MCPServerRuntimeInfo {
|
||||
error_code?: string;
|
||||
status: MCPSessionStatus;
|
||||
error_message?: string;
|
||||
/** Stage at which the session failed. Frontends key off this to render
|
||||
|
||||
@@ -872,6 +872,15 @@ const enUS = {
|
||||
connectionSuccess: 'Connection successful',
|
||||
connectionFailed: 'Connection failed, please check URL',
|
||||
connectionFailedStatus: 'Connection Failed',
|
||||
connectionUnreachable:
|
||||
'Cannot reach the MCP server. Check that it is running and accessible.',
|
||||
connectionTimeout:
|
||||
'The MCP server did not respond in time. Check the service or increase the timeout.',
|
||||
connectionHttpError:
|
||||
'The MCP server returned HTTP {{status}}. Check its access requirements and server logs.',
|
||||
oauthAuthorizationRequired: 'OAuth authorization required',
|
||||
oauthAuthorizationRequiredSuggestion:
|
||||
'This MCP server requires OAuth sign-in. OAuth sign-in is not available yet; add an Authorization header manually if the server supports it.',
|
||||
boxDisabledStdioRefused:
|
||||
'Stdio MCP servers require the Box sandbox, which is disabled in config (box.enabled = false).',
|
||||
boxUnavailableStdioRefused:
|
||||
|
||||
@@ -892,6 +892,15 @@ const esES = {
|
||||
connectionSuccess: 'Conexión exitosa',
|
||||
connectionFailed: 'Error de conexión, por favor verifica la URL',
|
||||
connectionFailedStatus: 'Conexión fallida',
|
||||
connectionUnreachable:
|
||||
'No se puede acceder al servidor MCP. Compruebe que esté iniciado y accesible.',
|
||||
connectionTimeout:
|
||||
'El servidor MCP no respondió a tiempo. Compruebe el servicio o aumente el tiempo de espera.',
|
||||
connectionHttpError:
|
||||
'El servidor MCP devolvió HTTP {{status}}. Compruebe los requisitos de acceso y los registros del servidor.',
|
||||
oauthAuthorizationRequired: 'Se requiere autorización OAuth',
|
||||
oauthAuthorizationRequiredSuggestion:
|
||||
'Este servidor MCP requiere inicio de sesión con OAuth. Aún no está disponible; agregue manualmente un encabezado Authorization si el servidor lo permite.',
|
||||
boxDisabledStdioRefused:
|
||||
'Los servidores MCP en modo stdio requieren el sandbox de Box, desactivado en la configuración (box.enabled = false).',
|
||||
boxUnavailableStdioRefused:
|
||||
|
||||
@@ -880,6 +880,15 @@ const jaJP = {
|
||||
connectionSuccess: '接続に成功しました',
|
||||
connectionFailed: '接続に失敗しました,URLを確認してください',
|
||||
connectionFailedStatus: '接続失敗',
|
||||
connectionUnreachable:
|
||||
'MCP サーバーに接続できません。起動状態とネットワークを確認してください。',
|
||||
connectionTimeout:
|
||||
'MCP サーバーの応答がタイムアウトしました。サービスを確認するか、待機時間を延長してください。',
|
||||
connectionHttpError:
|
||||
'MCP サーバーが HTTP {{status}} を返しました。アクセス要件とサーバーログを確認してください。',
|
||||
oauthAuthorizationRequired: 'OAuth 認可が必要です',
|
||||
oauthAuthorizationRequiredSuggestion:
|
||||
'この MCP サーバーには OAuth ログインが必要です。現在は OAuth ログインに対応していません。サーバーが許可している場合は、Authorization ヘッダーを手動で追加してください。',
|
||||
boxDisabledStdioRefused:
|
||||
'Stdio モードの MCP サーバーは Box サンドボックスを必要としますが、設定で無効化されています(box.enabled = false)。',
|
||||
boxUnavailableStdioRefused:
|
||||
|
||||
@@ -885,6 +885,15 @@ const ruRU = {
|
||||
connectionSuccess: 'Подключение успешно',
|
||||
connectionFailed: 'Не удалось подключиться, проверьте URL',
|
||||
connectionFailedStatus: 'Ошибка подключения',
|
||||
connectionUnreachable:
|
||||
'Сервер MCP недоступен. Проверьте, запущен ли он и доступен ли по сети.',
|
||||
connectionTimeout:
|
||||
'Время ожидания ответа MCP истекло. Проверьте сервис или увеличьте тайм-аут.',
|
||||
connectionHttpError:
|
||||
'Сервер MCP вернул HTTP {{status}}. Проверьте требования доступа и журналы сервера.',
|
||||
oauthAuthorizationRequired: 'Требуется авторизация OAuth',
|
||||
oauthAuthorizationRequiredSuggestion:
|
||||
'Для этого MCP-сервера требуется вход через OAuth. OAuth-вход пока не поддерживается; если сервер это позволяет, добавьте заголовок Authorization вручную.',
|
||||
boxDisabledStdioRefused:
|
||||
'MCP-серверы в режиме stdio требуют песочницу Box, которая отключена в конфигурации (box.enabled = false).',
|
||||
boxUnavailableStdioRefused:
|
||||
|
||||
@@ -863,6 +863,15 @@ const thTH = {
|
||||
connectionSuccess: 'เชื่อมต่อสำเร็จ',
|
||||
connectionFailed: 'เชื่อมต่อล้มเหลว กรุณาตรวจสอบ URL',
|
||||
connectionFailedStatus: 'เชื่อมต่อล้มเหลว',
|
||||
connectionUnreachable:
|
||||
'ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์ MCP ได้ โปรดตรวจสอบว่าบริการทำงานและเข้าถึงได้',
|
||||
connectionTimeout:
|
||||
'เซิร์ฟเวอร์ MCP ไม่ตอบกลับภายในเวลาที่กำหนด โปรดตรวจสอบบริการหรือเพิ่มเวลารอ',
|
||||
connectionHttpError:
|
||||
'เซิร์ฟเวอร์ MCP ส่งคืน HTTP {{status}} โปรดตรวจสอบข้อกำหนดการเข้าถึงและบันทึกของเซิร์ฟเวอร์',
|
||||
oauthAuthorizationRequired: 'ต้องมีการอนุญาต OAuth',
|
||||
oauthAuthorizationRequiredSuggestion:
|
||||
'MCP server นี้ต้องเข้าสู่ระบบด้วย OAuth ซึ่งยังไม่รองรับในขณะนี้ หาก server อนุญาต คุณสามารถเพิ่ม Authorization header ด้วยตนเองได้',
|
||||
boxDisabledStdioRefused:
|
||||
'MCP server แบบ stdio ต้องใช้ Sandbox Box ซึ่งถูกปิดใช้งานในการตั้งค่า (box.enabled = false)',
|
||||
boxUnavailableStdioRefused:
|
||||
|
||||
@@ -878,6 +878,15 @@ const viVN = {
|
||||
connectionSuccess: 'Kết nối thành công',
|
||||
connectionFailed: 'Kết nối thất bại, vui lòng kiểm tra URL',
|
||||
connectionFailedStatus: 'Kết nối thất bại',
|
||||
connectionUnreachable:
|
||||
'Không thể kết nối tới máy chủ MCP. Hãy kiểm tra dịch vụ và kết nối mạng.',
|
||||
connectionTimeout:
|
||||
'Máy chủ MCP không phản hồi kịp thời. Hãy kiểm tra dịch vụ hoặc tăng thời gian chờ.',
|
||||
connectionHttpError:
|
||||
'Máy chủ MCP trả về HTTP {{status}}. Hãy kiểm tra yêu cầu truy cập và nhật ký máy chủ.',
|
||||
oauthAuthorizationRequired: 'Yêu cầu ủy quyền OAuth',
|
||||
oauthAuthorizationRequiredSuggestion:
|
||||
'MCP server này yêu cầu đăng nhập OAuth. Hiện chưa hỗ trợ đăng nhập OAuth; hãy thêm thủ công tiêu đề Authorization nếu server cho phép.',
|
||||
boxDisabledStdioRefused:
|
||||
'MCP server ở chế độ stdio cần Sandbox Box, hiện đã bị tắt trong cấu hình (box.enabled = false).',
|
||||
boxUnavailableStdioRefused:
|
||||
|
||||
@@ -837,6 +837,14 @@ const zhHans = {
|
||||
connectionSuccess: '连接成功',
|
||||
connectionFailed: '连接失败,请检查URL',
|
||||
connectionFailedStatus: '连接失败',
|
||||
connectionUnreachable:
|
||||
'无法连接到 MCP 服务器,请确认服务已启动且网络可达。',
|
||||
connectionTimeout: 'MCP 服务器响应超时,请检查服务状态或增加超时时间。',
|
||||
connectionHttpError:
|
||||
'MCP 服务器返回 HTTP {{status}},请检查访问要求和服务器日志。',
|
||||
oauthAuthorizationRequired: '需要 OAuth 授权',
|
||||
oauthAuthorizationRequiredSuggestion:
|
||||
'此 MCP 服务器需要 OAuth 登录。当前尚不支持 OAuth 登录;如果服务器允许,可以手动添加 Authorization 请求头。',
|
||||
boxDisabledStdioRefused:
|
||||
'Stdio 模式的 MCP 服务器依赖 Box 沙箱,目前已在配置中禁用(box.enabled = false)。',
|
||||
boxUnavailableStdioRefused:
|
||||
|
||||
@@ -839,6 +839,14 @@ const zhHant = {
|
||||
connectionSuccess: '連接成功',
|
||||
connectionFailed: '連接失敗,請檢查URL',
|
||||
connectionFailedStatus: '連接失敗',
|
||||
connectionUnreachable:
|
||||
'無法連接到 MCP 伺服器,請確認服務已啟動且網路可達。',
|
||||
connectionTimeout: 'MCP 伺服器回應逾時,請檢查服務狀態或增加逾時時間。',
|
||||
connectionHttpError:
|
||||
'MCP 伺服器回傳 HTTP {{status}},請檢查存取要求和伺服器日誌。',
|
||||
oauthAuthorizationRequired: '需要 OAuth 授權',
|
||||
oauthAuthorizationRequiredSuggestion:
|
||||
'此 MCP 伺服器需要 OAuth 登入。目前尚不支援 OAuth 登入;如果伺服器允許,可以手動新增 Authorization 請求標頭。',
|
||||
boxDisabledStdioRefused:
|
||||
'Stdio 模式的 MCP 伺服器依賴 Box 沙箱,目前已在設定中停用(box.enabled = false)。',
|
||||
boxUnavailableStdioRefused:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
function ok(data: unknown) {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
test('shows an actionable OAuth-required state after a transient MCP test', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.route('**/api/v1/mcp/servers/_/test', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(ok({ task_id: 2363 })),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/system/tasks/2363', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(
|
||||
ok({
|
||||
runtime: {
|
||||
done: true,
|
||||
exception: 'Connection failed',
|
||||
state: 'error',
|
||||
},
|
||||
task_context: {
|
||||
current_action: 'Testing MCP server',
|
||||
log: '',
|
||||
metadata: {
|
||||
runtime_info: {
|
||||
status: 'error',
|
||||
error_phase: 'oauth_required',
|
||||
retry_count: 1,
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
resource_count: 0,
|
||||
resources: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/home/mcp?id=new');
|
||||
await page.locator('input[name="name"]').fill('oauth-protected-mcp');
|
||||
await page
|
||||
.locator('input[name="url"]')
|
||||
.fill('https://mcp.example.test/protected');
|
||||
await page.getByRole('button', { name: /^Test$/ }).click();
|
||||
|
||||
await expect(page.getByText('OAuth authorization required')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'This MCP server requires OAuth sign-in. OAuth sign-in is not available yet; add an Authorization header manually if the server supports it.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('oauth-required.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user