From aa8d53dde60abb8b37370d2416ee0720897acf3a Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Wed, 20 May 2026 18:03:47 +0800 Subject: [PATCH] feat(mcp-web): block stdio MCP creation at the form when Box is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Box is disabled in config (``box.enabled = false``) or unreachable, saving a new MCP server in stdio mode produced one that could never start — the user would only learn that from the runtime error on the detail page. Stop the user before they save instead. Both MCP forms (the page-level ``MCPForm.tsx`` and the older dialog ``MCPFormDialog.tsx``) now: - Disable the ``stdio`` option in the mode select when Box is unavailable, with a small "(requires Box)" suffix so the reason is obvious. Existing stdio configs still display their current value - Show ``BoxUnavailableNotice`` inline under the mode select when the currently-selected mode is stdio and Box is unavailable, so editing a stale stdio config makes the cause visible - Disable the Save / Submit button while stdio is selected under that condition. ``MCPForm`` exposes a new ``onSaveBlockedChange`` prop so the parent ``MCPDetailContent`` can disable both its Submit and Save buttons. ``MCPFormDialog`` disables its Save button locally - Refuse the submit handler too (Enter-key path) with a toast carrying the same i18n message i18n: ``mcp.boxRequired`` (short tag in the disabled option) and ``mcp.stdioBlockedByBoxToast`` added to all 8 locales. Backend runtime gate (``_init_stdio_python_server`` refusal + ``BOX_UNAVAILABLE`` error_phase + retry short-circuit) stays in place as the last line of defence for API bypass. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/app/home/mcp/MCPDetailContent.tsx | 12 ++++++- .../home/mcp/components/mcp-form/MCPForm.tsx | 35 ++++++++++++++++++- .../mcp-server/mcp-form/MCPFormDialog.tsx | 26 ++++++++++++-- web/src/i18n/locales/en-US.ts | 3 ++ web/src/i18n/locales/es-ES.ts | 3 ++ web/src/i18n/locales/ja-JP.ts | 3 ++ web/src/i18n/locales/ru-RU.ts | 3 ++ web/src/i18n/locales/th-TH.ts | 3 ++ web/src/i18n/locales/vi-VN.ts | 3 ++ web/src/i18n/locales/zh-Hans.ts | 3 ++ web/src/i18n/locales/zh-Hant.ts | 3 ++ 11 files changed, 93 insertions(+), 4 deletions(-) diff --git a/web/src/app/home/mcp/MCPDetailContent.tsx b/web/src/app/home/mcp/MCPDetailContent.tsx index c2a2a07d..473676ab 100644 --- a/web/src/app/home/mcp/MCPDetailContent.tsx +++ b/web/src/app/home/mcp/MCPDetailContent.tsx @@ -58,6 +58,9 @@ export default function MCPDetailContent({ id }: { id: string }) { // Track whether the form has unsaved changes const [formDirty, setFormDirty] = useState(false); + // True when the form picked stdio mode but Box is disabled/unreachable — + // saving would create a server that can never start, so block it. + const [saveBlockedByBox, setSaveBlockedByBox] = useState(false); // Ref to MCPForm for triggering test from header const formRef = useRef(null); @@ -223,6 +226,7 @@ export default function MCPDetailContent({ id }: { id: string }) { - @@ -351,6 +360,7 @@ export default function MCPDetailContent({ id }: { id: string }) { onNewServerCreated={handleNewServerCreated} onDirtyChange={setFormDirty} onTestingChange={setMcpTesting} + onSaveBlockedChange={setSaveBlockedByBox} onRuntimeInfoChange={(runtimeInfo) => setDetailRuntimeStatus(runtimeInfo?.status ?? null) } 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 d611c2d5..09b73b27 100644 --- a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx +++ b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx @@ -50,6 +50,8 @@ import { MCPServerExtraArgsStdio, } from '@/app/infra/entities/api'; import { CustomApiError } from '@/app/infra/entities/common'; +import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice'; +import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus'; function StatusDisplay({ testing, @@ -357,6 +359,10 @@ interface MCPFormProps { onDirtyChange?: (dirty: boolean) => void; onTestingChange?: (testing: boolean) => void; onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void; + /** Reported when the form cannot be saved because the current mode is + * ``stdio`` and the Box sandbox is disabled/unavailable. Parents that + * render the Save button outside this component should disable it. */ + onSaveBlockedChange?: (blocked: boolean) => void; layout?: 'stacked' | 'split'; sideHeader?: ReactNode; sideFooter?: ReactNode; @@ -377,6 +383,7 @@ const MCPForm = forwardRef(function MCPForm( onDirtyChange, onTestingChange, onRuntimeInfoChange, + onSaveBlockedChange, layout = 'stacked', sideHeader, sideFooter, @@ -414,12 +421,22 @@ const MCPForm = forwardRef(function MCPForm( ); const pollingIntervalRef = useRef(null); const watchMode = form.watch('mode'); + const { available: boxAvailable, hint: boxHint } = useBoxStatus(); + // stdio mode requires the Box sandbox at runtime. If the user picks + // stdio while Box is disabled / unreachable, the server would refuse + // to start anyway — block creation upfront so they aren't surprised + // by an immediate "Connection failed" on the detail page. + const stdioBlockedByBox = watchMode === 'stdio' && !boxAvailable; const { isDirty } = form.formState; useEffect(() => { onDirtyChange?.(isDirty); }, [isDirty, onDirtyChange]); + useEffect(() => { + onSaveBlockedChange?.(stdioBlockedByBox); + }, [stdioBlockedByBox, onSaveBlockedChange]); + useEffect(() => { onTestingChange?.(mcpTesting); }, [mcpTesting, onTestingChange]); @@ -582,6 +599,12 @@ const MCPForm = forwardRef(function MCPForm( } async function handleFormSubmit(value: z.infer) { + // Belt-and-suspenders: even though the Save button is disabled when + // stdio is unselectable, intercept programmatic submits too. + if (value.mode === 'stdio' && !boxAvailable) { + toast.error(t('mcp.stdioBlockedByBoxToast')); + return; + } try { let serverConfig: MCPServer; @@ -833,10 +856,20 @@ const MCPForm = forwardRef(function MCPForm( {t('mcp.http')} - {t('mcp.stdio')} + + {t('mcp.stdio')} + {!boxAvailable && ( + + ({t('mcp.boxRequired')}) + + )} + {t('mcp.sse')} + {stdioBlockedByBox && ( + + )} )} 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 6c43b5c3..746a2e22 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 @@ -47,6 +47,8 @@ import { MCPServerExtraArgsStdio, } from '@/app/infra/entities/api'; import { CustomApiError } from '@/app/infra/entities/common'; +import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice'; +import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus'; // Status Display Component - 在测试中、连接中或连接失败时使用 function StatusDisplay({ @@ -237,6 +239,10 @@ export default function MCPFormDialog({ const pollingIntervalRef = useRef(null); const watchMode = form.watch('mode'); + const { available: boxAvailable, hint: boxHint } = useBoxStatus(); + // stdio mode requires the Box sandbox at runtime. Block creation here + // so users aren't surprised by a connection failure on the detail page. + const stdioBlockedByBox = watchMode === 'stdio' && !boxAvailable; // Load server data when editing useEffect(() => { @@ -360,6 +366,12 @@ export default function MCPFormDialog({ } async function handleFormSubmit(value: z.infer) { + // Belt-and-suspenders: Save button is also disabled in this case, but + // a programmatic submit (e.g. Enter key) should still be refused. + if (value.mode === 'stdio' && !boxAvailable) { + toast.error(t('mcp.stdioBlockedByBoxToast')); + return; + } try { let serverConfig: MCPServer; @@ -652,10 +664,20 @@ export default function MCPFormDialog({ {t('mcp.http')} - {t('mcp.stdio')} + + {t('mcp.stdio')} + {!boxAvailable && ( + + ({t('mcp.boxRequired')}) + + )} + {t('mcp.sse')} + {stdioBlockedByBox && ( + + )} )} @@ -847,7 +869,7 @@ export default function MCPFormDialog({ )} - diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 690e8f3b..45c418a4 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -744,6 +744,9 @@ const enUS = { '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.', + boxRequired: 'requires Box', + stdioBlockedByBoxToast: + 'Stdio MCP cannot be saved while the Box sandbox is disabled or unreachable. Enable Box or pick http/sse.', 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 762eb082..902ac004 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -758,6 +758,9 @@ const esES = { '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.', + boxRequired: 'requiere Box', + stdioBlockedByBoxToast: + 'No se puede guardar el MCP en modo stdio mientras el sandbox de Box está desactivado o no disponible. Active Box o seleccione 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 84757c50..ae32b048 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -749,6 +749,9 @@ const jaJP = { 'Stdio モードの MCP サーバーは Box サンドボックスを必要としますが、現在接続できません。', boxStdioRefusedSuggestion: 'Box を有効化(box.enabled = true)してランタイムの接続を確認するか、このサーバーを http/sse モードに切り替えてください。', + boxRequired: 'Box が必要', + stdioBlockedByBoxToast: + 'Box サンドボックスが無効または利用できないため、stdio モードの MCP は保存できません。Box を有効化するか、http/sse モードに切り替えてください。', toolsFound: '個のツール', unknownError: '不明なエラー', noToolsFound: 'ツールが見つかりません', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 1cfd5ea6..e57f6df8 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -754,6 +754,9 @@ const ruRU = { 'MCP-серверы в режиме stdio требуют песочницу Box, которая сейчас недоступна.', boxStdioRefusedSuggestion: 'Включите Box (box.enabled = true) и убедитесь, что среда работает, либо переключите этот сервер в режим http/sse.', + boxRequired: 'требуется Box', + stdioBlockedByBoxToast: + 'Сохранить MCP в режиме stdio нельзя: песочница Box отключена или недоступна. Включите Box либо выберите режим http/sse.', toolsFound: 'инструментов', unknownError: 'Неизвестная ошибка', noToolsFound: 'Инструменты не найдены', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index e7de5665..5601d8be 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -734,6 +734,9 @@ const thTH = { 'MCP server แบบ stdio ต้องใช้ Sandbox Box ซึ่งขณะนี้เชื่อมต่อไม่ได้', boxStdioRefusedSuggestion: 'กรุณาเปิดใช้งาน Box (box.enabled = true) และตรวจสอบว่ารันไทม์ทำงานปกติ หรือเปลี่ยน MCP server เป็นโหมด http/sse', + boxRequired: 'ต้องใช้ Box', + stdioBlockedByBoxToast: + 'ไม่สามารถบันทึก MCP โหมด stdio เนื่องจาก Sandbox Box ถูกปิดใช้งานหรือไม่พร้อมใช้งาน กรุณาเปิดใช้งาน Box หรือเลือกโหมด http/sse', toolsFound: 'เครื่องมือ', unknownError: 'ข้อผิดพลาดที่ไม่ทราบสาเหตุ', noToolsFound: 'ไม่พบเครื่องมือ', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index d79ebc67..f73071fc 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -748,6 +748,9 @@ const viVN = { '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.', + boxRequired: 'cần Box', + stdioBlockedByBoxToast: + 'Không thể lưu MCP ở chế độ stdio khi Sandbox Box bị tắt hoặc không khả dụng. Hãy bật Box hoặc chọn 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 096cdb97..6bafb8e5 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -716,6 +716,9 @@ const zhHans = { 'Stdio 模式的 MCP 服务器依赖 Box 沙箱,目前无法连接。', boxStdioRefusedSuggestion: '请启用 Box(box.enabled = true)并确认运行时连接正常,或将此服务器切换到 http/sse 模式。', + boxRequired: '需要 Box', + stdioBlockedByBoxToast: + 'Box 沙箱已禁用或不可用,无法保存 stdio 模式的 MCP。请启用 Box 或改为 http/sse 模式。', toolsFound: '个工具', unknownError: '未知错误', noToolsFound: '未找到任何工具', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 886bc348..4416d323 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -715,6 +715,9 @@ const zhHant = { 'Stdio 模式的 MCP 伺服器依賴 Box 沙箱,目前無法連線。', boxStdioRefusedSuggestion: '請啟用 Box(box.enabled = true)並確認執行時連線正常,或將此伺服器切換到 http/sse 模式。', + boxRequired: '需要 Box', + stdioBlockedByBoxToast: + 'Box 沙箱已停用或無法使用,無法儲存 stdio 模式的 MCP。請啟用 Box 或改為 http/sse 模式。', toolsFound: '個工具', unknownError: '未知錯誤', noToolsFound: '未找到任何工具',