Spaces:
Paused
Paused
| import { PlusCircle, Loader2, Check } from 'lucide-react'; | |
| import { CircleAlert } from 'lucide-react'; | |
| import { useState, useCallback } from 'react'; | |
| import type { ReactNode } from 'react'; | |
| import type { MCPClient, StdioMCPConfig, HttpMCPConfig } from '@/api/types'; | |
| import { Alert, AlertDescription } from '@/components/ui/alert.tsx'; | |
| import { Button } from '@/components/ui/button.tsx'; | |
| import { Checkbox } from '@/components/ui/checkbox.tsx'; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogDescription, | |
| DialogFooter, | |
| DialogHeader, | |
| DialogTitle, | |
| DialogTrigger, | |
| } from '@/components/ui/dialog.tsx'; | |
| import { | |
| Field, | |
| FieldContent, | |
| FieldDescription, | |
| FieldGroup, | |
| FieldLabel, | |
| FieldSet, | |
| } from '@/components/ui/field.tsx'; | |
| import { InputGroup, InputGroupTextarea } from '@/components/ui/input-group.tsx'; | |
| import { useTranslation } from '@/i18n/useI18n.ts'; | |
| type Status = 'idle' | 'loading' | 'success' | 'error'; | |
| interface Props { | |
| children: ReactNode; | |
| onAdd: (mcps: MCPClient[]) => Promise<void>; | |
| } | |
| function parseMcpConfig( | |
| raw: string, | |
| keepAlive: boolean, | |
| t: (key: string, opts?: Record<string, string>) => string, | |
| ): MCPClient[] { | |
| let parsed: unknown; | |
| try { | |
| parsed = JSON.parse(raw); | |
| } catch (e) { | |
| throw new Error(t('dialog-mcp-create.parseError', { message: (e as Error).message })); | |
| } | |
| const obj = parsed as Record<string, unknown>; | |
| const servers = obj.mcpServers as Record<string, Record<string, unknown>> | undefined; | |
| if (!servers || typeof servers !== 'object') { | |
| throw new Error(t('dialog-mcp-create.missingMcpServers')); | |
| } | |
| const entries = Object.entries(servers); | |
| if (entries.length === 0) { | |
| throw new Error(t('dialog-mcp-create.emptyMcpServers')); | |
| } | |
| return entries.map(([name, config]) => { | |
| let mcp_config: StdioMCPConfig | HttpMCPConfig; | |
| if ('url' in config) { | |
| mcp_config = { | |
| type: 'http_mcp', | |
| url: config.url as string, | |
| headers: (config.headers as Record<string, string> | undefined) ?? null, | |
| timeout: (config.timeout as number | undefined) ?? null, | |
| }; | |
| } else { | |
| mcp_config = { | |
| type: 'stdio_mcp', | |
| command: config.command as string, | |
| args: (config.args as string[] | undefined) ?? null, | |
| env: (config.env as Record<string, string> | undefined) ?? null, | |
| cwd: (config.cwd as string | undefined) ?? null, | |
| }; | |
| } | |
| return { name, is_stateful: keepAlive, mcp_config }; | |
| }); | |
| } | |
| export const CreateMCPDialog = ({ children, onAdd }: Props) => { | |
| const { t } = useTranslation(); | |
| const [open, setOpen] = useState(false); | |
| const [configValue, setConfigValue] = useState(''); | |
| const [keepAlive, setKeepAlive] = useState(true); | |
| const [status, setStatus] = useState<Status>('idle'); | |
| const [errorMsg, setErrorMsg] = useState(''); | |
| const reset = useCallback(() => { | |
| setConfigValue(''); | |
| setKeepAlive(true); | |
| setStatus('idle'); | |
| setErrorMsg(''); | |
| }, []); | |
| const handleOpenChange = useCallback( | |
| (next: boolean) => { | |
| if (!next) reset(); | |
| setOpen(next); | |
| }, | |
| [reset], | |
| ); | |
| const handleAdd = useCallback(async () => { | |
| setErrorMsg(''); | |
| let mcpClients: MCPClient[]; | |
| try { | |
| mcpClients = parseMcpConfig(configValue, keepAlive, t); | |
| } catch (e) { | |
| setErrorMsg((e as Error).message); | |
| setStatus('error'); | |
| return; | |
| } | |
| setStatus('loading'); | |
| try { | |
| await onAdd(mcpClients); | |
| setStatus('success'); | |
| setTimeout(() => { | |
| handleOpenChange(false); | |
| }, 1500); | |
| } catch (e) { | |
| // ApiErrors are already shown via the global toast in client.ts. | |
| // Show only local validation errors (e.g. duplicate name) inline. | |
| const isApiError = e instanceof Error && e.name === 'ApiError'; | |
| if (!isApiError) { | |
| setErrorMsg(e instanceof Error ? e.message : String(e)); | |
| } | |
| setStatus('idle'); | |
| } | |
| }, [configValue, keepAlive, t, onAdd, handleOpenChange]); | |
| return ( | |
| <Dialog open={open} onOpenChange={handleOpenChange}> | |
| <DialogTrigger asChild>{children}</DialogTrigger> | |
| <DialogContent className="!w-[500px] !max-w-[500px]"> | |
| <DialogHeader> | |
| <DialogTitle>{t('dialog-mcp-create.title')}</DialogTitle> | |
| <DialogDescription>{t('dialog-mcp-create.description')}</DialogDescription> | |
| </DialogHeader> | |
| <FieldSet> | |
| <FieldGroup> | |
| <Field> | |
| <FieldContent> | |
| <FieldLabel>{t('dialog-mcp-create.configLabel')}</FieldLabel> | |
| </FieldContent> | |
| <InputGroup> | |
| <InputGroupTextarea | |
| className="max-h-100" | |
| value={configValue} | |
| onChange={(e) => setConfigValue(e.target.value)} | |
| placeholder={ | |
| '{\n "mcpServers": {\n "playwright": {\n "command": "npx",\n "args": ["@playwright/mcp@latest"]\n }\n }\n}' | |
| } | |
| /> | |
| </InputGroup> | |
| </Field> | |
| <Field orientation="horizontal"> | |
| <Checkbox | |
| id="mcp-keep-alive" | |
| checked={keepAlive} | |
| onCheckedChange={(v) => setKeepAlive(v === true)} | |
| /> | |
| <FieldContent> | |
| <FieldLabel htmlFor="mcp-keep-alive"> | |
| {t('dialog-mcp-create.keepAlive')} | |
| </FieldLabel> | |
| <FieldDescription> | |
| {t('dialog-mcp-create.keepAliveDesc')} | |
| </FieldDescription> | |
| </FieldContent> | |
| </Field> | |
| </FieldGroup> | |
| </FieldSet> | |
| {errorMsg && ( | |
| <Alert variant="destructive"> | |
| <CircleAlert /> | |
| <AlertDescription>{errorMsg}</AlertDescription> | |
| </Alert> | |
| )} | |
| <DialogFooter> | |
| <Button variant="ghost" onClick={() => handleOpenChange(false)}> | |
| <CircleAlert className="size-3.5" /> | |
| {t('common.cancel')} | |
| </Button> | |
| <Button | |
| onClick={handleAdd} | |
| disabled={status === 'loading' || status === 'success'} | |
| > | |
| {status === 'loading' && <Loader2 className="size-3.5 animate-spin" />} | |
| {status === 'success' && <Check className="size-3.5" />} | |
| {status !== 'loading' && status !== 'success' && ( | |
| <PlusCircle className="size-3.5" /> | |
| )} | |
| {status === 'loading' | |
| ? t('dialog-mcp-create.adding') | |
| : status === 'success' | |
| ? t('dialog-mcp-create.added') | |
| : t('common.add')} | |
| </Button> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| ); | |
| }; | |