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:
huige66631
2026-09-09 16:13:33 +08:00
committed by GitHub
parent 485113ae43
commit ce6b647fe7
18 changed files with 433 additions and 37 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+16 -9
View File
@@ -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:
+56 -1
View File
@@ -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
+1 -1
View File
@@ -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);
}
}
+1
View File
@@ -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
+9
View File
@@ -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:
+9
View File
@@ -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:
+9
View File
@@ -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:
+9
View File
@@ -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:
+9
View File
@@ -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:
+9
View File
@@ -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:
+8
View File
@@ -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:
+8
View File
@@ -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:
+74
View File
@@ -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,
});
});