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
+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,
});
});