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:
Junyan Qin
2026-05-20 18:03:47 +08:00
parent 216b1b9f03
commit aa8d53dde6
11 changed files with 93 additions and 4 deletions

View File

@@ -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)
}

View File

@@ -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>
)}

View File

@@ -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>