mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
feat(mcp): detect OAuth-protected remote MCP servers (#2363)
* feat(mcp): surface OAuth-required server tests * fix(mcp): show connection failure details in status cards --------- Co-authored-by: RockChinQ <rockchinq@gmail.com>
This commit is contained in:
@@ -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