mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-17 15:27: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:
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
@@ -446,15 +446,19 @@ class MCPService:
|
|||||||
persisted_session = runtime_mcp_session
|
persisted_session = runtime_mcp_session
|
||||||
|
|
||||||
async def _refresh_and_report() -> None:
|
async def _refresh_and_report() -> None:
|
||||||
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
|
try:
|
||||||
if needs_start:
|
needs_start = (
|
||||||
await persisted_session.start()
|
persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
|
||||||
else:
|
)
|
||||||
try:
|
if needs_start:
|
||||||
await persisted_session.refresh()
|
|
||||||
except Exception:
|
|
||||||
await persisted_session.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()
|
coroutine = _refresh_and_report()
|
||||||
else:
|
else:
|
||||||
@@ -471,8 +475,11 @@ class MCPService:
|
|||||||
async def _run_and_cleanup() -> None:
|
async def _run_and_cleanup() -> None:
|
||||||
try:
|
try:
|
||||||
await test_session.start()
|
await test_session.start()
|
||||||
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
|
|
||||||
finally:
|
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:
|
try:
|
||||||
await test_session.shutdown()
|
await test_session.shutdown()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
@@ -206,6 +207,13 @@ class MCPSessionStatus(enum.Enum):
|
|||||||
ERROR = 'error'
|
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):
|
class _TransportReconnect(Exception):
|
||||||
"""Internal signal: the Box stdio WS transport dropped but the managed
|
"""Internal signal: the Box stdio WS transport dropped but the managed
|
||||||
process is still alive. Triggers a lightweight transport reconnect that
|
process is still alive. Triggers a lightweight transport reconnect that
|
||||||
@@ -265,6 +273,7 @@ class RuntimeMCPSession:
|
|||||||
_ready_event: asyncio.Event
|
_ready_event: asyncio.Event
|
||||||
|
|
||||||
error_message: str | None = None
|
error_message: str | None = None
|
||||||
|
_public_error_code: str = 'runtime_error'
|
||||||
|
|
||||||
error_phase: MCPSessionErrorPhase | None = None
|
error_phase: MCPSessionErrorPhase | None = None
|
||||||
|
|
||||||
@@ -510,6 +519,13 @@ class RuntimeMCPSession:
|
|||||||
await self._init_streamable_http_server()
|
await self._init_streamable_http_server()
|
||||||
return
|
return
|
||||||
except Exception as e:
|
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):
|
if not self._should_fallback_to_sse(e):
|
||||||
self.ap.logger.info(
|
self.ap.logger.info(
|
||||||
f'MCP server {self.server_name}: Streamable HTTP transport failed '
|
f'MCP server {self.server_name}: Streamable HTTP transport failed '
|
||||||
@@ -630,6 +646,7 @@ class RuntimeMCPSession:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status = MCPSessionStatus.ERROR
|
self.status = MCPSessionStatus.ERROR
|
||||||
self.error_message = str(e)
|
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()}')
|
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
|
# Do NOT set _ready_event here — let _lifecycle_loop_with_retry
|
||||||
# handle retries first. It will set the event when all retries
|
# handle retries first. It will set the event when all retries
|
||||||
@@ -752,6 +769,11 @@ class RuntimeMCPSession:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self._shutdown_event.is_set():
|
if self._shutdown_event.is_set():
|
||||||
return # Shutdown requested, don't retry
|
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:
|
if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE:
|
||||||
box_service = getattr(self.ap, 'box_service', None)
|
box_service = getattr(self.ap, 'box_service', None)
|
||||||
if box_service is not None and getattr(box_service, 'enabled', True):
|
if box_service is not None and getattr(box_service, 'enabled', True):
|
||||||
@@ -832,6 +854,39 @@ class RuntimeMCPSession:
|
|||||||
else:
|
else:
|
||||||
yield exc
|
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
|
@staticmethod
|
||||||
def _should_fallback_to_sse(exc: BaseException) -> bool:
|
def _should_fallback_to_sse(exc: BaseException) -> bool:
|
||||||
"""Whether a Streamable HTTP failure matches legacy-SSE fallback.
|
"""Whether a Streamable HTTP failure matches legacy-SSE fallback.
|
||||||
@@ -1374,7 +1429,7 @@ class RuntimeMCPSession:
|
|||||||
# environment values. Detailed diagnostics belong in AUDIT_VIEW
|
# environment values. Detailed diagnostics belong in AUDIT_VIEW
|
||||||
# logs; resource-list responses expose only a stable status.
|
# logs; resource-list responses expose only a stable status.
|
||||||
'error_message': 'MCP runtime failed' if self.error_message else None,
|
'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,
|
'error_phase': self.error_phase.value if self.error_phase else None,
|
||||||
'retry_count': self.retry_count,
|
'retry_count': self.retry_count,
|
||||||
'tool_count': len(self.get_tools()),
|
'tool_count': len(self.get_tools()),
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class MCPSessionErrorPhase(enum.Enum):
|
|||||||
MCP_INIT = 'mcp_init'
|
MCP_INIT = 'mcp_init'
|
||||||
RUNTIME = 'runtime'
|
RUNTIME = 'runtime'
|
||||||
TOOL_CALL = 'tool_call'
|
TOOL_CALL = 'tool_call'
|
||||||
|
OAUTH_REQUIRED = 'oauth_required'
|
||||||
# Stdio MCP refused because Box is disabled in config or currently
|
# Stdio MCP refused because Box is disabled in config or currently
|
||||||
# unavailable. Not transient — retries would be pointless. The frontend
|
# unavailable. Not transient — retries would be pointless. The frontend
|
||||||
# uses this phase to render a localized actionable message instead of
|
# uses this phase to render a localized actionable message instead of
|
||||||
|
|||||||
@@ -1009,6 +1009,37 @@ class TestMCPServiceTestMCPServer:
|
|||||||
# Verify - returns task ID
|
# Verify - returns task ID
|
||||||
assert task_id == 123
|
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):
|
async def test_test_mcp_server_not_found_raises(self):
|
||||||
"""Raises ValueError when server not found."""
|
"""Raises ValueError when server not found."""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -1052,6 +1083,45 @@ class TestMCPServiceTestMCPServer:
|
|||||||
ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once()
|
ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once()
|
||||||
assert task_id == 456
|
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):
|
async def test_rejected_transient_test_session_is_shut_down(self):
|
||||||
ap = SimpleNamespace()
|
ap = SimpleNamespace()
|
||||||
mock_session = MagicMock()
|
mock_session = MagicMock()
|
||||||
|
|||||||
@@ -13,7 +13,15 @@ from aiohttp import web
|
|||||||
from mcp import types as mcp_types
|
from mcp import types as mcp_types
|
||||||
|
|
||||||
from langbot.pkg.api.http.context import ExecutionContext
|
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(
|
TEST_EXECUTION_CONTEXT = ExecutionContext(
|
||||||
@@ -24,8 +32,9 @@ TEST_EXECUTION_CONTEXT = ExecutionContext(
|
|||||||
|
|
||||||
|
|
||||||
class _TransportProbe:
|
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_status = streamable_status
|
||||||
|
self.streamable_headers = streamable_headers or {}
|
||||||
self.streamable_posts = 0
|
self.streamable_posts = 0
|
||||||
self.streamable_messages: list[str] = []
|
self.streamable_messages: list[str] = []
|
||||||
self.sse_gets = 0
|
self.sse_gets = 0
|
||||||
@@ -93,7 +102,7 @@ class _TransportProbe:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
return web.Response(status=202)
|
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
|
self.sse_gets += 1
|
||||||
response = web.StreamResponse(
|
response = web.StreamResponse(
|
||||||
@@ -136,8 +145,8 @@ class _TransportProbe:
|
|||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _transport_server(streamable_status: int | None):
|
async def _transport_server(streamable_status: int | None, streamable_headers: dict[str, str] | None = None):
|
||||||
probe = _TransportProbe(streamable_status)
|
probe = _TransportProbe(streamable_status, streamable_headers)
|
||||||
application = web.Application()
|
application = web.Application()
|
||||||
application.router.add_route('*', '/mcp', probe.handle_mcp_endpoint)
|
application.router.add_route('*', '/mcp', probe.handle_mcp_endpoint)
|
||||||
application.router.add_post('/messages', probe.handle_sse_message)
|
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)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_remote_transport_real_timeout_does_not_fallback():
|
async def test_remote_transport_real_timeout_does_not_fallback():
|
||||||
async with _transport_server(None) as (probe, url):
|
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:
|
finally:
|
||||||
probe.release_streamable_request.set()
|
probe.release_streamable_request.set()
|
||||||
await _close_session(session)
|
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: {
|
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',
|
url: 'http://127.0.0.1:4173',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { TFunction } from '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 { Resolver, useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -101,7 +108,7 @@ function StatusDisplay({
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2 text-red-600">
|
<div className="flex items-center gap-2 text-red-600">
|
||||||
<XCircle className="size-5" />
|
<XCircle className="size-5" />
|
||||||
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
<span className="font-medium">{t('mcp.connectionFailedStatus')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="pl-7 text-sm text-red-500 space-y-0.5">
|
<div className="pl-7 text-sm text-red-500 space-y-0.5">
|
||||||
<div>
|
<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 (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2 text-red-600">
|
<div className="flex items-center gap-2 text-red-600">
|
||||||
<XCircle className="size-5" />
|
<XCircle className="size-5" />
|
||||||
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
<span className="font-medium">{t('mcp.connectionFailedStatus')}</span>
|
||||||
</div>
|
</div>
|
||||||
{runtimeInfo.error_message && (
|
{errorDetail && (
|
||||||
<div className="pl-7 text-sm text-red-500">
|
<div className="pl-7 whitespace-pre-wrap break-words text-sm text-muted-foreground">
|
||||||
{runtimeInfo.error_message}
|
{errorDetail}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -835,15 +868,31 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
async function testMcp() {
|
async function testMcp() {
|
||||||
setMcpTesting(true);
|
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 {
|
try {
|
||||||
const mode = form.getValues('mode');
|
const mode = form.getValues('mode');
|
||||||
if (mode === 'stdio' && !mcpStdioEnabled) {
|
if (mode === 'stdio' && !mcpStdioEnabled) {
|
||||||
toast.error(t('mcp.stdioDisabledByPolicy'));
|
showConnectionFailure(t('mcp.stdioDisabledByPolicy'));
|
||||||
setMcpTesting(false);
|
setMcpTesting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mode === 'stdio' && !boxAvailable) {
|
if (mode === 'stdio' && !boxAvailable) {
|
||||||
toast.error(t('mcp.stdioBlockedByBoxToast'));
|
showConnectionFailure(t('mcp.stdioBlockedByBoxToast'));
|
||||||
setMcpTesting(false);
|
setMcpTesting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -914,15 +963,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
if (taskResp.runtime.exception) {
|
if (taskResp.runtime.exception) {
|
||||||
const errorMsg =
|
const errorMsg =
|
||||||
taskResp.runtime.exception || t('mcp.unknownError');
|
taskResp.runtime.exception || t('mcp.unknownError');
|
||||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
const runtimeInfoFromTest = taskResp.task_context?.metadata
|
||||||
setRuntimeInfo({
|
?.runtime_info as MCPServerRuntimeInfo | undefined;
|
||||||
status: MCPSessionStatus.ERROR,
|
showConnectionFailure(errorMsg, runtimeInfoFromTest);
|
||||||
error_message: errorMsg,
|
|
||||||
tool_count: 0,
|
|
||||||
tools: [],
|
|
||||||
resource_count: 0,
|
|
||||||
resources: [],
|
|
||||||
});
|
|
||||||
if (shouldTestPersistedServer) {
|
if (shouldTestPersistedServer) {
|
||||||
await onPersistedTestComplete?.(serverName);
|
await onPersistedTestComplete?.(serverName);
|
||||||
}
|
}
|
||||||
@@ -949,14 +992,19 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
setMcpTesting(false);
|
setMcpTesting(false);
|
||||||
const errorMsg =
|
const errorMsg =
|
||||||
(err as CustomApiError).msg || t('mcp.getTaskFailed');
|
(err as CustomApiError).msg ||
|
||||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
(err as Error).message ||
|
||||||
|
t('mcp.getTaskFailed');
|
||||||
|
showConnectionFailure(errorMsg);
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMcpTesting(false);
|
setMcpTesting(false);
|
||||||
const errorMsg = (err as Error).message || t('mcp.unknownError');
|
const errorMsg =
|
||||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
(err as CustomApiError).msg ||
|
||||||
|
(err as Error).message ||
|
||||||
|
t('mcp.unknownError');
|
||||||
|
showConnectionFailure(errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -586,6 +586,7 @@ export enum MCPSessionStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MCPServerRuntimeInfo {
|
export interface MCPServerRuntimeInfo {
|
||||||
|
error_code?: string;
|
||||||
status: MCPSessionStatus;
|
status: MCPSessionStatus;
|
||||||
error_message?: string;
|
error_message?: string;
|
||||||
/** Stage at which the session failed. Frontends key off this to render
|
/** Stage at which the session failed. Frontends key off this to render
|
||||||
|
|||||||
@@ -872,6 +872,15 @@ const enUS = {
|
|||||||
connectionSuccess: 'Connection successful',
|
connectionSuccess: 'Connection successful',
|
||||||
connectionFailed: 'Connection failed, please check URL',
|
connectionFailed: 'Connection failed, please check URL',
|
||||||
connectionFailedStatus: 'Connection Failed',
|
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:
|
boxDisabledStdioRefused:
|
||||||
'Stdio MCP servers require the Box sandbox, which is disabled in config (box.enabled = false).',
|
'Stdio MCP servers require the Box sandbox, which is disabled in config (box.enabled = false).',
|
||||||
boxUnavailableStdioRefused:
|
boxUnavailableStdioRefused:
|
||||||
|
|||||||
@@ -892,6 +892,15 @@ const esES = {
|
|||||||
connectionSuccess: 'Conexión exitosa',
|
connectionSuccess: 'Conexión exitosa',
|
||||||
connectionFailed: 'Error de conexión, por favor verifica la URL',
|
connectionFailed: 'Error de conexión, por favor verifica la URL',
|
||||||
connectionFailedStatus: 'Conexión fallida',
|
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:
|
boxDisabledStdioRefused:
|
||||||
'Los servidores MCP en modo stdio requieren el sandbox de Box, desactivado en la configuración (box.enabled = false).',
|
'Los servidores MCP en modo stdio requieren el sandbox de Box, desactivado en la configuración (box.enabled = false).',
|
||||||
boxUnavailableStdioRefused:
|
boxUnavailableStdioRefused:
|
||||||
|
|||||||
@@ -880,6 +880,15 @@ const jaJP = {
|
|||||||
connectionSuccess: '接続に成功しました',
|
connectionSuccess: '接続に成功しました',
|
||||||
connectionFailed: '接続に失敗しました,URLを確認してください',
|
connectionFailed: '接続に失敗しました,URLを確認してください',
|
||||||
connectionFailedStatus: '接続失敗',
|
connectionFailedStatus: '接続失敗',
|
||||||
|
connectionUnreachable:
|
||||||
|
'MCP サーバーに接続できません。起動状態とネットワークを確認してください。',
|
||||||
|
connectionTimeout:
|
||||||
|
'MCP サーバーの応答がタイムアウトしました。サービスを確認するか、待機時間を延長してください。',
|
||||||
|
connectionHttpError:
|
||||||
|
'MCP サーバーが HTTP {{status}} を返しました。アクセス要件とサーバーログを確認してください。',
|
||||||
|
oauthAuthorizationRequired: 'OAuth 認可が必要です',
|
||||||
|
oauthAuthorizationRequiredSuggestion:
|
||||||
|
'この MCP サーバーには OAuth ログインが必要です。現在は OAuth ログインに対応していません。サーバーが許可している場合は、Authorization ヘッダーを手動で追加してください。',
|
||||||
boxDisabledStdioRefused:
|
boxDisabledStdioRefused:
|
||||||
'Stdio モードの MCP サーバーは Box サンドボックスを必要としますが、設定で無効化されています(box.enabled = false)。',
|
'Stdio モードの MCP サーバーは Box サンドボックスを必要としますが、設定で無効化されています(box.enabled = false)。',
|
||||||
boxUnavailableStdioRefused:
|
boxUnavailableStdioRefused:
|
||||||
|
|||||||
@@ -885,6 +885,15 @@ const ruRU = {
|
|||||||
connectionSuccess: 'Подключение успешно',
|
connectionSuccess: 'Подключение успешно',
|
||||||
connectionFailed: 'Не удалось подключиться, проверьте URL',
|
connectionFailed: 'Не удалось подключиться, проверьте URL',
|
||||||
connectionFailedStatus: 'Ошибка подключения',
|
connectionFailedStatus: 'Ошибка подключения',
|
||||||
|
connectionUnreachable:
|
||||||
|
'Сервер MCP недоступен. Проверьте, запущен ли он и доступен ли по сети.',
|
||||||
|
connectionTimeout:
|
||||||
|
'Время ожидания ответа MCP истекло. Проверьте сервис или увеличьте тайм-аут.',
|
||||||
|
connectionHttpError:
|
||||||
|
'Сервер MCP вернул HTTP {{status}}. Проверьте требования доступа и журналы сервера.',
|
||||||
|
oauthAuthorizationRequired: 'Требуется авторизация OAuth',
|
||||||
|
oauthAuthorizationRequiredSuggestion:
|
||||||
|
'Для этого MCP-сервера требуется вход через OAuth. OAuth-вход пока не поддерживается; если сервер это позволяет, добавьте заголовок Authorization вручную.',
|
||||||
boxDisabledStdioRefused:
|
boxDisabledStdioRefused:
|
||||||
'MCP-серверы в режиме stdio требуют песочницу Box, которая отключена в конфигурации (box.enabled = false).',
|
'MCP-серверы в режиме stdio требуют песочницу Box, которая отключена в конфигурации (box.enabled = false).',
|
||||||
boxUnavailableStdioRefused:
|
boxUnavailableStdioRefused:
|
||||||
|
|||||||
@@ -863,6 +863,15 @@ const thTH = {
|
|||||||
connectionSuccess: 'เชื่อมต่อสำเร็จ',
|
connectionSuccess: 'เชื่อมต่อสำเร็จ',
|
||||||
connectionFailed: 'เชื่อมต่อล้มเหลว กรุณาตรวจสอบ URL',
|
connectionFailed: 'เชื่อมต่อล้มเหลว กรุณาตรวจสอบ URL',
|
||||||
connectionFailedStatus: 'เชื่อมต่อล้มเหลว',
|
connectionFailedStatus: 'เชื่อมต่อล้มเหลว',
|
||||||
|
connectionUnreachable:
|
||||||
|
'ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์ MCP ได้ โปรดตรวจสอบว่าบริการทำงานและเข้าถึงได้',
|
||||||
|
connectionTimeout:
|
||||||
|
'เซิร์ฟเวอร์ MCP ไม่ตอบกลับภายในเวลาที่กำหนด โปรดตรวจสอบบริการหรือเพิ่มเวลารอ',
|
||||||
|
connectionHttpError:
|
||||||
|
'เซิร์ฟเวอร์ MCP ส่งคืน HTTP {{status}} โปรดตรวจสอบข้อกำหนดการเข้าถึงและบันทึกของเซิร์ฟเวอร์',
|
||||||
|
oauthAuthorizationRequired: 'ต้องมีการอนุญาต OAuth',
|
||||||
|
oauthAuthorizationRequiredSuggestion:
|
||||||
|
'MCP server นี้ต้องเข้าสู่ระบบด้วย OAuth ซึ่งยังไม่รองรับในขณะนี้ หาก server อนุญาต คุณสามารถเพิ่ม Authorization header ด้วยตนเองได้',
|
||||||
boxDisabledStdioRefused:
|
boxDisabledStdioRefused:
|
||||||
'MCP server แบบ stdio ต้องใช้ Sandbox Box ซึ่งถูกปิดใช้งานในการตั้งค่า (box.enabled = false)',
|
'MCP server แบบ stdio ต้องใช้ Sandbox Box ซึ่งถูกปิดใช้งานในการตั้งค่า (box.enabled = false)',
|
||||||
boxUnavailableStdioRefused:
|
boxUnavailableStdioRefused:
|
||||||
|
|||||||
@@ -878,6 +878,15 @@ const viVN = {
|
|||||||
connectionSuccess: 'Kết nối thành công',
|
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',
|
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',
|
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:
|
boxDisabledStdioRefused:
|
||||||
'MCP server ở chế độ stdio cần Sandbox Box, hiện đã bị tắt trong cấu hình (box.enabled = false).',
|
'MCP server ở chế độ stdio cần Sandbox Box, hiện đã bị tắt trong cấu hình (box.enabled = false).',
|
||||||
boxUnavailableStdioRefused:
|
boxUnavailableStdioRefused:
|
||||||
|
|||||||
@@ -837,6 +837,14 @@ const zhHans = {
|
|||||||
connectionSuccess: '连接成功',
|
connectionSuccess: '连接成功',
|
||||||
connectionFailed: '连接失败,请检查URL',
|
connectionFailed: '连接失败,请检查URL',
|
||||||
connectionFailedStatus: '连接失败',
|
connectionFailedStatus: '连接失败',
|
||||||
|
connectionUnreachable:
|
||||||
|
'无法连接到 MCP 服务器,请确认服务已启动且网络可达。',
|
||||||
|
connectionTimeout: 'MCP 服务器响应超时,请检查服务状态或增加超时时间。',
|
||||||
|
connectionHttpError:
|
||||||
|
'MCP 服务器返回 HTTP {{status}},请检查访问要求和服务器日志。',
|
||||||
|
oauthAuthorizationRequired: '需要 OAuth 授权',
|
||||||
|
oauthAuthorizationRequiredSuggestion:
|
||||||
|
'此 MCP 服务器需要 OAuth 登录。当前尚不支持 OAuth 登录;如果服务器允许,可以手动添加 Authorization 请求头。',
|
||||||
boxDisabledStdioRefused:
|
boxDisabledStdioRefused:
|
||||||
'Stdio 模式的 MCP 服务器依赖 Box 沙箱,目前已在配置中禁用(box.enabled = false)。',
|
'Stdio 模式的 MCP 服务器依赖 Box 沙箱,目前已在配置中禁用(box.enabled = false)。',
|
||||||
boxUnavailableStdioRefused:
|
boxUnavailableStdioRefused:
|
||||||
|
|||||||
@@ -839,6 +839,14 @@ const zhHant = {
|
|||||||
connectionSuccess: '連接成功',
|
connectionSuccess: '連接成功',
|
||||||
connectionFailed: '連接失敗,請檢查URL',
|
connectionFailed: '連接失敗,請檢查URL',
|
||||||
connectionFailedStatus: '連接失敗',
|
connectionFailedStatus: '連接失敗',
|
||||||
|
connectionUnreachable:
|
||||||
|
'無法連接到 MCP 伺服器,請確認服務已啟動且網路可達。',
|
||||||
|
connectionTimeout: 'MCP 伺服器回應逾時,請檢查服務狀態或增加逾時時間。',
|
||||||
|
connectionHttpError:
|
||||||
|
'MCP 伺服器回傳 HTTP {{status}},請檢查存取要求和伺服器日誌。',
|
||||||
|
oauthAuthorizationRequired: '需要 OAuth 授權',
|
||||||
|
oauthAuthorizationRequiredSuggestion:
|
||||||
|
'此 MCP 伺服器需要 OAuth 登入。目前尚不支援 OAuth 登入;如果伺服器允許,可以手動新增 Authorization 請求標頭。',
|
||||||
boxDisabledStdioRefused:
|
boxDisabledStdioRefused:
|
||||||
'Stdio 模式的 MCP 伺服器依賴 Box 沙箱,目前已在設定中停用(box.enabled = false)。',
|
'Stdio 模式的 MCP 伺服器依賴 Box 沙箱,目前已在設定中停用(box.enabled = false)。',
|
||||||
boxUnavailableStdioRefused:
|
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