mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat(mcp-web): block stdio MCP creation at the form when Box is unavailable
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<MCPFormHandle>(null);
|
||||
@@ -223,6 +226,7 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
<Button
|
||||
type="submit"
|
||||
form="mcp-form"
|
||||
disabled={saveBlockedByBox}
|
||||
onClick={async (e) => {
|
||||
if (!(await checkExtensionsLimit())) {
|
||||
e.preventDefault();
|
||||
@@ -242,6 +246,7 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewServerCreated={handleNewServerCreated}
|
||||
onTestingChange={setMcpTesting}
|
||||
onSaveBlockedChange={setSaveBlockedByBox}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,7 +339,11 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
>
|
||||
{t('common.test')}
|
||||
</Button>
|
||||
<Button type="submit" form="mcp-form" disabled={!formDirty}>
|
||||
<Button
|
||||
type="submit"
|
||||
form="mcp-form"
|
||||
disabled={!formDirty || saveBlockedByBox}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
onDirtyChange,
|
||||
onTestingChange,
|
||||
onRuntimeInfoChange,
|
||||
onSaveBlockedChange,
|
||||
layout = 'stacked',
|
||||
sideHeader,
|
||||
sideFooter,
|
||||
@@ -414,12 +421,22 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
);
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(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<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
}
|
||||
|
||||
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
|
||||
// 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<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">{t('mcp.http')}</SelectItem>
|
||||
<SelectItem value="stdio">{t('mcp.stdio')}</SelectItem>
|
||||
<SelectItem value="stdio" disabled={!boxAvailable}>
|
||||
{t('mcp.stdio')}
|
||||
{!boxAvailable && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({t('mcp.boxRequired')})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="sse">{t('mcp.sse')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{stdioBlockedByBox && (
|
||||
<BoxUnavailableNotice hint={boxHint} className="mt-2" />
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -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<NodeJS.Timeout | null>(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<typeof formSchema>) {
|
||||
// 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({
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">{t('mcp.http')}</SelectItem>
|
||||
<SelectItem value="stdio">{t('mcp.stdio')}</SelectItem>
|
||||
<SelectItem value="stdio" disabled={!boxAvailable}>
|
||||
{t('mcp.stdio')}
|
||||
{!boxAvailable && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({t('mcp.boxRequired')})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="sse">{t('mcp.sse')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{stdioBlockedByBox && (
|
||||
<BoxUnavailableNotice hint={boxHint} className="mt-2" />
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -847,7 +869,7 @@ export default function MCPFormDialog({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button type="submit">
|
||||
<Button type="submit" disabled={stdioBlockedByBox}>
|
||||
{isEditMode ? t('common.save') : t('common.submit')}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'ツールが見つかりません',
|
||||
|
||||
@@ -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: 'Инструменты не найдены',
|
||||
|
||||
@@ -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: 'ไม่พบเครื่องมือ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '未找到任何工具',
|
||||
|
||||
@@ -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: '未找到任何工具',
|
||||
|
||||
Reference in New Issue
Block a user