From 216b1b9f03bf8c102018c1d46c8ec4544f5f1a66 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Wed, 20 May 2026 17:51:32 +0800 Subject: [PATCH] feat(mcp): friendly UI message when stdio MCP refused by Box state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the MCP detail dialog dumped the raw RuntimeError text from ``_init_stdio_python_server`` — English-only, prefixed with "Failed after 4 attempts", and exposing internal config names. The retry wrapper also kept retrying a refusal that is deterministically going to fail again, polluting logs. Replace the raw text with a structured signal: - New ``MCPSessionErrorPhase.BOX_UNAVAILABLE`` enum value. The stdio refusal path sets it before raising and uses a short opaque discriminator (``box_disabled_in_config`` / ``box_unavailable``) as the message body — never user-facing - ``_lifecycle_loop_with_retry`` short-circuits on ``BOX_UNAVAILABLE``: surfaces the error immediately, no retries, no "Failed after N attempts" prefix. Silences the warning storm seen during smoke-testing - ``MCPServerRuntimeInfo`` (TS type) now declares ``error_phase``, ``retry_count``, ``box_session_id``, ``box_enabled`` to match what the backend already returns in get_runtime_info_dict() - Both MCP detail forms (``mcp/components/mcp-form/MCPForm.tsx`` and ``plugins/mcp-server/mcp-form/MCPFormDialog.tsx``) detect ``error_phase === 'box_unavailable'`` and render a two-line localized notice: state line ("Box disabled / unreachable") plus remediation line ("enable Box or switch to http/sse") - 8 locale files (en/zh-Hans/zh-Hant/ja/ru/vi/th/es) get ``mcp.boxDisabledStdioRefused``, ``mcp.boxUnavailableStdioRefused``, ``mcp.boxStdioRefusedSuggestion`` Co-Authored-By: Claude Opus 4.7 (1M context) --- src/langbot/pkg/provider/tools/loaders/mcp.py | 27 ++++++++++++------- .../pkg/provider/tools/loaders/mcp_stdio.py | 5 ++++ .../home/mcp/components/mcp-form/MCPForm.tsx | 26 ++++++++++++++++++ .../mcp-server/mcp-form/MCPFormDialog.tsx | 26 ++++++++++++++++++ web/src/app/infra/entities/api/index.ts | 10 +++++++ web/src/i18n/locales/en-US.ts | 6 +++++ web/src/i18n/locales/es-ES.ts | 6 +++++ web/src/i18n/locales/ja-JP.ts | 6 +++++ web/src/i18n/locales/ru-RU.ts | 6 +++++ web/src/i18n/locales/th-TH.ts | 6 +++++ web/src/i18n/locales/vi-VN.ts | 6 +++++ web/src/i18n/locales/zh-Hans.ts | 6 +++++ web/src/i18n/locales/zh-Hant.ts | 6 +++++ 13 files changed, 132 insertions(+), 10 deletions(-) diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index 0ecc3241..5269e6da 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -94,19 +94,18 @@ class RuntimeMCPSession: # (disabled by config or connection failed). Refuse stdio MCP rather # than silently falling through to host-stdio — the operator asked # for the sandbox and the failure mode should be visible. + # + # Set ``error_phase = BOX_UNAVAILABLE`` BEFORE raising so the retry + # wrapper can short-circuit (retrying is pointless when Box is + # deliberately off) and the frontend can render a localized, + # actionable message instead of this raw RuntimeError. Keep the + # message itself short — the frontend ignores it for this phase. box_service = getattr(self.ap, 'box_service', None) if box_service is not None and not getattr(box_service, 'available', False): - connector_error = getattr(box_service, '_connector_error', '') or 'currently unavailable' + self.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE if not getattr(box_service, 'enabled', True): - reason = 'disabled in config (box.enabled = false)' - else: - reason = f'unavailable: {connector_error}' - raise RuntimeError( - f'Stdio MCP server "{self.server_name}" requires the Box runtime, ' - f'which is {reason}. Either enable Box in config.yaml ' - f'(box.enabled = true) and ensure the runtime is healthy, ' - f'or switch this MCP server to http/sse transport.' - ) + raise RuntimeError('box_disabled_in_config') + raise RuntimeError('box_unavailable') # Legacy: no box_service installed at all (pre-Box dev mode). Fall # through to host-stdio for backward compatibility. @@ -231,6 +230,14 @@ class RuntimeMCPSession: self.retry_count = attempt + 1 if self._shutdown_event.is_set(): return # Shutdown requested, don't retry + # BOX_UNAVAILABLE is a deliberate refusal, not a transient + # failure — retrying produces log spam and a misleading + # "Failed after N attempts" message. Surface it immediately. + if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE: + self.status = MCPSessionStatus.ERROR + self.error_message = str(e) + self._ready_event.set() + return if attempt >= self._MAX_RETRIES: self.status = MCPSessionStatus.ERROR self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {e}' diff --git a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py index 2af7b938..bdddcd29 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py @@ -34,6 +34,11 @@ class MCPSessionErrorPhase(enum.Enum): MCP_INIT = 'mcp_init' RUNTIME = 'runtime' TOOL_CALL = 'tool_call' + # 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 + # the raw RuntimeError text. + BOX_UNAVAILABLE = 'box_unavailable' class MCPServerBoxConfig(pydantic.BaseModel): diff --git a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx index a4ed1359..d611c2d5 100644 --- a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx +++ b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx @@ -78,6 +78,32 @@ function StatusDisplay({ ); } + // Stdio MCP refused because Box is disabled / unreachable. The backend + // marks the phase so we can show a localized, actionable message instead + // of the raw "box_disabled_in_config" / "box_unavailable" marker. + if (runtimeInfo.error_phase === 'box_unavailable') { + const isDisabledByConfig = + runtimeInfo.error_message === 'box_disabled_in_config'; + return ( +
+
+ + {t('mcp.connectionFailed')} +
+
+
+ {isDisabledByConfig + ? t('mcp.boxDisabledStdioRefused') + : t('mcp.boxUnavailableStdioRefused')} +
+
+ {t('mcp.boxStdioRefusedSuggestion')} +
+
+
+ ); + } + return (
diff --git a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx index 1f5ee3ba..6c43b5c3 100644 --- a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx +++ b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx @@ -77,6 +77,32 @@ function StatusDisplay({ ); } + // Stdio MCP refused because Box is disabled / unreachable. The backend + // marks the phase so we can show a localized, actionable message instead + // of the raw "box_disabled_in_config" / "box_unavailable" marker. + if (runtimeInfo.error_phase === 'box_unavailable') { + const isDisabledByConfig = + runtimeInfo.error_message === 'box_disabled_in_config'; + return ( +
+
+ + {t('mcp.connectionFailed')} +
+
+
+ {isDisabledByConfig + ? t('mcp.boxDisabledStdioRefused') + : t('mcp.boxUnavailableStdioRefused')} +
+
+ {t('mcp.boxStdioRefusedSuggestion')} +
+
+
+ ); + } + // 连接失败 return (
diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 234bda4d..171b4d47 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -529,8 +529,18 @@ export enum MCPSessionStatus { export interface MCPServerRuntimeInfo { status: MCPSessionStatus; error_message?: string; + /** Stage at which the session failed. Frontends key off this to render + * a localized actionable message instead of the raw ``error_message``. + * Notable values: ``box_unavailable`` (stdio MCP refused because Box is + * disabled / unreachable). See ``MCPSessionErrorPhase`` (backend). */ + error_phase?: string; + retry_count?: number; tool_count: number; tools: MCPTool[]; + /** Optional ``box_session_id`` / ``box_enabled`` set when this stdio + * server runs inside Box. Absent when Box is unavailable. */ + box_session_id?: string; + box_enabled?: boolean; } export type MCPServer = diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 50e0f2ae..690e8f3b 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -738,6 +738,12 @@ const enUS = { connectionSuccess: 'Connection successful', connectionFailed: 'Connection failed, please check URL', connectionFailedStatus: 'Connection Failed', + boxDisabledStdioRefused: + 'Stdio MCP servers require the Box sandbox, which is disabled in config (box.enabled = false).', + boxUnavailableStdioRefused: + 'Stdio MCP servers require the Box sandbox, which is currently unreachable.', + boxStdioRefusedSuggestion: + 'Enable Box (box.enabled = true) and ensure the runtime is healthy, or switch this server to http/sse mode.', toolsFound: 'tools', unknownError: 'Unknown error', noToolsFound: 'No tools found', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 318b9e7a..762eb082 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -752,6 +752,12 @@ const esES = { connectionSuccess: 'Conexión exitosa', connectionFailed: 'Error de conexión, por favor verifica la URL', connectionFailedStatus: 'Conexión fallida', + boxDisabledStdioRefused: + 'Los servidores MCP en modo stdio requieren el sandbox de Box, desactivado en la configuración (box.enabled = false).', + boxUnavailableStdioRefused: + 'Los servidores MCP en modo stdio requieren el sandbox de Box, actualmente no accesible.', + boxStdioRefusedSuggestion: + 'Active Box (box.enabled = true) y asegúrese de que el runtime está conectado, o cambie este servidor a modo http/sse.', toolsFound: 'herramientas', unknownError: 'Error desconocido', noToolsFound: 'No se encontraron herramientas', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 44db911a..84757c50 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -743,6 +743,12 @@ const jaJP = { connectionSuccess: '接続に成功しました', connectionFailed: '接続に失敗しました,URLを確認してください', connectionFailedStatus: '接続失敗', + boxDisabledStdioRefused: + 'Stdio モードの MCP サーバーは Box サンドボックスを必要としますが、設定で無効化されています(box.enabled = false)。', + boxUnavailableStdioRefused: + 'Stdio モードの MCP サーバーは Box サンドボックスを必要としますが、現在接続できません。', + boxStdioRefusedSuggestion: + 'Box を有効化(box.enabled = true)してランタイムの接続を確認するか、このサーバーを http/sse モードに切り替えてください。', toolsFound: '個のツール', unknownError: '不明なエラー', noToolsFound: 'ツールが見つかりません', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 8ea61770..1cfd5ea6 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -748,6 +748,12 @@ const ruRU = { connectionSuccess: 'Подключение успешно', connectionFailed: 'Не удалось подключиться, проверьте URL', connectionFailedStatus: 'Ошибка подключения', + boxDisabledStdioRefused: + 'MCP-серверы в режиме stdio требуют песочницу Box, которая отключена в конфигурации (box.enabled = false).', + boxUnavailableStdioRefused: + 'MCP-серверы в режиме stdio требуют песочницу Box, которая сейчас недоступна.', + boxStdioRefusedSuggestion: + 'Включите Box (box.enabled = true) и убедитесь, что среда работает, либо переключите этот сервер в режим http/sse.', toolsFound: 'инструментов', unknownError: 'Неизвестная ошибка', noToolsFound: 'Инструменты не найдены', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 48f924f2..e7de5665 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -728,6 +728,12 @@ const thTH = { connectionSuccess: 'เชื่อมต่อสำเร็จ', connectionFailed: 'เชื่อมต่อล้มเหลว กรุณาตรวจสอบ URL', connectionFailedStatus: 'เชื่อมต่อล้มเหลว', + boxDisabledStdioRefused: + 'MCP server แบบ stdio ต้องใช้ Sandbox Box ซึ่งถูกปิดใช้งานในการตั้งค่า (box.enabled = false)', + boxUnavailableStdioRefused: + 'MCP server แบบ stdio ต้องใช้ Sandbox Box ซึ่งขณะนี้เชื่อมต่อไม่ได้', + boxStdioRefusedSuggestion: + 'กรุณาเปิดใช้งาน Box (box.enabled = true) และตรวจสอบว่ารันไทม์ทำงานปกติ หรือเปลี่ยน MCP server เป็นโหมด http/sse', toolsFound: 'เครื่องมือ', unknownError: 'ข้อผิดพลาดที่ไม่ทราบสาเหตุ', noToolsFound: 'ไม่พบเครื่องมือ', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 19fba640..d79ebc67 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -742,6 +742,12 @@ 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', + boxDisabledStdioRefused: + 'MCP server ở chế độ stdio cần Sandbox Box, hiện đã bị tắt trong cấu hình (box.enabled = false).', + boxUnavailableStdioRefused: + 'MCP server ở chế độ stdio cần Sandbox Box, hiện không thể kết nối.', + boxStdioRefusedSuggestion: + 'Hãy bật Box (box.enabled = true) và đảm bảo runtime hoạt động, hoặc chuyển server này sang chế độ http/sse.', toolsFound: 'công cụ', unknownError: 'Lỗi không xác định', noToolsFound: 'Không tìm thấy công cụ nào', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 203108df..096cdb97 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -710,6 +710,12 @@ const zhHans = { connectionSuccess: '连接成功', connectionFailed: '连接失败,请检查URL', connectionFailedStatus: '连接失败', + boxDisabledStdioRefused: + 'Stdio 模式的 MCP 服务器依赖 Box 沙箱,目前已在配置中禁用(box.enabled = false)。', + boxUnavailableStdioRefused: + 'Stdio 模式的 MCP 服务器依赖 Box 沙箱,目前无法连接。', + boxStdioRefusedSuggestion: + '请启用 Box(box.enabled = true)并确认运行时连接正常,或将此服务器切换到 http/sse 模式。', toolsFound: '个工具', unknownError: '未知错误', noToolsFound: '未找到任何工具', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 6a3f2223..886bc348 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -709,6 +709,12 @@ const zhHant = { connectionSuccess: '連接成功', connectionFailed: '連接失敗,請檢查URL', connectionFailedStatus: '連接失敗', + boxDisabledStdioRefused: + 'Stdio 模式的 MCP 伺服器依賴 Box 沙箱,目前已在設定中停用(box.enabled = false)。', + boxUnavailableStdioRefused: + 'Stdio 模式的 MCP 伺服器依賴 Box 沙箱,目前無法連線。', + boxStdioRefusedSuggestion: + '請啟用 Box(box.enabled = true)並確認執行時連線正常,或將此伺服器切換到 http/sse 模式。', toolsFound: '個工具', unknownError: '未知錯誤', noToolsFound: '未找到任何工具',