Files
openchamber/packages/ui/src/components/sections/mcp/McpPage.tsx
T
Bohdan Triapitsyn 2031e3b4a8 Decouple bundled UI from runtime API and add remote instance tooling (#1228)
Add a packaged-client runtime boundary so the shared UI can talk to local,
desktop, remote, and VS Code runtimes through the right transport instead of
assuming one same-origin web server.

Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and
runtime URL helpers, while keeping official OpenCode traffic on the SDK path.
Support runtime switching, remote host selection, desktop client credentials,
and headless connection links for pairing packaged clients with remote
OpenChamber servers.

Harden the new auth model by moving long-lived client tokens out of browser
URLs, introducing short-lived scoped URL tokens for browser-owned transports,
restricting URL-token access to explicit readable/realtime routes, and making
client-token management session-scoped or self-scoped as appropriate.

Update browser-owned assets and preview proxy flows to work with the split
runtime model, including authenticated project icons, preview token propagation,
CSP-safe preview bridge injection, and preview proxy auth that survives
short-lived URL-token expiry.

Tighten Electron security boundaries for packaged clients by gating privileged
preload state to trusted origins and requiring explicit confirmation before
connect deep-links import or switch remote runtimes.

Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
2026-06-02 00:43:05 +03:00

1968 lines
78 KiB
TypeScript

import React from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import { isVSCodeRuntime } from '@/lib/desktop';
import {
useMcpConfigStore,
envRecordToArray,
type McpDraft,
type McpScope,
} from '@/stores/useMcpConfigStore';
import { useShallow } from 'zustand/react/shallow';
import {
parseImportedMcpSnippet,
applyImportedMcpToDraft,
} from './mcpImport';
import { useMcpStore } from '@/stores/useMcpStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { cn } from '@/lib/utils';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
// ─────────────────────────────────────────────────────────────
// CommandTextarea — one arg per line, paste-friendly
// ─────────────────────────────────────────────────────────────
interface CommandTextareaProps {
value: string[];
onChange: (v: string[]) => void;
pasteCommandTitle: string;
pasteCommandLabel: string;
pasteSuccess: (count: number) => string;
clipboardReadFailed: string;
preview: (count: number) => string;
}
/**
* Splits a shell-like command string into argv array.
* Handles simple quoted args (single/double) and plain tokens.
*/
function parseShellCommand(raw: string): string[] {
const args: string[] = [];
let current = '';
let inSingle = false;
let inDouble = false;
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; }
if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; }
if ((ch === ' ' || ch === '\t') && !inSingle && !inDouble) {
if (current) { args.push(current); current = ''; }
continue;
}
current += ch;
}
if (current) args.push(current);
return args;
}
function extractAuthorizationResponse(raw: string): {
code: string | null;
context: { name: string; directory: string | null } | null;
stateKey: string | null;
} {
const trimmed = raw.trim();
if (!trimmed) {
return { code: null, context: null, stateKey: null };
}
try {
const parsed = new URL(trimmed);
const code = parsed.searchParams.get('code');
if (typeof code === 'string' && code.trim()) {
return {
code: code.trim(),
context: parseMcpOAuthCallbackContext(parsed.searchParams),
stateKey: parseMcpOAuthCallbackStateKey(parsed.searchParams),
};
}
} catch {
// Fall through to treating the pasted value as a raw authorization code.
}
return {
code: trimmed,
context: null,
stateKey: null,
};
}
const CommandTextarea: React.FC<CommandTextareaProps> = ({
value,
onChange,
pasteCommandTitle,
pasteCommandLabel,
pasteSuccess,
clipboardReadFailed,
preview,
}) => {
// Internal: one arg per line
const [text, setText] = React.useState(() => value.join('\n'));
// Sync when external value changes (e.g. switching servers)
const prevValueRef = React.useRef(value);
React.useEffect(() => {
if (JSON.stringify(prevValueRef.current) !== JSON.stringify(value)) {
prevValueRef.current = value;
setText(value.join('\n'));
}
}, [value]);
const commit = (raw: string) => {
const lines = raw.split('\n').filter((l) => l.trim().length > 0);
onChange(lines);
};
const handlePasteFromClipboard = async () => {
try {
const raw = await navigator.clipboard.readText();
const trimmed = raw.trim();
// If it looks like a multi-line list, keep as-is; otherwise parse as shell command
const lines = trimmed.includes('\n')
? trimmed.split('\n').filter((l) => l.trim())
: parseShellCommand(trimmed);
setText(lines.join('\n'));
onChange(lines);
toast.success(pasteSuccess(lines.length));
} catch {
toast.error(clipboardReadFailed);
}
};
return (
<div className="space-y-2" data-bwignore="true" data-1p-ignore="true" data-lpignore="true">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="xs"
className="!font-normal gap-1 text-muted-foreground"
onClick={handlePasteFromClipboard}
type="button"
title={pasteCommandTitle}
>
<Icon name="clipboard" className="h-3 w-3" />
{pasteCommandLabel}
</Button>
</div>
<Textarea
value={text}
onChange={(e) => {
setText(e.target.value);
commit(e.target.value);
}}
onBlur={() => {
// Normalise on blur: strip trailing spaces from each line
const cleaned = text
.split('\n')
.map((l) => l.trimEnd())
.join('\n');
setText(cleaned);
commit(cleaned);
}}
placeholder={
'npx\n-y\n@modelcontextprotocol/server-postgres\npostgresql://user:pass@host/db'
}
rows={Math.max(4, value.length + 1)}
className="font-mono typography-meta resize-y min-h-[80px]"
spellCheck={false}
/>
{/* Formatted preview of what will be saved */}
{value.length > 0 && (
<details className="group">
<summary className="typography-micro text-muted-foreground/60 cursor-pointer select-none hover:text-muted-foreground">
{preview(value.length)}
</summary>
<div className="mt-1 rounded-md bg-[var(--surface-elevated)] px-3 py-2 overflow-x-auto">
<code className="typography-micro text-foreground/80 whitespace-pre">
{value.map((a, i) => (
<span key={i} className="block">
<span className="text-muted-foreground select-none mr-2">[{i}]</span>
{a}
</span>
))}
</code>
</div>
</details>
)}
</div>
);
};
// ─────────────────────────────────────────────────────────────
// EnvEditor — compact rows, wide value, paste .env support
// ─────────────────────────────────────────────────────────────
interface EnvEntry { key: string; value: string; }
interface EnvEditorProps {
value: EnvEntry[];
onChange: (v: EnvEntry[]) => void;
keyTransform?: (value: string) => string;
keyPlaceholder?: string;
keyInputClassName?: string;
pasteLabel?: string;
pasteTitle?: string;
noPairsFoundError: string;
importSuccess: (count: number) => string;
clipboardReadFailed: string;
keyLabel: string;
valueLabel: string;
valuePlaceholder: string;
hideValueTitle: string;
showValueTitle: string;
addVariable: string;
plainTextWarning: string;
removeVariableAria: string;
}
const normalizeEnvKey = (value: string): string => value.toUpperCase().replace(/[^A-Z0-9_]/g, '_');
const EnvEditor: React.FC<EnvEditorProps> = ({
value,
onChange,
keyTransform = normalizeEnvKey,
keyPlaceholder = 'API_KEY',
keyInputClassName = 'w-36 shrink-0 font-mono typography-meta uppercase',
pasteLabel = 'Paste .env',
pasteTitle = 'Paste KEY=VALUE lines from clipboard',
noPairsFoundError,
importSuccess,
clipboardReadFailed,
keyLabel,
valueLabel,
valuePlaceholder,
hideValueTitle,
showValueTitle,
addVariable,
plainTextWarning,
removeVariableAria,
}) => {
const [revealedKeys, setRevealedKeys] = React.useState<Set<number>>(new Set());
const addRow = () => onChange([...value, { key: '', value: '' }]);
const removeRow = (idx: number) => {
onChange(value.filter((_, i) => i !== idx));
setRevealedKeys((prev) => {
const next = new Set(prev);
next.delete(idx);
return next;
});
};
const updateRow = (idx: number, field: 'key' | 'value', val: string) => {
const next = [...value];
next[idx] = { ...next[idx], [field]: val };
onChange(next);
};
const toggleReveal = (idx: number) => {
setRevealedKeys((prev) => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx); else next.add(idx);
return next;
});
};
const handlePasteDotEnv = async () => {
try {
const raw = await navigator.clipboard.readText();
const parsed: EnvEntry[] = [];
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
let val = trimmed.slice(eqIdx + 1).trim();
// Strip surrounding quotes
if ((val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (key) parsed.push({ key, value: val });
}
if (parsed.length === 0) {
toast.error(noPairsFoundError);
return;
}
// Merge: update existing keys, append new ones
const merged = [...value];
for (const p of parsed) {
const existing = merged.findIndex((e) => e.key === p.key);
if (existing !== -1) merged[existing] = p;
else merged.push(p);
}
onChange(merged);
toast.success(importSuccess(parsed.length));
} catch {
toast.error(clipboardReadFailed);
}
};
const hasSensitiveValues = value.some((e) => e.value.length > 0);
return (
<div className="space-y-2">
{/* Header row */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
<span className="typography-micro text-muted-foreground w-32 shrink-0">{keyLabel}</span>
<span className="typography-micro text-muted-foreground">{valueLabel}</span>
</div>
<Button
variant="ghost"
size="xs"
className="!font-normal gap-1 text-muted-foreground"
onClick={handlePasteDotEnv}
type="button"
title={pasteTitle}
>
<Icon name="clipboard" className="h-3 w-3" />
{pasteLabel}
</Button>
</div>
{/* Rows */}
<div className="space-y-1.5">
{value.map((entry, idx) => (
<div key={idx} className="flex items-center gap-2">
{/* KEY — fixed narrow width */}
<Input
value={entry.key}
onChange={(e) => updateRow(idx, 'key', keyTransform(e.target.value))}
placeholder={keyPlaceholder}
className={keyInputClassName}
data-bwignore="true"
data-1p-ignore="true"
data-lpignore="true"
spellCheck={false}
/>
{/* VALUE — takes remaining space */}
<div className="relative flex-1 flex items-center">
<Input
type={revealedKeys.has(idx) ? 'text' : 'password'}
value={entry.value}
onChange={(e) => updateRow(idx, 'value', e.target.value)}
placeholder={valuePlaceholder}
className="font-mono typography-meta pr-8 w-full"
autoComplete="new-password"
data-bwignore="true"
data-1p-ignore="true"
data-lpignore="true"
spellCheck={false}
/>
<button
type="button"
onClick={() => toggleReveal(idx)}
className="absolute right-2 text-muted-foreground/60 hover:text-muted-foreground"
title={revealedKeys.has(idx) ? hideValueTitle : showValueTitle}
>
{revealedKeys.has(idx)
? <Icon name="eye-off" className="h-3.5 w-3.5" />
: <Icon name="eye" className="h-3.5 w-3.5" />}
</button>
</div>
{/* Remove */}
<Button size="sm"
variant="ghost"
className="h-7 w-7 px-0 shrink-0 text-muted-foreground hover:text-[var(--status-error)]"
onClick={() => removeRow(idx)}
aria-label={removeVariableAria}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
<Button
variant="outline"
size="xs"
className="!font-normal gap-1.5"
onClick={addRow}
type="button"
>
<Icon name="add" className="h-3.5 w-3.5" />
{addVariable}
</Button>
{hasSensitiveValues && (
<p className="typography-micro text-muted-foreground/60">
{plainTextWarning}
</p>
)}
</div>
);
};
// ─────────────────────────────────────────────────────────────
// Status badge
// ─────────────────────────────────────────────────────────────
const StatusBadge: React.FC<{
status: string | undefined;
enabled: boolean;
getStatusLabel: (status: string) => string;
variant?: 'compact' | 'pill'
}> = ({ status, enabled, getStatusLabel, variant = 'compact' }) => {
if (!enabled) return null;
if (!status) return null;
const colorClassMap: Record<string, { text: string; bg: string }> = {
connected: { text: 'text-[var(--status-success)]', bg: 'bg-[var(--status-success)]/10' },
failed: { text: 'text-[var(--status-error)]', bg: 'bg-[var(--status-error)]/10' },
needs_auth: { text: 'text-[var(--status-warning)]', bg: 'bg-[var(--status-warning)]/10' },
needs_client_registration: { text: 'text-[var(--status-warning)]', bg: 'bg-[var(--status-warning)]/10' },
};
const colors = colorClassMap[status] ?? { text: 'text-muted-foreground', bg: '' };
if (variant === 'pill') {
return (
<span className={cn('typography-micro font-medium rounded-full px-2 py-0.5', colors.text, colors.bg)}>
● {getStatusLabel(status)}
</span>
);
}
return (
<span className={cn('typography-micro font-medium', colors.text)}>
● {getStatusLabel(status)}
</span>
);
};
const getStatusDescription = (
status: string | undefined,
t: (key: string, params?: Record<string, unknown>) => string,
error?: string
): string => {
switch (status) {
case 'connected':
return t('settings.mcp.page.status.description.connected');
case 'failed':
return error?.trim() || t('settings.mcp.page.status.description.failedDefault');
case 'needs_auth':
return t('settings.mcp.page.status.description.needsAuth');
case 'needs_client_registration':
return error?.trim() || t('settings.mcp.page.status.description.needsClientRegistrationDefault');
case 'disabled':
return t('settings.mcp.page.status.description.disabled');
default:
return t('settings.mcp.page.status.description.default');
}
};
const statusCardClass = (status: string | undefined): string => {
switch (status) {
case 'failed':
return 'border-[var(--status-error-border)] bg-[var(--status-error-background)]';
case 'needs_auth':
case 'needs_client_registration':
return 'border-[var(--status-warning-border)] bg-[var(--status-warning-background)]';
default:
return 'border-[var(--interactive-border)] bg-[var(--surface-elevated)]';
}
};
const shouldShowFullStatusCard = (status: string | undefined, authUrl: string | null, needsAuthorization: boolean, isAuthPolling: boolean): boolean => {
// Only show full card for error/warning states or when auth is in progress
if (status === 'failed' || status === 'needs_auth' || status === 'needs_client_registration') return true;
if (authUrl) return true;
if (needsAuthorization || isAuthPolling) return true;
return false;
};
const buildMcpOAuthRedirectUri = (name?: string | null, directory?: string | null): string | null => {
if (typeof window === 'undefined') {
return null;
}
const url = new URL(MCP_OAUTH_CALLBACK_PATH, getRuntimeApiBaseUrl() || window.location.origin);
if (typeof name === 'string' && name.trim()) {
url.searchParams.set('server', name.trim());
}
if (typeof directory === 'string' && directory.trim()) {
url.searchParams.set('directory', directory.trim());
}
return url.toString();
};
const queuePendingMcpAuthContext = async (input: {
state: string;
name: string;
directory?: string | null;
}): Promise<void> => {
const response = await runtimeFetch('/api/mcp/auth/pending', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
state: input.state,
name: input.name,
directory: typeof input.directory === 'string' && input.directory.trim() ? input.directory.trim() : null,
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error || 'Failed to prepare MCP authorization callback');
}
};
const getPendingMcpAuthContext = async (stateKey: string): Promise<{ name: string; directory: string | null } | null> => {
const response = await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey)}`);
if (!response.ok) {
return null;
}
const payload = await response.json().catch(() => null) as { name?: string; directory?: string | null } | null;
if (!payload?.name?.trim()) {
return null;
}
return {
name: payload.name.trim(),
directory: typeof payload.directory === 'string' && payload.directory.trim() ? payload.directory.trim() : null,
};
};
const clearPendingMcpAuthContext = async (stateKey: string | null | undefined): Promise<void> => {
if (typeof stateKey !== 'string' || !stateKey.trim()) {
return;
}
await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey.trim())}`, { method: 'DELETE' }).catch(() => undefined);
};
const normalizeMcpAuthErrorMessage = (
error: unknown,
fallback: string,
t: (key: string, params?: Record<string, unknown>) => string
): string => {
const message = error instanceof Error ? error.message : fallback;
if (/oauth state required/i.test(message)) {
return t('settings.mcp.page.toast.authSessionExpired');
}
return message;
};
const buildMcpRuntimeActionKey = (name: string | null, directory?: string | null): string => {
const normalizedDirectory = typeof directory === 'string' && directory.trim()
? directory.trim()
: '__global__';
return `${name ?? '__none__'}::${normalizedDirectory}`;
};
// ─────────────────────────────────────────────────────────────
// McpPage
// ─────────────────────────────────────────────────────────────
export const McpPage: React.FC = () => {
const { t } = useI18n();
const tUnsafe = React.useCallback(
(key: string, params?: Record<string, unknown>) => t(key as never, params as never),
[t]
);
const {
selectedMcpName,
mcpServers,
mcpDraft,
setMcpDraft,
setSelectedMcp,
getMcpByName,
createMcp,
updateMcp,
deleteMcp,
} = useMcpConfigStore(useShallow((s) => ({
selectedMcpName: s.selectedMcpName,
mcpServers: s.mcpServers,
mcpDraft: s.mcpDraft,
setMcpDraft: s.setMcpDraft,
setSelectedMcp: s.setSelectedMcp,
getMcpByName: s.getMcpByName,
createMcp: s.createMcp,
updateMcp: s.updateMcp,
deleteMcp: s.deleteMcp,
})));
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const isVSCodeAuthRuntime = React.useMemo(() => isVSCodeRuntime(), []);
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
const mcpDiagnostics = useMcpStore((state) => state.getDiagnosticForDirectory(currentDirectory ?? null));
const refreshStatus = useMcpStore((state) => state.refresh);
const connectMcp = useMcpStore((state) => state.connect);
const disconnectMcp = useMcpStore((state) => state.disconnect);
const startAuthMcp = useMcpStore((state) => state.startAuth);
const completeAuthMcp = useMcpStore((state) => state.completeAuth);
const clearAuthMcp = useMcpStore((state) => state.clearAuth);
const testConnectionMcp = useMcpStore((state) => state.testConnection);
const selectedServer = selectedMcpName ? getMcpByName(selectedMcpName) : null;
const isNewServer = Boolean(mcpDraft && mcpDraft.name === selectedMcpName && !selectedServer);
// ── form state ──
const [draftName, setDraftName] = React.useState('');
const [draftScope, setDraftScope] = React.useState<McpScope>('user');
const [mcpType, setMcpType] = React.useState<'local' | 'remote'>('local');
const [command, setCommand] = React.useState<string[]>([]);
const [url, setUrl] = React.useState('');
const [envEntries, setEnvEntries] = React.useState<Array<{ key: string; value: string }>>([]);
const [headerEntries, setHeaderEntries] = React.useState<Array<{ key: string; value: string }>>([]);
const [oauthEnabled, setOauthEnabled] = React.useState(true);
const [oauthClientId, setOauthClientId] = React.useState('');
const [oauthClientSecret, setOauthClientSecret] = React.useState('');
const [oauthScope, setOauthScope] = React.useState('');
const [oauthRedirectUri, setOauthRedirectUri] = React.useState('');
const [timeout, setTimeoutValue] = React.useState('');
const [enabled, setEnabled] = React.useState(true);
const [isSaving, setIsSaving] = React.useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
const [isConnecting, setIsConnecting] = React.useState(false);
const [isAuthorizing, setIsAuthorizing] = React.useState(false);
const [isClearingAuth, setIsClearingAuth] = React.useState(false);
const [isTestingConnection, setIsTestingConnection] = React.useState(false);
const [isCompletingAuth, setIsCompletingAuth] = React.useState(false);
const [authUrl, setAuthUrl] = React.useState<string | null>(null);
const [authStateKey, setAuthStateKey] = React.useState<string | null>(null);
const [authCallbackInput, setAuthCallbackInput] = React.useState('');
const [isAuthPolling, setIsAuthPolling] = React.useState(false);
const authPollAttemptsRef = React.useRef(0);
const authPollStartsFromNeedsAuthRef = React.useRef(false);
const [isAdvancedRemoteOptionsOpen, setIsAdvancedRemoteOptionsOpen] = React.useState(false);
const [showImportDialog, setShowImportDialog] = React.useState(false);
const [importJsonText, setImportJsonText] = React.useState('');
const [importError, setImportError] = React.useState<string | null>(null);
const runtimeActionKey = React.useMemo(
() => buildMcpRuntimeActionKey(selectedMcpName, currentDirectory),
[currentDirectory, selectedMcpName],
);
const runtimeActionKeyRef = React.useRef(runtimeActionKey);
const initialRef = React.useRef<{
mcpType: 'local' | 'remote'; command: string[]; url: string;
envEntries: Array<{ key: string; value: string }>;
headerEntries: Array<{ key: string; value: string }>;
oauthEnabled: boolean;
oauthClientId: string;
oauthClientSecret: string;
oauthScope: string;
oauthRedirectUri: string;
timeout: string;
enabled: boolean;
} | null>(null);
const resetTransientAuthState = React.useCallback(() => {
setAuthUrl(null);
setAuthStateKey(null);
setAuthCallbackInput('');
setIsAuthPolling(false);
authPollAttemptsRef.current = 0;
setIsCompletingAuth(false);
setIsAuthorizing(false);
setIsClearingAuth(false);
authPollStartsFromNeedsAuthRef.current = false;
}, []);
const handleOpenImportDialog = React.useCallback(() => {
setImportJsonText('');
setImportError(null);
setShowImportDialog(true);
}, []);
const handlePasteImportClipboard = React.useCallback(async () => {
try {
const text = await navigator.clipboard.readText();
setImportJsonText(text);
setImportError(null);
} catch {
toast.error(t('settings.mcp.page.toast.clipboardReadFailed'));
}
}, [t]);
const handleImportJson = React.useCallback(() => {
const outcome = parseImportedMcpSnippet(importJsonText, { fallbackName: draftName });
if (!outcome.ok) {
setImportError(outcome.error);
return;
}
const partial = {
name: draftName,
scope: draftScope,
type: mcpType,
command,
url,
environment: envEntries,
headers: headerEntries,
oauthEnabled,
oauthClientId,
oauthClientSecret,
oauthScope,
oauthRedirectUri,
timeout,
enabled,
};
const next = applyImportedMcpToDraft(outcome, partial, { isNewServer });
setDraftName(next.name);
setMcpType(next.type as 'local' | 'remote');
setCommand(next.command ?? []);
setUrl(next.url ?? '');
setEnvEntries(next.environment ?? []);
setHeaderEntries(next.headers ?? []);
setOauthEnabled(next.oauthEnabled ?? false);
setOauthClientId(next.oauthClientId ?? '');
setOauthClientSecret(next.oauthClientSecret ?? '');
setOauthScope(next.oauthScope ?? '');
setOauthRedirectUri(next.oauthRedirectUri ?? '');
setTimeoutValue(next.timeout ?? '');
setEnabled(next.enabled ?? true);
setShowImportDialog(false);
setImportJsonText('');
setImportError(null);
toast.success(t('settings.mcp.page.toast.configImported'));
}, [
importJsonText,
draftName,
draftScope,
mcpType,
command,
url,
envEntries,
headerEntries,
oauthEnabled,
oauthClientId,
oauthClientSecret,
oauthScope,
oauthRedirectUri,
timeout,
enabled,
isNewServer,
t,
]);
// Populate form when selection changes
React.useEffect(() => {
if (isNewServer && mcpDraft) {
setDraftName(mcpDraft.name);
setDraftScope(mcpDraft.scope || 'user');
setMcpType(mcpDraft.type);
setCommand(mcpDraft.command);
setUrl(mcpDraft.url);
setEnvEntries(mcpDraft.environment);
setHeaderEntries(mcpDraft.headers);
setOauthEnabled(mcpDraft.oauthEnabled);
setOauthClientId(mcpDraft.oauthClientId);
setOauthClientSecret(mcpDraft.oauthClientSecret);
setOauthScope(mcpDraft.oauthScope);
setOauthRedirectUri(mcpDraft.oauthRedirectUri);
setTimeoutValue(mcpDraft.timeout);
setEnabled(mcpDraft.enabled);
setIsAdvancedRemoteOptionsOpen(false);
initialRef.current = {
mcpType: mcpDraft.type, command: mcpDraft.command,
url: mcpDraft.url,
envEntries: mcpDraft.environment,
headerEntries: mcpDraft.headers,
oauthEnabled: mcpDraft.oauthEnabled,
oauthClientId: mcpDraft.oauthClientId,
oauthClientSecret: mcpDraft.oauthClientSecret,
oauthScope: mcpDraft.oauthScope,
oauthRedirectUri: mcpDraft.oauthRedirectUri,
timeout: mcpDraft.timeout,
enabled: mcpDraft.enabled,
};
return;
}
if (selectedServer) {
setDraftScope(selectedServer.scope === 'project' ? 'project' : 'user');
const envArr = envRecordToArray(selectedServer.environment);
const remoteServer = selectedServer.type === 'remote'
? selectedServer as typeof selectedServer & {
headers?: Record<string, string>;
oauth?: {
clientId?: string;
clientSecret?: string;
scope?: string;
redirectUri?: string;
} | false;
timeout?: number;
}
: null;
const headersArr = envRecordToArray(remoteServer?.headers);
const oauth = remoteServer?.oauth;
const oauthConfig = oauth && typeof oauth === 'object' ? oauth : null;
const nextOauthEnabled = oauth !== false;
const serverType = selectedServer.type;
const cmd = serverType === 'local' ? ((selectedServer as { command?: string[] }).command ?? []) : [];
const u = serverType === 'remote' ? ((selectedServer as { url?: string }).url ?? '') : '';
const nextTimeout = typeof remoteServer?.timeout === 'number' && Number.isFinite(remoteServer.timeout)
? String(remoteServer.timeout)
: '';
setMcpType(serverType);
setCommand(cmd);
setUrl(u);
setEnvEntries(envArr);
setHeaderEntries(headersArr);
setOauthEnabled(nextOauthEnabled);
setOauthClientId(nextOauthEnabled ? (oauthConfig?.clientId ?? '') : '');
setOauthClientSecret(nextOauthEnabled ? (oauthConfig?.clientSecret ?? '') : '');
setOauthScope(nextOauthEnabled ? (oauthConfig?.scope ?? '') : '');
setOauthRedirectUri(nextOauthEnabled ? (oauthConfig?.redirectUri ?? '') : '');
setTimeoutValue(nextTimeout);
setEnabled(selectedServer.enabled);
setIsAdvancedRemoteOptionsOpen(false);
initialRef.current = {
mcpType: serverType,
command: cmd,
url: u,
envEntries: envArr,
headerEntries: headersArr,
oauthEnabled: nextOauthEnabled,
oauthClientId: nextOauthEnabled ? (oauthConfig?.clientId ?? '') : '',
oauthClientSecret: nextOauthEnabled ? (oauthConfig?.clientSecret ?? '') : '',
oauthScope: nextOauthEnabled ? (oauthConfig?.scope ?? '') : '',
oauthRedirectUri: nextOauthEnabled ? (oauthConfig?.redirectUri ?? '') : '',
timeout: nextTimeout,
enabled: selectedServer.enabled,
};
}
}, [selectedServer, isNewServer, mcpDraft]);
const isDirty = React.useMemo(() => {
const init = initialRef.current;
if (!init) return false;
return (
mcpType !== init.mcpType ||
enabled !== init.enabled ||
JSON.stringify(command) !== JSON.stringify(init.command) ||
url !== init.url ||
JSON.stringify(envEntries) !== JSON.stringify(init.envEntries) ||
JSON.stringify(headerEntries) !== JSON.stringify(init.headerEntries) ||
oauthEnabled !== init.oauthEnabled ||
oauthClientId !== init.oauthClientId ||
oauthClientSecret !== init.oauthClientSecret ||
oauthScope !== init.oauthScope ||
oauthRedirectUri !== init.oauthRedirectUri ||
timeout !== init.timeout
);
}, [mcpType, command, url, envEntries, headerEntries, oauthEnabled, oauthClientId, oauthClientSecret, oauthScope, oauthRedirectUri, timeout, enabled]);
const handleSave = async () => {
const name = isNewServer ? draftName.trim() : selectedMcpName ?? '';
if (!name) { toast.error(t('settings.mcp.page.toast.nameRequired')); return; }
if (isNewServer && mcpServers.some((s) => s.name === name)) {
toast.error(t('settings.mcp.page.toast.serverNameExists')); return;
}
if (mcpType === 'local' && command.filter(Boolean).length === 0) {
toast.error(t('settings.mcp.page.toast.localCommandRequired')); return;
}
if (mcpType === 'remote' && !url.trim()) {
toast.error(t('settings.mcp.page.toast.remoteUrlRequired')); return;
}
const draft: McpDraft = {
name,
scope: draftScope,
type: mcpType,
command,
url,
environment: envEntries,
headers: headerEntries,
oauthEnabled,
oauthClientId,
oauthClientSecret,
oauthScope,
oauthRedirectUri,
timeout,
enabled,
};
setIsSaving(true);
try {
const result = isNewServer ? await createMcp(draft) : await updateMcp(name, draft);
if (result.ok) {
await clearPendingMcpAuthContext(authStateKey);
resetTransientAuthState();
if (isNewServer) { setMcpDraft(null); setSelectedMcp(name); }
await refreshStatus({ directory: currentDirectory, silent: true });
if (result.reloadFailed) {
toast.warning(result.message || (isNewServer
? t('settings.mcp.page.toast.serverCreatedReloadFailed')
: t('settings.mcp.page.toast.savedReloadFailed')), {
description: result.warning || t('settings.mcp.page.toast.retryRefreshHint'),
});
} else {
toast.success(result.message || (isNewServer
? t('settings.mcp.page.toast.serverCreatedReloading')
: t('settings.mcp.page.toast.savedReloading')));
}
} else {
toast.error(t('settings.mcp.page.toast.saveFailed'));
}
} catch (err) {
toast.error(err instanceof Error ? err.message : t('settings.mcp.page.toast.unexpectedError'));
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!selectedMcpName) return;
setIsDeleting(true);
const result = await deleteMcp(selectedMcpName);
if (result.ok) {
await clearPendingMcpAuthContext(authStateKey);
resetTransientAuthState();
if (result.reloadFailed) {
toast.warning(result.message || t('settings.mcp.page.toast.serverDeletedReloadFailed', { name: selectedMcpName }), {
description: result.warning || t('settings.mcp.page.toast.refreshListIfStale'),
});
} else {
toast.success(result.message || t('settings.mcp.page.toast.serverDeleted', { name: selectedMcpName }));
}
setShowDeleteConfirm(false);
} else toast.error(t('settings.mcp.page.toast.deleteFailed'));
setIsDeleting(false);
};
const handleToggleConnect = async () => {
if (!selectedMcpName) return;
setIsConnecting(true);
try {
const isConnected = mcpStatus[selectedMcpName]?.status === 'connected';
if (isConnected) {
await disconnectMcp(selectedMcpName, currentDirectory);
toast.success(t('settings.mcp.page.toast.disconnected'));
} else {
await connectMcp(selectedMcpName, currentDirectory);
await refreshStatus({ directory: currentDirectory, silent: true });
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName];
if (nextStatus?.status === 'connected') {
toast.success(t('settings.mcp.page.toast.connected'));
} else if (nextStatus?.status === 'needs_auth') {
toast.message(t('settings.mcp.page.toast.connectionNeedsAuthorization'));
} else if (nextStatus?.status === 'needs_client_registration') {
toast.message(t('settings.mcp.page.toast.connectionNeedsClientRegistration'));
} else if (nextStatus?.status === 'failed') {
toast.error(nextStatus.error || t('settings.mcp.page.toast.connectionFailed'));
} else {
toast.message(t('settings.mcp.page.toast.connectionAttemptFinished'));
}
return;
}
await refreshStatus({ directory: currentDirectory, silent: true });
} catch (err) {
toast.error(err instanceof Error ? err.message : t('settings.mcp.page.toast.connectionFailed'));
} finally {
setIsConnecting(false);
}
};
const requireSavedConfig = React.useCallback((): boolean => {
if (isNewServer) {
toast.error(t('settings.mcp.page.toast.createServerBeforeLiveActions'));
return false;
}
if (isDirty) {
toast.error(t('settings.mcp.page.toast.saveBeforeLiveActions'));
return false;
}
return true;
}, [isDirty, isNewServer, t]);
const handleRefreshRuntimeStatus = React.useCallback(async (silent = false) => {
try {
await refreshStatus({ directory: currentDirectory, silent });
} catch (err) {
if (!silent) {
toast.error(err instanceof Error ? err.message : t('settings.mcp.page.toast.refreshStatusFailed'));
}
}
}, [currentDirectory, refreshStatus, t]);
React.useEffect(() => {
void handleRefreshRuntimeStatus(true);
}, [handleRefreshRuntimeStatus]);
React.useEffect(() => {
runtimeActionKeyRef.current = runtimeActionKey;
setIsConnecting(false);
setIsTestingConnection(false);
resetTransientAuthState();
}, [resetTransientAuthState, runtimeActionKey]);
const handleStartAuthorization = React.useCallback(async () => {
if (!selectedMcpName || mcpType !== 'remote' || !requireSavedConfig()) return;
setIsAuthorizing(true);
const actionKey = runtimeActionKey;
let queuedStateKey: string | null = null;
try {
const currentStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName]?.status;
authPollStartsFromNeedsAuthRef.current = currentStatus === 'needs_auth' || currentStatus === 'needs_client_registration';
const redirectUri = buildMcpOAuthRedirectUri(selectedMcpName, currentDirectory);
if (!redirectUri) {
throw new Error(t('settings.mcp.page.toast.oauthRedirectUrlBuildFailed'));
}
if (!oauthRedirectUri.trim() && !isVSCodeAuthRuntime) {
const saved = await updateMcp(selectedMcpName, {
oauthEnabled,
oauthClientId,
oauthClientSecret,
oauthScope,
oauthRedirectUri: redirectUri,
});
if (!saved.ok) {
throw new Error(t('settings.mcp.page.toast.oauthBrowserCallbackSaveFailed'));
}
if (saved.reloadFailed) {
throw new Error(saved.warning || saved.message || t('settings.mcp.page.toast.openCodeReloadFailedAfterCallbackSave'));
}
if (runtimeActionKeyRef.current !== actionKey) {
return;
}
setOauthRedirectUri(redirectUri);
initialRef.current = initialRef.current
? { ...initialRef.current, oauthRedirectUri: redirectUri }
: initialRef.current;
}
const nextAuthUrl = await startAuthMcp(selectedMcpName, currentDirectory);
const stateKey = parseMcpOAuthCallbackStateKey(new URL(nextAuthUrl).searchParams);
if (stateKey) {
queuedStateKey = stateKey;
await queuePendingMcpAuthContext({
state: stateKey,
name: selectedMcpName,
directory: currentDirectory,
});
}
if (runtimeActionKeyRef.current !== actionKey) {
return;
}
setAuthUrl(nextAuthUrl);
setAuthStateKey(stateKey ?? null);
setIsAuthPolling(true);
authPollAttemptsRef.current = 0;
const opened = await openExternalUrl(nextAuthUrl);
if (runtimeActionKeyRef.current !== actionKey) {
return;
}
if (opened) {
toast.message(
isVSCodeAuthRuntime
? t('settings.mcp.page.toast.completeAuthorizationInBrowserWithPaste')
: t('settings.mcp.page.toast.completeAuthorizationInBrowser'),
);
} else {
toast.error(t('settings.mcp.page.toast.openAuthorizationUrlFailed'));
}
} catch (err) {
await clearPendingMcpAuthContext(queuedStateKey);
if (runtimeActionKeyRef.current === actionKey) {
toast.error(normalizeMcpAuthErrorMessage(err, t('settings.mcp.page.toast.authorizationStartFailed'), tUnsafe));
}
} finally {
if (runtimeActionKeyRef.current === actionKey) {
setIsAuthorizing(false);
}
}
}, [currentDirectory, isVSCodeAuthRuntime, mcpType, oauthClientId, oauthClientSecret, oauthEnabled, oauthRedirectUri, oauthScope, requireSavedConfig, runtimeActionKey, selectedMcpName, startAuthMcp, t, tUnsafe, updateMcp]);
const handleClearAuthorization = React.useCallback(async () => {
if (!selectedMcpName || !requireSavedConfig()) return;
setIsClearingAuth(true);
const actionKey = runtimeActionKey;
try {
await clearAuthMcp(selectedMcpName, currentDirectory);
if (runtimeActionKeyRef.current !== actionKey) {
return;
}
setAuthUrl(null);
setAuthStateKey(null);
setAuthCallbackInput('');
setIsAuthPolling(false);
authPollAttemptsRef.current = 0;
await clearPendingMcpAuthContext(authStateKey);
toast.success(t('settings.mcp.page.toast.savedAuthorizationRemoved'));
} catch (err) {
if (runtimeActionKeyRef.current === actionKey) {
toast.error(normalizeMcpAuthErrorMessage(err, t('settings.mcp.page.toast.clearAuthorizationFailed'), tUnsafe));
}
} finally {
if (runtimeActionKeyRef.current === actionKey) {
setIsClearingAuth(false);
}
}
}, [authStateKey, clearAuthMcp, currentDirectory, requireSavedConfig, runtimeActionKey, selectedMcpName, t, tUnsafe]);
const handleCopyAuthUrl = React.useCallback(async () => {
if (!authUrl) return;
const result = await copyTextToClipboard(authUrl);
if (result.ok) {
toast.success(t('settings.mcp.page.toast.authorizationUrlCopied'));
return;
}
toast.error(t('settings.mcp.page.toast.authorizationUrlCopyFailed'));
}, [authUrl, t]);
const handleCompleteAuthorization = React.useCallback(async () => {
const response = extractAuthorizationResponse(authCallbackInput);
if (!response.code) {
toast.error(t('settings.mcp.page.toast.pasteCallbackOrCodeFirst'));
return;
}
const pendingContext = response.stateKey ? await getPendingMcpAuthContext(response.stateKey) : null;
const resolvedContext = response.context ?? pendingContext;
const targetName = resolvedContext?.name ?? selectedMcpName;
const targetDirectory = resolvedContext?.directory ?? currentDirectory;
if (!targetName) {
toast.error(t('settings.mcp.page.toast.missingServerDetails'));
return;
}
if (!resolvedContext && !requireSavedConfig()) return;
setIsCompletingAuth(true);
const actionKey = runtimeActionKey;
try {
await completeAuthMcp(targetName, response.code, targetDirectory);
await clearPendingMcpAuthContext(response.stateKey ?? authStateKey);
if (runtimeActionKeyRef.current !== actionKey) {
return;
}
setAuthCallbackInput('');
setAuthUrl(null);
setAuthStateKey(null);
setIsAuthPolling(false);
authPollAttemptsRef.current = 0;
toast.success(targetName === selectedMcpName
? t('settings.mcp.page.toast.authorizationCompleted')
: t('settings.mcp.page.toast.authorizationCompletedFor', { name: targetName }));
} catch (err) {
if (runtimeActionKeyRef.current === actionKey) {
toast.error(normalizeMcpAuthErrorMessage(err, t('settings.mcp.page.toast.authorizationCompleteFailed'), tUnsafe));
}
} finally {
if (runtimeActionKeyRef.current === actionKey) {
setIsCompletingAuth(false);
}
}
}, [authCallbackInput, authStateKey, completeAuthMcp, currentDirectory, requireSavedConfig, runtimeActionKey, selectedMcpName, t, tUnsafe]);
const handleTestConnection = React.useCallback(async () => {
if (!selectedMcpName || !requireSavedConfig()) return;
if (!enabled) {
toast.error(t('settings.mcp.page.toast.enableServerBeforeTest'));
return;
}
setIsTestingConnection(true);
try {
const result = await testConnectionMcp(selectedMcpName, currentDirectory);
const nextStatus = result.status?.status;
if (result.warning) {
toast.warning(result.warning);
} else if (nextStatus === 'connected') {
toast.success(t('settings.mcp.page.toast.connectionTestSucceeded'));
} else if (nextStatus === 'needs_auth') {
toast.message(t('settings.mcp.page.toast.connectionNeedsAuthorization'));
} else if (nextStatus === 'needs_client_registration') {
toast.message(t('settings.mcp.page.toast.connectionNeedsClientRegistration'));
} else if (nextStatus === 'failed') {
toast.error(result.status?.error || result.error || t('settings.mcp.page.toast.connectionTestFailed'));
} else if (result.error) {
toast.error(result.error);
} else {
toast.message(t('settings.mcp.page.toast.connectionTestFinished'));
}
} catch (err) {
toast.error(err instanceof Error ? err.message : t('settings.mcp.page.toast.connectionTestFailed'));
} finally {
setIsTestingConnection(false);
}
}, [currentDirectory, enabled, requireSavedConfig, selectedMcpName, t, testConnectionMcp]);
React.useEffect(() => {
if (!isAuthPolling || !selectedMcpName) {
return;
}
const intervalId = window.setInterval(() => {
void (async () => {
authPollAttemptsRef.current += 1;
await refreshStatus({ directory: currentDirectory, silent: true });
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName];
if (!nextStatus) {
return;
}
if (
authPollStartsFromNeedsAuthRef.current
&& nextStatus.status !== 'needs_auth'
&& nextStatus.status !== 'needs_client_registration'
) {
setIsAuthPolling(false);
authPollAttemptsRef.current = 0;
authPollStartsFromNeedsAuthRef.current = false;
setAuthUrl(null);
setAuthCallbackInput('');
if (nextStatus.status === 'connected') {
toast.success(t('settings.mcp.page.toast.authorizationCompleted'));
}
return;
}
if (!authPollStartsFromNeedsAuthRef.current && nextStatus.status === 'failed') {
setIsAuthPolling(false);
authPollAttemptsRef.current = 0;
authPollStartsFromNeedsAuthRef.current = false;
toast.error(nextStatus.error || t('settings.mcp.page.toast.authorizationFailed'));
return;
}
if (authPollAttemptsRef.current >= 30) {
setIsAuthPolling(false);
authPollAttemptsRef.current = 0;
authPollStartsFromNeedsAuthRef.current = false;
toast.message(t('settings.mcp.page.toast.authorizationStillInProgress'));
}
})();
}, 2000);
return () => {
window.clearInterval(intervalId);
};
}, [currentDirectory, isAuthPolling, refreshStatus, selectedMcpName, t]);
// ── Empty state ──
if (!selectedMcpName) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<Icon name="plug" className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">{t('settings.mcp.page.empty.selectServer')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.mcp.page.empty.addNewOne')}</p>
</div>
</div>
);
}
const runtimeStatus = mcpStatus[selectedMcpName];
const runtimeDiagnostic = selectedMcpName ? mcpDiagnostics[selectedMcpName] : undefined;
const effectiveRuntimeStatus = runtimeStatus ?? runtimeDiagnostic;
const isConnected = runtimeStatus?.status === 'connected';
const needsAuthorization = runtimeStatus?.status === 'needs_auth' || runtimeStatus?.status === 'needs_client_registration';
const suggestedRedirectUri = isVSCodeAuthRuntime ? null : buildMcpOAuthRedirectUri(selectedMcpName, currentDirectory);
const runtimeDescription = getStatusDescription(
effectiveRuntimeStatus?.status,
tUnsafe,
effectiveRuntimeStatus && 'error' in effectiveRuntimeStatus ? effectiveRuntimeStatus.error : undefined,
);
const getStatusLabel = (status: string) => {
switch (status) {
case 'connected':
return t('settings.mcp.page.status.label.connected');
case 'failed':
return t('settings.mcp.page.status.label.failed');
case 'needs_auth':
return t('settings.mcp.page.status.label.needsAuth');
case 'needs_client_registration':
return t('settings.mcp.page.status.label.needsRegistration');
default:
return status;
}
};
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
<div className="mb-4">
<div className="min-w-0">
{isNewServer ? (
<h2 className="typography-ui-header font-semibold text-foreground truncate">{t('settings.mcp.page.header.newServer')}</h2>
) : (
<div className="flex items-center gap-2 min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">{selectedMcpName}</h2>
<StatusBadge status={effectiveRuntimeStatus?.status} enabled={enabled} getStatusLabel={getStatusLabel} variant="pill" />
</div>
)}
<div className="flex items-center gap-2 mt-0.5">
<p className="typography-meta text-muted-foreground truncate">
{isNewServer
? t('settings.mcp.page.header.configureNewServer')
: t('settings.mcp.page.header.transport', { type: mcpType === 'local' ? t('settings.mcp.page.transport.local') : t('settings.mcp.page.transport.remote') })}
</p>
{!isNewServer && (
<>
<Button
variant={isConnected ? 'outline' : 'default'}
size="xs"
className="!font-normal"
onClick={handleToggleConnect}
disabled={isConnecting || !enabled}
>
{isConnecting ? t('settings.mcp.page.actions.working') : isConnected ? t('settings.mcp.page.actions.disconnect') : t('settings.mcp.page.actions.connect')}
</Button>
{mcpType === 'remote' && (
<>
<Button
variant={needsAuthorization ? 'default' : 'outline'}
size="xs"
className="!font-normal"
onClick={() => void handleStartAuthorization()}
disabled={isAuthorizing || !enabled}
>
{isAuthorizing
? t('settings.mcp.page.actions.starting')
: needsAuthorization
? t('settings.mcp.page.actions.authorize')
: t('settings.mcp.page.actions.reauthorize')}
</Button>
<Button
variant="ghost"
size="xs"
className="!font-normal gap-1 text-muted-foreground"
onClick={() => void handleClearAuthorization()}
disabled={isClearingAuth || !enabled}
>
{isClearingAuth ? t('settings.mcp.page.actions.clearing') : t('settings.mcp.page.actions.clearAuth')}
</Button>
</>
)}
{isConnected && (
<Button
variant="ghost"
size="xs"
className="!font-normal gap-1 text-muted-foreground"
onClick={() => void handleTestConnection()}
disabled={isTestingConnection || !enabled}
>
{isTestingConnection ? t('settings.mcp.page.actions.testing') : t('settings.mcp.page.actions.test')}
</Button>
)}
</>
)}
</div>
</div>
</div>
{/* Runtime Status - Simplified for connected, expanded for errors */}
{!isNewServer && shouldShowFullStatusCard(effectiveRuntimeStatus?.status, authUrl, needsAuthorization, isAuthPolling) && (
<div className="mb-6 px-2">
<div className={cn('rounded-lg border p-3', statusCardClass(effectiveRuntimeStatus?.status))}>
<div className="space-y-4">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.status.runtimeStatus')}</span>
<StatusBadge status={effectiveRuntimeStatus?.status} enabled={enabled} getStatusLabel={getStatusLabel} />
</div>
<p className="typography-meta text-muted-foreground">{runtimeDescription}</p>
<p className="typography-micro text-muted-foreground/80">
{draftScope === 'project'
? t('settings.mcp.page.status.projectScopedTo', { directory: currentDirectory ?? t('settings.mcp.page.status.activeProject') })
: t('settings.mcp.page.status.userScoped')}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{!isConnected && (
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void handleTestConnection()}
disabled={isTestingConnection || !enabled}
>
{isTestingConnection ? t('settings.mcp.page.actions.testing') : t('settings.mcp.page.actions.testConnection')}
</Button>
)}
</div>
{authUrl && (
<div className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-2">
<div className="space-y-2">
<div className="typography-micro text-muted-foreground">{t('settings.mcp.page.auth.authorizationUrl')}</div>
<div className="break-all typography-micro text-foreground font-mono">{authUrl}</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="xs" className="!font-normal" onClick={() => void openExternalUrl(authUrl)}>
<Icon name="external-link" className="h-3.5 w-3.5" />
{t('settings.mcp.page.actions.openInBrowser')}
</Button>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => void handleCopyAuthUrl()}>
<Icon name="clipboard" className="h-3.5 w-3.5" />
{t('settings.mcp.page.actions.copyLink')}
</Button>
</div>
</div>
</div>
)}
{mcpType === 'remote' && (needsAuthorization || isAuthPolling || authUrl) && (
<div className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-3">
<div className="space-y-2">
<div>
<div className="typography-ui-label text-foreground">{t('settings.mcp.page.auth.manualFallbackTitle')}</div>
<p className="mt-1 typography-micro text-muted-foreground">
{t('settings.mcp.page.auth.manualFallbackDescription')}
</p>
</div>
<Textarea
value={authCallbackInput}
onChange={(event) => setAuthCallbackInput(event.target.value)}
placeholder={t('settings.mcp.page.auth.callbackInputPlaceholder')}
rows={3}
className="font-mono typography-meta resize-y"
data-bwignore="true"
data-1p-ignore="true"
spellCheck={false}
/>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void handleCompleteAuthorization()}
disabled={isCompletingAuth}
>
{isCompletingAuth ? t('settings.mcp.page.actions.completing') : t('settings.mcp.page.actions.completeAuthorization')}
</Button>
</div>
</div>
</div>
)}
</div>
{isAuthPolling && (
<p className="mt-4 typography-micro text-muted-foreground">
{t('settings.mcp.page.auth.waitingForOpenCode')}
</p>
)}
</div>
</div>
)}
{/* Server Identity */}
<div className="mb-6">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.mcp.page.server.title')}</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
{isNewServer && (
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.server.name')}</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))}
placeholder={t('settings.mcp.page.server.namePlaceholder')}
className="h-7 w-48 font-mono px-2"
autoFocus
/>
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as McpScope)}>
<SelectTrigger className="!h-7 !w-7 !min-w-0 !px-0 !py-0 justify-center [&>svg:last-child]:hidden" title={draftScope === 'user' ? t('settings.common.scope.global') : t('settings.common.scope.project')}>
{draftScope === 'user' ? <Icon name="user-3" className="h-3.5 w-3.5" /> : <Icon name="folder" className="h-3.5 w-3.5" />}
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="user">
<div className="flex items-center gap-2">
<Icon name="user-3" className="h-3.5 w-3.5" />
<span>{t('settings.common.scope.global')}</span>
</div>
</SelectItem>
<SelectItem value="project">
<div className="flex items-center gap-2">
<Icon name="folder" className="h-3.5 w-3.5" />
<span>{t('settings.common.scope.project')}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{/* Import JSON - prominent placement for new servers */}
{isNewServer && (
<div className="py-1.5">
<Button
variant="outline"
size="xs"
className="!font-normal gap-1.5"
onClick={handleOpenImportDialog}
type="button"
title={t('settings.mcp.page.server.importJsonTitle')}
>
<Icon name="file-code" className="h-3.5 w-3.5" />
{t('settings.mcp.page.server.importJson')}
</Button>
</div>
)}
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={enabled}
onClick={() => setEnabled(!enabled)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setEnabled(!enabled);
}
}}
>
<Checkbox
checked={enabled}
onChange={setEnabled}
ariaLabel={t('settings.mcp.page.server.enableAria')}
/>
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.server.enable')}</span>
</div>
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.server.transportMode')}</span>
<div className="flex flex-wrap items-center gap-1">
<Button
variant="chip"
size="xs"
aria-pressed={mcpType === 'local'}
onClick={() => setMcpType('local')}
className="!font-normal"
>
{t('settings.mcp.page.transport.local')}
</Button>
<Button
variant="chip"
size="xs"
aria-pressed={mcpType === 'remote'}
onClick={() => setMcpType('remote')}
className="!font-normal"
>
{t('settings.mcp.page.transport.remote')}
</Button>
</div>
</div>
</div>
</section>
</div>
{/* Connection */}
<div className="mb-6">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{mcpType === 'local' ? t('settings.mcp.page.connection.command') : t('settings.mcp.page.connection.serverUrl')}
</h3>
</div>
<section className="px-2 pb-2 pt-0">
{mcpType === 'local' ? (
<CommandTextarea
value={command}
onChange={setCommand}
pasteCommandTitle={t('settings.mcp.page.connection.pasteCommandTitle')}
pasteCommandLabel={t('settings.mcp.page.connection.pasteCommand')}
pasteSuccess={(count) => t('settings.mcp.page.toast.pastedArgumentsCount', { count })}
clipboardReadFailed={t('settings.mcp.page.toast.clipboardReadFailed')}
preview={(count) => t('settings.mcp.page.connection.previewArgs', { count })}
/>
) : (
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t('settings.mcp.page.connection.serverUrlPlaceholder')}
className="font-mono typography-meta"
/>
)}
</section>
</div>
{mcpType === 'remote' && (
<div className="mb-6">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.mcp.page.advanced.title')}</h3>
</div>
<section className="px-2 pb-2 pt-0">
<Collapsible
open={isAdvancedRemoteOptionsOpen}
onOpenChange={setIsAdvancedRemoteOptionsOpen}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
<div className="flex items-center gap-1.5 text-left">
<span className="typography-ui-label font-normal text-foreground">{t('settings.mcp.page.advanced.configure')}</span>
<span className="typography-micro text-muted-foreground">
({oauthEnabled ? t('settings.mcp.page.advanced.autoDetect') : t('settings.mcp.page.advanced.custom')} · {headerEntries.length} {t('settings.mcp.page.advanced.headers')}{timeout ? ` · ${timeout}ms` : ''})
</span>
</div>
{isAdvancedRemoteOptionsOpen ? (
<Icon name="arrow-down-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
) : (
<Icon name="arrow-right-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
)}
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<div className="space-y-4">
<div className="space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.advanced.timeoutMs')}</span>
</div>
<div className="flex items-center gap-2">
<Input
type="number"
min="1"
step="1"
value={timeout}
onChange={(e) => setTimeoutValue(e.target.value)}
placeholder="5000"
className="h-7 w-32 font-mono px-2"
data-bwignore="true"
data-1p-ignore="true"
/>
</div>
</div>
<p className="typography-micro text-muted-foreground sm:pl-64">
{t('settings.mcp.page.advanced.timeoutHint')}
</p>
</div>
<div>
<div className="mb-2 typography-ui-label text-foreground">
{t('settings.mcp.page.advanced.requestHeaders')}
{headerEntries.length > 0 && (
<span className="ml-1.5 typography-micro text-muted-foreground font-normal">({headerEntries.length})</span>
)}
</div>
<EnvEditor
value={headerEntries}
onChange={setHeaderEntries}
keyTransform={(value) => value.trimStart()}
keyPlaceholder={t('settings.mcp.page.advanced.headerNamePlaceholder')}
keyInputClassName="w-36 shrink-0 font-mono typography-meta"
pasteLabel={t('settings.mcp.page.advanced.pasteHeaders')}
pasteTitle={t('settings.mcp.page.advanced.pasteHeadersTitle')}
noPairsFoundError={t('settings.mcp.page.toast.noKeyValuePairsFound')}
importSuccess={(count) => t('settings.mcp.page.toast.importedVariablesCount', { count })}
clipboardReadFailed={t('settings.mcp.page.toast.clipboardReadFailed')}
keyLabel={t('settings.mcp.page.env.key')}
valueLabel={t('settings.mcp.page.env.value')}
valuePlaceholder={t('settings.mcp.page.env.valuePlaceholder')}
hideValueTitle={t('settings.mcp.page.env.hide')}
showValueTitle={t('settings.mcp.page.env.show')}
addVariable={t('settings.mcp.page.env.addVariable')}
plainTextWarning={t('settings.mcp.page.env.plainTextWarning')}
removeVariableAria={t('settings.mcp.page.env.removeVariableAria')}
/>
</div>
<div className="space-y-3">
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={oauthEnabled}
onClick={() => setOauthEnabled(!oauthEnabled)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setOauthEnabled(!oauthEnabled);
}
}}
>
<Checkbox checked={oauthEnabled} onChange={setOauthEnabled} ariaLabel={t('settings.mcp.page.advanced.oauthAutoDetectionAria')} />
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.advanced.oauthAutoDetection')}</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Input
value={oauthClientId}
onChange={(e) => setOauthClientId(e.target.value)}
placeholder={t('settings.mcp.page.advanced.oauthClientIdPlaceholder')}
className="font-mono typography-meta"
disabled={!oauthEnabled}
data-bwignore="true"
data-1p-ignore="true"
/>
<Input
value={oauthClientSecret}
onChange={(e) => setOauthClientSecret(e.target.value)}
placeholder={t('settings.mcp.page.advanced.oauthClientSecretPlaceholder')}
className="font-mono typography-meta"
disabled={!oauthEnabled}
data-bwignore="true"
data-1p-ignore="true"
/>
<Input
value={oauthScope}
onChange={(e) => setOauthScope(e.target.value)}
placeholder={t('settings.mcp.page.advanced.oauthScopesPlaceholder')}
className="font-mono typography-meta"
disabled={!oauthEnabled}
data-bwignore="true"
data-1p-ignore="true"
/>
<Input
value={oauthRedirectUri}
onChange={(e) => setOauthRedirectUri(e.target.value)}
placeholder={t('settings.mcp.page.advanced.oauthRedirectUriPlaceholder')}
className="font-mono typography-meta"
disabled={!oauthEnabled}
data-bwignore="true"
data-1p-ignore="true"
/>
</div>
<p className="typography-micro text-muted-foreground">
{t('settings.mcp.page.advanced.oauthHint')}
</p>
{suggestedRedirectUri && (
<p className="typography-micro text-muted-foreground">
{t('settings.mcp.page.advanced.oauthCallbackHint')}
<span className="mt-1 block break-all font-mono text-foreground/80">{suggestedRedirectUri}</span>
</p>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
</section>
</div>
)}
{/* Environment Variables */}
<div className="mb-2">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.mcp.page.env.title')}
{envEntries.length > 0 && (
<span className="ml-1.5 typography-micro text-muted-foreground font-normal">
({envEntries.length})
</span>
)}
</h3>
</div>
<section className="px-2 pb-2 pt-0">
{envEntries.length === 0 ? (
<Button
variant="outline"
size="xs"
className="!font-normal gap-1.5"
onClick={() => setEnvEntries([{ key: '', value: '' }])}
>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.mcp.page.env.addEnvironmentVariable')}
</Button>
) : (
<EnvEditor
value={envEntries}
onChange={setEnvEntries}
keyPlaceholder={t('settings.mcp.page.env.keyPlaceholder')}
pasteLabel={t('settings.mcp.page.env.pasteEnv')}
pasteTitle={t('settings.mcp.page.env.pasteEnvTitle')}
noPairsFoundError={t('settings.mcp.page.toast.noKeyValuePairsFound')}
importSuccess={(count) => t('settings.mcp.page.toast.importedVariablesCount', { count })}
clipboardReadFailed={t('settings.mcp.page.toast.clipboardReadFailed')}
keyLabel={t('settings.mcp.page.env.key')}
valueLabel={t('settings.mcp.page.env.value')}
valuePlaceholder={t('settings.mcp.page.env.valuePlaceholder')}
hideValueTitle={t('settings.mcp.page.env.hide')}
showValueTitle={t('settings.mcp.page.env.show')}
addVariable={t('settings.mcp.page.env.addVariable')}
plainTextWarning={t('settings.mcp.page.env.plainTextWarning')}
removeVariableAria={t('settings.mcp.page.env.removeVariableAria')}
/>
)}
</section>
</div>
{/* Actions */}
<div className="flex items-center gap-2 px-2 py-1">
<Button
onClick={handleSave}
disabled={isSaving || (!isDirty && !isNewServer)}
size="xs"
className="!font-normal"
>
{isSaving ? t('settings.common.actions.saving') : isNewServer ? t('settings.common.actions.create') : t('settings.common.actions.saveChanges')}
</Button>
{!isNewServer && (
<Button
variant="destructive"
size="xs"
className="!font-normal"
onClick={() => setShowDeleteConfirm(true)}
>
{t('settings.common.actions.delete')}
</Button>
)}
</div>
</div>
{/* Import JSON dialog */}
<Dialog
open={showImportDialog}
onOpenChange={(open) => {
if (!open) {
setShowImportDialog(false);
setImportJsonText('');
setImportError(null);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('settings.mcp.page.importDialog.title')}</DialogTitle>
<DialogDescription>
{t('settings.mcp.page.importDialog.description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Textarea
value={importJsonText}
onChange={(e) => {
setImportJsonText(e.target.value);
setImportError(null);
}}
placeholder={'{\n "mcpServers": {\n "postgres": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-postgres"]\n }\n }\n}'}
rows={8}
className="font-mono typography-meta resize-y"
spellCheck={false}
data-bwignore="true"
data-1p-ignore="true"
/>
{importError && (
<p className="typography-micro text-[var(--status-error)]">{importError}</p>
)}
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="xs"
className="!font-normal gap-1"
onClick={handlePasteImportClipboard}
type="button"
>
<Icon name="clipboard" className="h-3.5 w-3.5" />
{t('settings.mcp.page.importDialog.pasteFromClipboard')}
</Button>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowImportDialog(false);
setImportJsonText('');
setImportError(null);
}}
className="text-foreground"
>
{t('settings.common.actions.cancel')}
</Button>
<Button
onClick={handleImportJson}
disabled={!importJsonText.trim()}
size="sm"
>
{t('settings.common.actions.import')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirm */}
<Dialog
open={showDeleteConfirm}
onOpenChange={(open) => { if (!open && !isDeleting) setShowDeleteConfirm(false); }}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('settings.mcp.page.deleteDialog.title', { name: selectedMcpName ?? '' })}</DialogTitle>
<DialogDescription>
{t('settings.mcp.page.deleteDialog.descriptionPrefix')}{' '}
<code className="text-foreground">opencode.json</code>.
{' '}
{t('settings.mcp.page.deleteDialog.descriptionSuffix')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
disabled={isDeleting}
className="text-foreground"
>
{t('settings.common.actions.cancel')}
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? t('settings.mcp.page.actions.deleting') : t('settings.common.actions.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ScrollableOverlay>
);
};