2026-02-23 04:22:33 +07:00
|
|
|
import React from 'react';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
|
|
|
import { toast } from '@/components/ui';
|
2026-04-21 11:46:51 -06:00
|
|
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
|
|
|
|
import { openExternalUrl } from '@/lib/url';
|
|
|
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
2026-02-23 04:22:33 +07:00
|
|
|
import {
|
2026-08-22 19:50:16 +03:00
|
|
|
selectMcpServersForDirectory,
|
2026-02-23 04:22:33 +07:00
|
|
|
useMcpConfigStore,
|
|
|
|
|
envRecordToArray,
|
|
|
|
|
type McpDraft,
|
|
|
|
|
type McpScope,
|
|
|
|
|
} from '@/stores/useMcpConfigStore';
|
2026-04-26 16:24:07 +03:00
|
|
|
import { useShallow } from 'zustand/react/shallow';
|
2026-04-21 11:46:51 -06:00
|
|
|
import {
|
|
|
|
|
parseImportedMcpSnippet,
|
|
|
|
|
applyImportedMcpToDraft,
|
|
|
|
|
} from './mcpImport';
|
2026-02-23 04:22:33 +07:00
|
|
|
import { useMcpStore } from '@/stores/useMcpStore';
|
2026-08-10 20:23:45 +03:00
|
|
|
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
|
2026-08-22 19:50:16 +03:00
|
|
|
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
2026-02-23 04:22:33 +07:00
|
|
|
import { cn } from '@/lib/utils';
|
2026-07-18 00:11:05 +03:00
|
|
|
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
|
|
|
|
import {
|
|
|
|
|
SettingsSection,
|
|
|
|
|
SettingsFieldRow,
|
|
|
|
|
SettingsCheckboxRow,
|
|
|
|
|
SettingsGroupTitle,
|
2026-08-09 19:30:25 +03:00
|
|
|
SettingsStackedField,
|
2026-07-18 00:11:05 +03:00
|
|
|
SETTINGS_SELECT_SIZE,
|
|
|
|
|
SETTINGS_FIELD_LABEL_CLASS,
|
|
|
|
|
} from '@/components/sections/shared/SettingsSection';
|
|
|
|
|
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
2026-08-09 19:30:25 +03:00
|
|
|
import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
|
|
|
|
|
import { buildMcpAuthorizationRedirectUri, startMcpAuthorization } from '@/components/sections/mcp/startMcpAuthorization';
|
2026-04-21 11:46:51 -06:00
|
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
2026-02-23 04:22:33 +07:00
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from '@/components/ui/dialog';
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
} from '@/components/ui/select';
|
2026-05-13 13:26:15 +03:00
|
|
|
import { Icon } from "@/components/icon/Icon";
|
2026-08-09 19:30:25 +03:00
|
|
|
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
|
2026-04-26 14:03:39 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
2026-02-23 04:22:33 +07:00
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
// CommandTextarea — one arg per line, paste-friendly
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
interface CommandTextareaProps {
|
|
|
|
|
value: string[];
|
|
|
|
|
onChange: (v: string[]) => void;
|
2026-04-26 14:03:39 +03:00
|
|
|
preview: (count: number) => string;
|
2026-08-09 19:30:25 +03:00
|
|
|
/** Called when the text is plainly a link rather than a command. */
|
|
|
|
|
onDetectUrl?: (url: string) => void;
|
2026-02-23 04:22:33 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 14:03:39 +03:00
|
|
|
const CommandTextarea: React.FC<CommandTextareaProps> = ({
|
|
|
|
|
value,
|
|
|
|
|
onChange,
|
|
|
|
|
preview,
|
2026-08-09 19:30:25 +03:00
|
|
|
onDetectUrl,
|
2026-04-26 14:03:39 +03:00
|
|
|
}) => {
|
2026-02-23 04:22:33 +07:00
|
|
|
// 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);
|
2026-08-09 19:30:25 +03:00
|
|
|
// A single line that is nothing but a URL is a hosted server, not a
|
|
|
|
|
// command to run — the page switches kind rather than making the user say.
|
|
|
|
|
if (onDetectUrl && lines.length === 1 && /^https?:\/\/\S+$/i.test(lines[0].trim())) {
|
|
|
|
|
onDetectUrl(lines[0].trim());
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-23 04:22:33 +07:00
|
|
|
onChange(lines);
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-09 19:30:25 +03:00
|
|
|
/**
|
|
|
|
|
* Pasting a whole command line splits it into arguments here, in the field
|
|
|
|
|
* the user pasted into. The old approach — a button that read the clipboard
|
|
|
|
|
* itself — fails outright wherever the runtime denies clipboard reads.
|
|
|
|
|
*/
|
|
|
|
|
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
|
|
|
const raw = event.clipboardData.getData('text');
|
|
|
|
|
const trimmed = raw.trim();
|
|
|
|
|
// Only take over a paste that replaces the whole field with one command
|
|
|
|
|
// line; anything else is ordinary editing and belongs to the browser.
|
|
|
|
|
if (!trimmed || trimmed.includes('\n') || !/\s/.test(trimmed)) return;
|
|
|
|
|
const target = event.currentTarget;
|
|
|
|
|
if (target.selectionStart !== 0 || target.selectionEnd !== target.value.length) return;
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const lines = parseShellCommand(trimmed);
|
|
|
|
|
setText(lines.join('\n'));
|
|
|
|
|
onChange(lines);
|
2026-02-23 04:22:33 +07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-21 11:46:51 -06:00
|
|
|
<div className="space-y-2" data-bwignore="true" data-1p-ignore="true" data-lpignore="true">
|
2026-02-23 04:22:33 +07:00
|
|
|
<Textarea
|
2026-08-09 19:30:25 +03:00
|
|
|
onPaste={handlePaste}
|
2026-02-23 04:22:33 +07:00
|
|
|
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)}
|
2026-08-09 19:30:25 +03:00
|
|
|
className="font-mono typography-meta min-h-[80px]"
|
2026-02-23 04:22:33 +07:00
|
|
|
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">
|
2026-04-26 14:03:39 +03:00
|
|
|
{preview(value.length)}
|
2026-02-23 04:22:33 +07:00
|
|
|
</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;
|
2026-04-21 11:46:51 -06:00
|
|
|
keyTransform?: (value: string) => string;
|
|
|
|
|
keyPlaceholder?: string;
|
|
|
|
|
keyInputClassName?: string;
|
|
|
|
|
pasteLabel?: string;
|
|
|
|
|
pasteTitle?: string;
|
2026-04-26 14:03:39 +03:00
|
|
|
noPairsFoundError: string;
|
|
|
|
|
importSuccess: (count: number) => string;
|
|
|
|
|
clipboardReadFailed: string;
|
|
|
|
|
keyLabel: string;
|
|
|
|
|
valueLabel: string;
|
|
|
|
|
valuePlaceholder: string;
|
|
|
|
|
hideValueTitle: string;
|
|
|
|
|
showValueTitle: string;
|
|
|
|
|
addVariable: string;
|
|
|
|
|
plainTextWarning: string;
|
|
|
|
|
removeVariableAria: string;
|
2026-02-23 04:22:33 +07:00
|
|
|
}
|
|
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
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',
|
2026-04-26 14:03:39 +03:00
|
|
|
noPairsFoundError,
|
|
|
|
|
importSuccess,
|
|
|
|
|
clipboardReadFailed,
|
|
|
|
|
keyLabel,
|
|
|
|
|
valueLabel,
|
|
|
|
|
valuePlaceholder,
|
|
|
|
|
hideValueTitle,
|
|
|
|
|
showValueTitle,
|
|
|
|
|
addVariable,
|
|
|
|
|
plainTextWarning,
|
|
|
|
|
removeVariableAria,
|
2026-04-21 11:46:51 -06:00
|
|
|
}) => {
|
2026-02-23 04:22:33 +07:00
|
|
|
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) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(noPairsFoundError);
|
2026-02-23 04:22:33 +07:00
|
|
|
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);
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(importSuccess(parsed.length));
|
2026-02-23 04:22:33 +07:00
|
|
|
} catch {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(clipboardReadFailed);
|
2026-02-23 04:22:33 +07:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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">
|
2026-04-26 14:03:39 +03:00
|
|
|
<span className="typography-micro text-muted-foreground w-32 shrink-0">{keyLabel}</span>
|
|
|
|
|
<span className="typography-micro text-muted-foreground">{valueLabel}</span>
|
2026-02-23 04:22:33 +07:00
|
|
|
</div>
|
2026-03-20 01:01:03 +02:00
|
|
|
<Button
|
2026-02-23 04:22:33 +07:00
|
|
|
variant="ghost"
|
2026-02-24 03:28:30 +02:00
|
|
|
size="xs"
|
|
|
|
|
className="!font-normal gap-1 text-muted-foreground"
|
2026-02-23 04:22:33 +07:00
|
|
|
onClick={handlePasteDotEnv}
|
|
|
|
|
type="button"
|
2026-04-21 11:46:51 -06:00
|
|
|
title={pasteTitle}
|
2026-02-23 04:22:33 +07:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="clipboard" className="h-3 w-3" />
|
2026-04-21 11:46:51 -06:00
|
|
|
{pasteLabel}
|
2026-03-20 01:01:03 +02:00
|
|
|
</Button>
|
2026-02-23 04:22:33 +07:00
|
|
|
</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}
|
2026-04-21 11:46:51 -06:00
|
|
|
onChange={(e) => updateRow(idx, 'key', keyTransform(e.target.value))}
|
|
|
|
|
placeholder={keyPlaceholder}
|
|
|
|
|
className={keyInputClassName}
|
|
|
|
|
data-bwignore="true"
|
|
|
|
|
data-1p-ignore="true"
|
|
|
|
|
data-lpignore="true"
|
2026-02-23 04:22:33 +07:00
|
|
|
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)}
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={valuePlaceholder}
|
2026-02-23 04:22:33 +07:00
|
|
|
className="font-mono typography-meta pr-8 w-full"
|
2026-04-21 11:46:51 -06:00
|
|
|
autoComplete="new-password"
|
|
|
|
|
data-bwignore="true"
|
|
|
|
|
data-1p-ignore="true"
|
|
|
|
|
data-lpignore="true"
|
2026-02-23 04:22:33 +07:00
|
|
|
spellCheck={false}
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => toggleReveal(idx)}
|
|
|
|
|
className="absolute right-2 text-muted-foreground/60 hover:text-muted-foreground"
|
2026-04-26 14:03:39 +03:00
|
|
|
title={revealedKeys.has(idx) ? hideValueTitle : showValueTitle}
|
2026-02-23 04:22:33 +07:00
|
|
|
>
|
|
|
|
|
{revealedKeys.has(idx)
|
2026-05-13 13:26:15 +03:00
|
|
|
? <Icon name="eye-off" className="h-3.5 w-3.5" />
|
|
|
|
|
: <Icon name="eye" className="h-3.5 w-3.5" />}
|
2026-02-23 04:22:33 +07:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{/* Remove */}
|
2026-03-20 01:01:03 +02:00
|
|
|
<Button size="sm"
|
2026-02-23 04:22:33 +07:00
|
|
|
variant="ghost"
|
2026-02-24 03:28:30 +02:00
|
|
|
className="h-7 w-7 px-0 shrink-0 text-muted-foreground hover:text-[var(--status-error)]"
|
2026-02-23 04:22:33 +07:00
|
|
|
onClick={() => removeRow(idx)}
|
2026-04-26 14:03:39 +03:00
|
|
|
aria-label={removeVariableAria}
|
2026-02-23 04:22:33 +07:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
2026-03-20 01:01:03 +02:00
|
|
|
</Button>
|
2026-02-23 04:22:33 +07:00
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
<Button
|
2026-02-23 04:22:33 +07:00
|
|
|
variant="outline"
|
2026-02-24 03:28:30 +02:00
|
|
|
size="xs"
|
|
|
|
|
className="!font-normal gap-1.5"
|
2026-02-23 04:22:33 +07:00
|
|
|
onClick={addRow}
|
|
|
|
|
type="button"
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="add" className="h-3.5 w-3.5" />
|
2026-04-26 14:03:39 +03:00
|
|
|
{addVariable}
|
2026-03-20 01:01:03 +02:00
|
|
|
</Button>
|
2026-02-23 04:22:33 +07:00
|
|
|
|
|
|
|
|
{hasSensitiveValues && (
|
|
|
|
|
<p className="typography-micro text-muted-foreground/60">
|
2026-04-26 14:03:39 +03:00
|
|
|
{plainTextWarning}
|
2026-02-23 04:22:33 +07:00
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
// Status badge
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
2026-04-26 14:03:39 +03:00
|
|
|
const StatusBadge: React.FC<{
|
|
|
|
|
status: string | undefined;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
getStatusLabel: (status: string) => string;
|
|
|
|
|
variant?: 'compact' | 'pill'
|
|
|
|
|
}> = ({ status, enabled, getStatusLabel, variant = 'compact' }) => {
|
2026-02-24 03:28:30 +02:00
|
|
|
if (!enabled) return null;
|
2026-02-23 04:22:33 +07:00
|
|
|
if (!status) return null;
|
|
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
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' },
|
2026-08-10 20:23:45 +03:00
|
|
|
awaiting_restart: { text: 'text-[var(--status-warning)]', bg: 'bg-[var(--status-warning)]/10' },
|
2026-02-23 04:22:33 +07:00
|
|
|
};
|
|
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
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)}>
|
2026-04-26 14:03:39 +03:00
|
|
|
● {getStatusLabel(status)}
|
2026-04-21 11:46:51 -06:00
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 04:22:33 +07:00
|
|
|
return (
|
2026-04-21 11:46:51 -06:00
|
|
|
<span className={cn('typography-micro font-medium', colors.text)}>
|
2026-04-26 14:03:39 +03:00
|
|
|
● {getStatusLabel(status)}
|
2026-02-23 04:22:33 +07:00
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 14:03:39 +03:00
|
|
|
const getStatusDescription = (
|
|
|
|
|
status: string | undefined,
|
|
|
|
|
t: (key: string, params?: Record<string, unknown>) => string,
|
|
|
|
|
error?: string
|
|
|
|
|
): string => {
|
2026-04-21 11:46:51 -06:00
|
|
|
switch (status) {
|
|
|
|
|
case 'connected':
|
2026-04-26 14:03:39 +03:00
|
|
|
return t('settings.mcp.page.status.description.connected');
|
2026-04-21 11:46:51 -06:00
|
|
|
case 'failed':
|
2026-04-26 14:03:39 +03:00
|
|
|
return error?.trim() || t('settings.mcp.page.status.description.failedDefault');
|
2026-04-21 11:46:51 -06:00
|
|
|
case 'needs_auth':
|
2026-04-26 14:03:39 +03:00
|
|
|
return t('settings.mcp.page.status.description.needsAuth');
|
2026-04-21 11:46:51 -06:00
|
|
|
case 'needs_client_registration':
|
2026-04-26 14:03:39 +03:00
|
|
|
return error?.trim() || t('settings.mcp.page.status.description.needsClientRegistrationDefault');
|
2026-04-21 11:46:51 -06:00
|
|
|
case 'disabled':
|
2026-04-26 14:03:39 +03:00
|
|
|
return t('settings.mcp.page.status.description.disabled');
|
2026-04-21 11:46:51 -06:00
|
|
|
default:
|
2026-04-26 14:03:39 +03:00
|
|
|
return t('settings.mcp.page.status.description.default');
|
2026-04-21 11:46:51 -06:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 getPendingMcpAuthContext = async (stateKey: string): Promise<{ name: string; directory: string | null } | null> => {
|
2026-06-02 00:43:05 +03:00
|
|
|
const response = await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey)}`);
|
2026-04-21 11:46:51 -06:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey.trim())}`, { method: 'DELETE' }).catch(() => undefined);
|
2026-04-21 11:46:51 -06:00
|
|
|
};
|
|
|
|
|
|
2026-04-26 14:03:39 +03:00
|
|
|
const normalizeMcpAuthErrorMessage = (
|
|
|
|
|
error: unknown,
|
|
|
|
|
fallback: string,
|
|
|
|
|
t: (key: string, params?: Record<string, unknown>) => string
|
|
|
|
|
): string => {
|
2026-04-21 11:46:51 -06:00
|
|
|
const message = error instanceof Error ? error.message : fallback;
|
|
|
|
|
if (/oauth state required/i.test(message)) {
|
2026-04-26 14:03:39 +03:00
|
|
|
return t('settings.mcp.page.toast.authSessionExpired');
|
2026-04-21 11:46:51 -06:00
|
|
|
}
|
|
|
|
|
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}`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-23 04:22:33 +07:00
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
// McpPage
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
export const McpPage: React.FC = () => {
|
2026-04-26 14:03:39 +03:00
|
|
|
const { t } = useI18n();
|
|
|
|
|
const tUnsafe = React.useCallback(
|
|
|
|
|
(key: string, params?: Record<string, unknown>) => t(key as never, params as never),
|
|
|
|
|
[t]
|
|
|
|
|
);
|
2026-02-23 04:22:33 +07:00
|
|
|
const {
|
|
|
|
|
selectedMcpName,
|
|
|
|
|
mcpDraft,
|
|
|
|
|
setMcpDraft,
|
|
|
|
|
setSelectedMcp,
|
|
|
|
|
getMcpByName,
|
|
|
|
|
createMcp,
|
|
|
|
|
updateMcp,
|
|
|
|
|
deleteMcp,
|
2026-04-26 16:24:07 +03:00
|
|
|
} = useMcpConfigStore(useShallow((s) => ({
|
|
|
|
|
selectedMcpName: s.selectedMcpName,
|
|
|
|
|
mcpDraft: s.mcpDraft,
|
|
|
|
|
setMcpDraft: s.setMcpDraft,
|
|
|
|
|
setSelectedMcp: s.setSelectedMcp,
|
|
|
|
|
getMcpByName: s.getMcpByName,
|
|
|
|
|
createMcp: s.createMcp,
|
|
|
|
|
updateMcp: s.updateMcp,
|
|
|
|
|
deleteMcp: s.deleteMcp,
|
|
|
|
|
})));
|
2026-02-23 04:22:33 +07:00
|
|
|
|
2026-08-22 19:50:16 +03:00
|
|
|
// Settings browses whichever project its own selector points at; the app
|
|
|
|
|
// stays where it is.
|
|
|
|
|
const currentDirectory = useSettingsDirectory();
|
2026-04-21 11:46:51 -06:00
|
|
|
const isVSCodeAuthRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
2026-08-22 19:50:16 +03:00
|
|
|
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory));
|
|
|
|
|
const mcpDiagnostics = useMcpStore((state) => state.getDiagnosticForDirectory(currentDirectory));
|
2026-02-23 04:22:33 +07:00
|
|
|
const refreshStatus = useMcpStore((state) => state.refresh);
|
|
|
|
|
const connectMcp = useMcpStore((state) => state.connect);
|
|
|
|
|
const disconnectMcp = useMcpStore((state) => state.disconnect);
|
2026-04-21 11:46:51 -06:00
|
|
|
const completeAuthMcp = useMcpStore((state) => state.completeAuth);
|
|
|
|
|
const clearAuthMcp = useMcpStore((state) => state.clearAuth);
|
|
|
|
|
const testConnectionMcp = useMcpStore((state) => state.testConnection);
|
2026-08-10 20:23:45 +03:00
|
|
|
const pendingRestartChanges = usePendingOpenCodeRestartStore((state) => state.changes);
|
2026-02-23 04:22:33 +07:00
|
|
|
|
2026-08-22 19:50:16 +03:00
|
|
|
const mcpServers = useMcpConfigStore((state) => selectMcpServersForDirectory(state, currentDirectory));
|
|
|
|
|
const selectedServer = selectedMcpName ? getMcpByName(selectedMcpName, currentDirectory) : null;
|
2026-02-23 04:22:33 +07:00
|
|
|
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 }>>([]);
|
2026-04-21 11:46:51 -06:00
|
|
|
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('');
|
2026-02-23 04:22:33 +07:00
|
|
|
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);
|
|
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
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);
|
|
|
|
|
|
2026-02-23 04:22:33 +07:00
|
|
|
const initialRef = React.useRef<{
|
|
|
|
|
mcpType: 'local' | 'remote'; command: string[]; url: string;
|
2026-04-21 11:46:51 -06:00
|
|
|
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;
|
2026-02-23 04:22:33 +07:00
|
|
|
} | null>(null);
|
|
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
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 {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('settings.mcp.page.toast.clipboardReadFailed'));
|
2026-04-21 11:46:51 -06:00
|
|
|
}
|
2026-04-26 14:03:39 +03:00
|
|
|
}, [t]);
|
2026-04-21 11:46:51 -06:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(t('settings.mcp.page.toast.configImported'));
|
2026-04-21 11:46:51 -06:00
|
|
|
}, [
|
|
|
|
|
importJsonText,
|
|
|
|
|
draftName,
|
|
|
|
|
draftScope,
|
|
|
|
|
mcpType,
|
|
|
|
|
command,
|
|
|
|
|
url,
|
|
|
|
|
envEntries,
|
|
|
|
|
headerEntries,
|
|
|
|
|
oauthEnabled,
|
|
|
|
|
oauthClientId,
|
|
|
|
|
oauthClientSecret,
|
|
|
|
|
oauthScope,
|
|
|
|
|
oauthRedirectUri,
|
|
|
|
|
timeout,
|
|
|
|
|
enabled,
|
|
|
|
|
isNewServer,
|
2026-04-26 14:03:39 +03:00
|
|
|
t,
|
2026-04-21 11:46:51 -06:00
|
|
|
]);
|
|
|
|
|
|
2026-02-23 04:22:33 +07:00
|
|
|
// 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);
|
2026-04-21 11:46:51 -06:00
|
|
|
setHeaderEntries(mcpDraft.headers);
|
|
|
|
|
setOauthEnabled(mcpDraft.oauthEnabled);
|
|
|
|
|
setOauthClientId(mcpDraft.oauthClientId);
|
|
|
|
|
setOauthClientSecret(mcpDraft.oauthClientSecret);
|
|
|
|
|
setOauthScope(mcpDraft.oauthScope);
|
|
|
|
|
setOauthRedirectUri(mcpDraft.oauthRedirectUri);
|
|
|
|
|
setTimeoutValue(mcpDraft.timeout);
|
2026-02-23 04:22:33 +07:00
|
|
|
setEnabled(mcpDraft.enabled);
|
2026-04-21 11:46:51 -06:00
|
|
|
setIsAdvancedRemoteOptionsOpen(false);
|
2026-02-23 04:22:33 +07:00
|
|
|
initialRef.current = {
|
|
|
|
|
mcpType: mcpDraft.type, command: mcpDraft.command,
|
2026-04-21 11:46:51 -06:00
|
|
|
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,
|
2026-02-23 04:22:33 +07:00
|
|
|
};
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (selectedServer) {
|
|
|
|
|
setDraftScope(selectedServer.scope === 'project' ? 'project' : 'user');
|
|
|
|
|
const envArr = envRecordToArray(selectedServer.environment);
|
2026-04-21 11:46:51 -06:00
|
|
|
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;
|
2026-04-26 14:03:39 +03:00
|
|
|
const serverType = selectedServer.type;
|
|
|
|
|
const cmd = serverType === 'local' ? ((selectedServer as { command?: string[] }).command ?? []) : [];
|
|
|
|
|
const u = serverType === 'remote' ? ((selectedServer as { url?: string }).url ?? '') : '';
|
2026-04-21 11:46:51 -06:00
|
|
|
const nextTimeout = typeof remoteServer?.timeout === 'number' && Number.isFinite(remoteServer.timeout)
|
|
|
|
|
? String(remoteServer.timeout)
|
|
|
|
|
: '';
|
2026-04-26 14:03:39 +03:00
|
|
|
setMcpType(serverType);
|
2026-04-21 11:46:51 -06:00
|
|
|
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 = {
|
2026-04-26 14:03:39 +03:00
|
|
|
mcpType: serverType,
|
2026-04-21 11:46:51 -06:00
|
|
|
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,
|
|
|
|
|
};
|
2026-02-23 04:22:33 +07:00
|
|
|
}
|
|
|
|
|
}, [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 ||
|
2026-04-21 11:46:51 -06:00
|
|
|
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
|
2026-02-23 04:22:33 +07:00
|
|
|
);
|
2026-04-21 11:46:51 -06:00
|
|
|
}, [mcpType, command, url, envEntries, headerEntries, oauthEnabled, oauthClientId, oauthClientSecret, oauthScope, oauthRedirectUri, timeout, enabled]);
|
2026-02-23 04:22:33 +07:00
|
|
|
|
2026-08-09 19:30:25 +03:00
|
|
|
// What the user has is either a command they were given or a link. Which of
|
|
|
|
|
// the two decides the transport, so the page reads it off the text instead of
|
|
|
|
|
// asking — and lets them correct it when the text alone cannot say.
|
|
|
|
|
const connectionKindTabs = React.useMemo<SortableTabsStripItem[]>(() => [
|
|
|
|
|
{
|
|
|
|
|
id: 'local',
|
|
|
|
|
label: t('settings.mcp.page.connection.kindCommand'),
|
|
|
|
|
icon: <Icon name="terminal" className="h-3.5 w-3.5" />,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'remote',
|
|
|
|
|
label: t('settings.mcp.page.connection.kindLink'),
|
|
|
|
|
icon: <Icon name="global" className="h-3.5 w-3.5" />,
|
|
|
|
|
},
|
|
|
|
|
], [t]);
|
|
|
|
|
|
|
|
|
|
const handleDetectedUrl = React.useCallback((candidate: string) => {
|
|
|
|
|
setMcpType('remote');
|
|
|
|
|
setUrl(candidate);
|
|
|
|
|
setCommand([]);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleUrlChange = React.useCallback((next: string) => {
|
|
|
|
|
setUrl(next);
|
|
|
|
|
// A command pasted into the link field is still a command.
|
|
|
|
|
const trimmed = next.trim();
|
|
|
|
|
if (trimmed && !/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && /\s/.test(trimmed)) {
|
|
|
|
|
setMcpType('local');
|
|
|
|
|
setCommand(parseShellCommand(trimmed));
|
|
|
|
|
setUrl('');
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-23 04:22:33 +07:00
|
|
|
const handleSave = async () => {
|
|
|
|
|
const name = isNewServer ? draftName.trim() : selectedMcpName ?? '';
|
2026-04-26 14:03:39 +03:00
|
|
|
if (!name) { toast.error(t('settings.mcp.page.toast.nameRequired')); return; }
|
2026-02-23 04:22:33 +07:00
|
|
|
if (isNewServer && mcpServers.some((s) => s.name === name)) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('settings.mcp.page.toast.serverNameExists')); return;
|
2026-02-23 04:22:33 +07:00
|
|
|
}
|
|
|
|
|
if (mcpType === 'local' && command.filter(Boolean).length === 0) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('settings.mcp.page.toast.localCommandRequired')); return;
|
2026-02-23 04:22:33 +07:00
|
|
|
}
|
|
|
|
|
if (mcpType === 'remote' && !url.trim()) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('settings.mcp.page.toast.remoteUrlRequired')); return;
|
2026-02-23 04:22:33 +07:00
|
|
|
}
|
|
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
const draft: McpDraft = {
|
|
|
|
|
name,
|
|
|
|
|
scope: draftScope,
|
|
|
|
|
type: mcpType,
|
|
|
|
|
command,
|
|
|
|
|
url,
|
|
|
|
|
environment: envEntries,
|
|
|
|
|
headers: headerEntries,
|
|
|
|
|
oauthEnabled,
|
|
|
|
|
oauthClientId,
|
|
|
|
|
oauthClientSecret,
|
|
|
|
|
oauthScope,
|
|
|
|
|
oauthRedirectUri,
|
|
|
|
|
timeout,
|
|
|
|
|
enabled,
|
|
|
|
|
};
|
2026-02-23 04:22:33 +07:00
|
|
|
setIsSaving(true);
|
|
|
|
|
try {
|
2026-08-22 19:50:16 +03:00
|
|
|
const result = isNewServer ? await createMcp(draft, currentDirectory) : await updateMcp(name, draft, currentDirectory);
|
2026-04-21 11:46:51 -06:00
|
|
|
if (result.ok) {
|
|
|
|
|
await clearPendingMcpAuthContext(authStateKey);
|
|
|
|
|
resetTransientAuthState();
|
2026-02-23 04:22:33 +07:00
|
|
|
if (isNewServer) { setMcpDraft(null); setSelectedMcp(name); }
|
2026-04-21 11:46:51 -06:00
|
|
|
await refreshStatus({ directory: currentDirectory, silent: true });
|
|
|
|
|
if (result.reloadFailed) {
|
2026-04-26 14:03:39 +03:00
|
|
|
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'),
|
2026-04-21 11:46:51 -06:00
|
|
|
});
|
2026-08-03 06:56:38 +00:00
|
|
|
} else if (result.restartDeferred) {
|
|
|
|
|
toast.success(t('settings.view.pendingRestart.saved'));
|
2026-04-21 11:46:51 -06:00
|
|
|
} else {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(result.message || (isNewServer
|
|
|
|
|
? t('settings.mcp.page.toast.serverCreatedReloading')
|
|
|
|
|
: t('settings.mcp.page.toast.savedReloading')));
|
2026-04-21 11:46:51 -06:00
|
|
|
}
|
2026-02-23 04:22:33 +07:00
|
|
|
} else {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('settings.mcp.page.toast.saveFailed'));
|
2026-02-23 04:22:33 +07:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(err instanceof Error ? err.message : t('settings.mcp.page.toast.unexpectedError'));
|
2026-02-23 04:22:33 +07:00
|
|
|
} finally {
|
|
|
|
|
setIsSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDelete = async () => {
|
|
|
|
|
if (!selectedMcpName) return;
|
|
|
|
|
setIsDeleting(true);
|
2026-08-22 19:50:16 +03:00
|
|
|
const result = await deleteMcp(selectedMcpName, currentDirectory);
|
2026-04-21 11:46:51 -06:00
|
|
|
if (result.ok) {
|
|
|
|
|
await clearPendingMcpAuthContext(authStateKey);
|
|
|
|
|
resetTransientAuthState();
|
|
|
|
|
if (result.reloadFailed) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.warning(result.message || t('settings.mcp.page.toast.serverDeletedReloadFailed', { name: selectedMcpName }), {
|
|
|
|
|
description: result.warning || t('settings.mcp.page.toast.refreshListIfStale'),
|
2026-04-21 11:46:51 -06:00
|
|
|
});
|
|
|
|
|
} else {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(result.message || t('settings.mcp.page.toast.serverDeleted', { name: selectedMcpName }));
|
2026-04-21 11:46:51 -06:00
|
|
|
}
|
|
|
|
|
setShowDeleteConfirm(false);
|
2026-04-26 14:03:39 +03:00
|
|
|
} else toast.error(t('settings.mcp.page.toast.deleteFailed'));
|
2026-02-23 04:22:33 +07:00
|
|
|
setIsDeleting(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleToggleConnect = async () => {
|
|
|
|
|
if (!selectedMcpName) return;
|
|
|
|
|
setIsConnecting(true);
|
|
|
|
|
try {
|
|
|
|
|
const isConnected = mcpStatus[selectedMcpName]?.status === 'connected';
|
|
|
|
|
if (isConnected) {
|
|
|
|
|
await disconnectMcp(selectedMcpName, currentDirectory);
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(t('settings.mcp.page.toast.disconnected'));
|
2026-02-23 04:22:33 +07:00
|
|
|
} else {
|
|
|
|
|
await connectMcp(selectedMcpName, currentDirectory);
|
2026-04-21 11:46:51 -06:00
|
|
|
await refreshStatus({ directory: currentDirectory, silent: true });
|
2026-08-22 19:50:16 +03:00
|
|
|
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName];
|
2026-04-21 11:46:51 -06:00
|
|
|
if (nextStatus?.status === 'connected') {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(t('settings.mcp.page.toast.connected'));
|
2026-04-21 11:46:51 -06:00
|
|
|
} else if (nextStatus?.status === 'needs_auth') {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.message(t('settings.mcp.page.toast.connectionNeedsAuthorization'));
|
2026-04-21 11:46:51 -06:00
|
|
|
} else if (nextStatus?.status === 'needs_client_registration') {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.message(t('settings.mcp.page.toast.connectionNeedsClientRegistration'));
|
2026-04-21 11:46:51 -06:00
|
|
|
} else if (nextStatus?.status === 'failed') {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(nextStatus.error || t('settings.mcp.page.toast.connectionFailed'));
|
2026-04-21 11:46:51 -06:00
|
|
|
} else {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.message(t('settings.mcp.page.toast.connectionAttemptFinished'));
|
2026-04-21 11:46:51 -06:00
|
|
|
}
|
|
|
|
|
return;
|
2026-02-23 04:22:33 +07:00
|
|
|
}
|
|
|
|
|
await refreshStatus({ directory: currentDirectory, silent: true });
|
|
|
|
|
} catch (err) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(err instanceof Error ? err.message : t('settings.mcp.page.toast.connectionFailed'));
|
2026-02-23 04:22:33 +07:00
|
|
|
} finally {
|
|
|
|
|
setIsConnecting(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
const requireSavedConfig = React.useCallback((): boolean => {
|
|
|
|
|
if (isNewServer) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('settings.mcp.page.toast.createServerBeforeLiveActions'));
|
2026-04-21 11:46:51 -06:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (isDirty) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('settings.mcp.page.toast.saveBeforeLiveActions'));
|
2026-04-21 11:46:51 -06:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
2026-04-26 14:03:39 +03:00
|
|
|
}, [isDirty, isNewServer, t]);
|
2026-04-21 11:46:51 -06:00
|
|
|
|
|
|
|
|
const handleRefreshRuntimeStatus = React.useCallback(async (silent = false) => {
|
|
|
|
|
try {
|
|
|
|
|
await refreshStatus({ directory: currentDirectory, silent });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (!silent) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(err instanceof Error ? err.message : t('settings.mcp.page.toast.refreshStatusFailed'));
|
2026-04-21 11:46:51 -06:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-26 14:03:39 +03:00
|
|
|
}, [currentDirectory, refreshStatus, t]);
|
2026-04-21 11:46:51 -06:00
|
|
|
|
|
|
|
|
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 {
|
2026-08-22 19:50:16 +03:00
|
|
|
const currentStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName]?.status;
|
2026-04-21 11:46:51 -06:00
|
|
|
authPollStartsFromNeedsAuthRef.current = currentStatus === 'needs_auth' || currentStatus === 'needs_client_registration';
|
|
|
|
|
|
2026-08-09 19:30:25 +03:00
|
|
|
// One implementation for every surface that can authorise; the page
|
|
|
|
|
// used to own this flow while the dropdown and the work-status panel
|
|
|
|
|
// called plain `connect`, which cannot start OAuth at all.
|
2026-08-10 20:23:45 +03:00
|
|
|
const { authorizationUrl: nextAuthUrl, opened, nativeFlow, completion } = await startMcpAuthorization({
|
2026-08-09 19:30:25 +03:00
|
|
|
name: selectedMcpName,
|
|
|
|
|
directory: currentDirectory,
|
|
|
|
|
});
|
2026-08-10 20:23:45 +03:00
|
|
|
|
|
|
|
|
if (nativeFlow) {
|
|
|
|
|
// OpenCode opened the browser and completes the flow itself; there is
|
|
|
|
|
// no URL or state to track. The completion promise is the authoritative
|
|
|
|
|
// end signal — status polling alone cannot tell a finished
|
|
|
|
|
// reauthorization from the still-connected state it started in.
|
|
|
|
|
if (runtimeActionKeyRef.current !== actionKey) return;
|
|
|
|
|
setAuthUrl(null);
|
|
|
|
|
setAuthStateKey(null);
|
|
|
|
|
setIsAuthPolling(true);
|
|
|
|
|
authPollAttemptsRef.current = 0;
|
|
|
|
|
toast.message(t('settings.mcp.page.toast.completeAuthorizationInBrowser'));
|
|
|
|
|
completion
|
|
|
|
|
?.then(() => {
|
|
|
|
|
if (runtimeActionKeyRef.current !== actionKey) return;
|
|
|
|
|
setIsAuthPolling(false);
|
|
|
|
|
authPollAttemptsRef.current = 0;
|
|
|
|
|
authPollStartsFromNeedsAuthRef.current = false;
|
|
|
|
|
toast.success(t('settings.mcp.page.toast.authorizationCompleted'));
|
|
|
|
|
})
|
|
|
|
|
.catch((completionError) => {
|
|
|
|
|
if (runtimeActionKeyRef.current !== actionKey) return;
|
|
|
|
|
setIsAuthPolling(false);
|
|
|
|
|
authPollAttemptsRef.current = 0;
|
|
|
|
|
authPollStartsFromNeedsAuthRef.current = false;
|
|
|
|
|
toast.error(normalizeMcpAuthErrorMessage(completionError, t('settings.mcp.page.toast.authorizationFailed'), tUnsafe));
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
const stateKey = parseMcpOAuthCallbackStateKey(new URL(nextAuthUrl).searchParams);
|
2026-08-09 19:30:25 +03:00
|
|
|
queuedStateKey = stateKey;
|
2026-04-21 11:46:51 -06:00
|
|
|
|
|
|
|
|
if (runtimeActionKeyRef.current !== actionKey) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setAuthUrl(nextAuthUrl);
|
|
|
|
|
setAuthStateKey(stateKey ?? null);
|
|
|
|
|
setIsAuthPolling(true);
|
|
|
|
|
authPollAttemptsRef.current = 0;
|
|
|
|
|
|
|
|
|
|
if (runtimeActionKeyRef.current !== actionKey) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (opened) {
|
|
|
|
|
toast.message(
|
|
|
|
|
isVSCodeAuthRuntime
|
2026-04-26 14:03:39 +03:00
|
|
|
? t('settings.mcp.page.toast.completeAuthorizationInBrowserWithPaste')
|
|
|
|
|
: t('settings.mcp.page.toast.completeAuthorizationInBrowser'),
|
2026-04-21 11:46:51 -06:00
|
|
|
);
|
|
|
|
|
} else {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('settings.mcp.page.toast.openAuthorizationUrlFailed'));
|
2026-04-21 11:46:51 -06:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
await clearPendingMcpAuthContext(queuedStateKey);
|
|
|
|
|
if (runtimeActionKeyRef.current === actionKey) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(normalizeMcpAuthErrorMessage(err, t('settings.mcp.page.toast.authorizationStartFailed'), tUnsafe));
|
2026-04-21 11:46:51 -06:00
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
if (runtimeActionKeyRef.current === actionKey) {
|
|
|
|
|
setIsAuthorizing(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-09 19:30:25 +03:00
|
|
|
}, [currentDirectory, isVSCodeAuthRuntime, mcpType, requireSavedConfig, runtimeActionKey, selectedMcpName, t, tUnsafe]);
|
2026-04-21 11:46:51 -06:00
|
|
|
|
|
|
|
|
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);
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(t('settings.mcp.page.toast.savedAuthorizationRemoved'));
|
2026-04-21 11:46:51 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
if (runtimeActionKeyRef.current === actionKey) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(normalizeMcpAuthErrorMessage(err, t('settings.mcp.page.toast.clearAuthorizationFailed'), tUnsafe));
|
2026-04-21 11:46:51 -06:00
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
if (runtimeActionKeyRef.current === actionKey) {
|
|
|
|
|
setIsClearingAuth(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-26 14:03:39 +03:00
|
|
|
}, [authStateKey, clearAuthMcp, currentDirectory, requireSavedConfig, runtimeActionKey, selectedMcpName, t, tUnsafe]);
|
2026-04-21 11:46:51 -06:00
|
|
|
|
|
|
|
|
const handleCopyAuthUrl = React.useCallback(async () => {
|
|
|
|
|
if (!authUrl) return;
|
|
|
|
|
const result = await copyTextToClipboard(authUrl);
|
|
|
|
|
if (result.ok) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(t('settings.mcp.page.toast.authorizationUrlCopied'));
|
2026-04-21 11:46:51 -06:00
|
|
|
return;
|
|
|
|
|
}
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('settings.mcp.page.toast.authorizationUrlCopyFailed'));
|
|
|
|
|
}, [authUrl, t]);
|
2026-04-21 11:46:51 -06:00
|
|
|
|
|
|
|
|
const handleCompleteAuthorization = React.useCallback(async () => {
|
|
|
|
|
const response = extractAuthorizationResponse(authCallbackInput);
|
|
|
|
|
if (!response.code) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('settings.mcp.page.toast.pasteCallbackOrCodeFirst'));
|
2026-04-21 11:46:51 -06:00
|
|
|
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) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('settings.mcp.page.toast.missingServerDetails'));
|
2026-04-21 11:46:51 -06:00
|
|
|
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;
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(targetName === selectedMcpName
|
|
|
|
|
? t('settings.mcp.page.toast.authorizationCompleted')
|
|
|
|
|
: t('settings.mcp.page.toast.authorizationCompletedFor', { name: targetName }));
|
2026-04-21 11:46:51 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
if (runtimeActionKeyRef.current === actionKey) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(normalizeMcpAuthErrorMessage(err, t('settings.mcp.page.toast.authorizationCompleteFailed'), tUnsafe));
|
2026-04-21 11:46:51 -06:00
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
if (runtimeActionKeyRef.current === actionKey) {
|
|
|
|
|
setIsCompletingAuth(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-26 14:03:39 +03:00
|
|
|
}, [authCallbackInput, authStateKey, completeAuthMcp, currentDirectory, requireSavedConfig, runtimeActionKey, selectedMcpName, t, tUnsafe]);
|
2026-04-21 11:46:51 -06:00
|
|
|
|
|
|
|
|
const handleTestConnection = React.useCallback(async () => {
|
|
|
|
|
if (!selectedMcpName || !requireSavedConfig()) return;
|
|
|
|
|
if (!enabled) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('settings.mcp.page.toast.enableServerBeforeTest'));
|
2026-04-21 11:46:51 -06:00
|
|
|
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') {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(t('settings.mcp.page.toast.connectionTestSucceeded'));
|
2026-04-21 11:46:51 -06:00
|
|
|
} else if (nextStatus === 'needs_auth') {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.message(t('settings.mcp.page.toast.connectionNeedsAuthorization'));
|
2026-04-21 11:46:51 -06:00
|
|
|
} else if (nextStatus === 'needs_client_registration') {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.message(t('settings.mcp.page.toast.connectionNeedsClientRegistration'));
|
2026-04-21 11:46:51 -06:00
|
|
|
} else if (nextStatus === 'failed') {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(result.status?.error || result.error || t('settings.mcp.page.toast.connectionTestFailed'));
|
2026-04-21 11:46:51 -06:00
|
|
|
} else if (result.error) {
|
|
|
|
|
toast.error(result.error);
|
|
|
|
|
} else {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.message(t('settings.mcp.page.toast.connectionTestFinished'));
|
2026-04-21 11:46:51 -06:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(err instanceof Error ? err.message : t('settings.mcp.page.toast.connectionTestFailed'));
|
2026-04-21 11:46:51 -06:00
|
|
|
} finally {
|
|
|
|
|
setIsTestingConnection(false);
|
|
|
|
|
}
|
2026-04-26 14:03:39 +03:00
|
|
|
}, [currentDirectory, enabled, requireSavedConfig, selectedMcpName, t, testConnectionMcp]);
|
2026-04-21 11:46:51 -06:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!isAuthPolling || !selectedMcpName) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const intervalId = window.setInterval(() => {
|
|
|
|
|
void (async () => {
|
|
|
|
|
authPollAttemptsRef.current += 1;
|
|
|
|
|
await refreshStatus({ directory: currentDirectory, silent: true });
|
2026-08-22 19:50:16 +03:00
|
|
|
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName];
|
2026-04-21 11:46:51 -06:00
|
|
|
|
|
|
|
|
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') {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(t('settings.mcp.page.toast.authorizationCompleted'));
|
2026-04-21 11:46:51 -06:00
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!authPollStartsFromNeedsAuthRef.current && nextStatus.status === 'failed') {
|
|
|
|
|
setIsAuthPolling(false);
|
|
|
|
|
authPollAttemptsRef.current = 0;
|
|
|
|
|
authPollStartsFromNeedsAuthRef.current = false;
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(nextStatus.error || t('settings.mcp.page.toast.authorizationFailed'));
|
2026-04-21 11:46:51 -06:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (authPollAttemptsRef.current >= 30) {
|
|
|
|
|
setIsAuthPolling(false);
|
|
|
|
|
authPollAttemptsRef.current = 0;
|
|
|
|
|
authPollStartsFromNeedsAuthRef.current = false;
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.message(t('settings.mcp.page.toast.authorizationStillInProgress'));
|
2026-04-21 11:46:51 -06:00
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
window.clearInterval(intervalId);
|
|
|
|
|
};
|
2026-04-26 14:03:39 +03:00
|
|
|
}, [currentDirectory, isAuthPolling, refreshStatus, selectedMcpName, t]);
|
2026-04-21 11:46:51 -06:00
|
|
|
|
2026-02-23 04:22:33 +07:00
|
|
|
// ── Empty state ──
|
|
|
|
|
if (!selectedMcpName) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full items-center justify-center">
|
|
|
|
|
<div className="text-center text-muted-foreground">
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="plug" className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
2026-04-26 14:03:39 +03:00
|
|
|
<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>
|
2026-02-23 04:22:33 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const runtimeStatus = mcpStatus[selectedMcpName];
|
2026-04-21 11:46:51 -06:00
|
|
|
const runtimeDiagnostic = selectedMcpName ? mcpDiagnostics[selectedMcpName] : undefined;
|
|
|
|
|
const effectiveRuntimeStatus = runtimeStatus ?? runtimeDiagnostic;
|
2026-08-10 20:23:45 +03:00
|
|
|
// Saved into the config but queued behind Apply & Restart: OpenCode does not
|
|
|
|
|
// know this server yet, so every runtime action (connect, authorize, clear
|
|
|
|
|
// auth) can only fail with "server not found". The page says that instead of
|
|
|
|
|
// offering the buttons.
|
|
|
|
|
const isAwaitingRestart = !isNewServer && !effectiveRuntimeStatus
|
|
|
|
|
&& pendingRestartChanges.some((change) => change.scope === 'mcp' && change.id.startsWith(`mcp:${selectedMcpName}:`));
|
2026-02-23 04:22:33 +07:00
|
|
|
const isConnected = runtimeStatus?.status === 'connected';
|
2026-04-21 11:46:51 -06:00
|
|
|
const needsAuthorization = runtimeStatus?.status === 'needs_auth' || runtimeStatus?.status === 'needs_client_registration';
|
2026-08-09 19:30:25 +03:00
|
|
|
// Must be the very URI `startMcpAuthorization` writes into the config, not a
|
|
|
|
|
// second construction of it. The page used to suggest a directory-bearing
|
|
|
|
|
// address while the flow sent a directory-less one, so a provider enforcing
|
|
|
|
|
// exact redirect matching rejected a registration copied from right here.
|
|
|
|
|
const suggestedRedirectUri = isVSCodeAuthRuntime || !selectedMcpName
|
|
|
|
|
? null
|
|
|
|
|
: buildMcpAuthorizationRedirectUri(selectedMcpName);
|
|
|
|
|
|
|
|
|
|
const handleCopyRedirectUri = async () => {
|
|
|
|
|
if (!suggestedRedirectUri) return;
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(suggestedRedirectUri);
|
|
|
|
|
toast.success(t('settings.mcp.page.toast.copiedCallbackUrl'));
|
|
|
|
|
} catch {
|
|
|
|
|
toast.error(t('settings.mcp.page.toast.clipboardWriteFailed'));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
const runtimeDescription = getStatusDescription(
|
|
|
|
|
effectiveRuntimeStatus?.status,
|
2026-04-26 14:03:39 +03:00
|
|
|
tUnsafe,
|
2026-04-21 11:46:51 -06:00
|
|
|
effectiveRuntimeStatus && 'error' in effectiveRuntimeStatus ? effectiveRuntimeStatus.error : undefined,
|
|
|
|
|
);
|
2026-04-26 14:03:39 +03:00
|
|
|
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');
|
2026-08-10 20:23:45 +03:00
|
|
|
case 'awaiting_restart':
|
|
|
|
|
return t('settings.mcp.page.status.label.awaitingRestart');
|
2026-04-26 14:03:39 +03:00
|
|
|
default:
|
|
|
|
|
return status;
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-02-23 04:22:33 +07:00
|
|
|
|
|
|
|
|
return (
|
2026-07-18 00:11:05 +03:00
|
|
|
<>
|
|
|
|
|
<SettingsPageLayout
|
|
|
|
|
title={isNewServer ? t('settings.mcp.page.header.newServer') : selectedMcpName}
|
|
|
|
|
titleAccessory={!isNewServer ? (
|
2026-08-10 20:23:45 +03:00
|
|
|
<StatusBadge
|
|
|
|
|
status={isAwaitingRestart ? 'awaiting_restart' : effectiveRuntimeStatus?.status}
|
|
|
|
|
enabled={enabled}
|
|
|
|
|
getStatusLabel={getStatusLabel}
|
|
|
|
|
variant="pill"
|
|
|
|
|
/>
|
2026-07-18 00:11:05 +03:00
|
|
|
) : undefined}
|
|
|
|
|
description={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') })}
|
2026-08-10 20:23:45 +03:00
|
|
|
headerEnd={!isNewServer && !isAwaitingRestart ? (
|
2026-07-18 00:11:05 +03:00
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
<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}
|
|
|
|
|
>
|
2026-08-10 20:23:45 +03:00
|
|
|
{/* "Reauthorize" only once a working authorization exists (the
|
|
|
|
|
server is connected); every other state — needs_auth,
|
|
|
|
|
failed, still unknown — reads "Authorize" so the label does
|
|
|
|
|
not imply stored credentials that may not be there. */}
|
2026-07-18 00:11:05 +03:00
|
|
|
{isAuthorizing
|
|
|
|
|
? t('settings.mcp.page.actions.starting')
|
2026-08-10 20:23:45 +03:00
|
|
|
: isConnected
|
|
|
|
|
? t('settings.mcp.page.actions.reauthorize')
|
|
|
|
|
: t('settings.mcp.page.actions.authorize')}
|
2026-07-18 00:11:05 +03:00
|
|
|
</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>
|
|
|
|
|
)}
|
2026-02-24 03:28:30 +02:00
|
|
|
</div>
|
2026-07-18 00:11:05 +03:00
|
|
|
) : undefined}
|
|
|
|
|
showSaveStatus={false}
|
|
|
|
|
>
|
|
|
|
|
|
|
|
|
|
|
2026-02-23 04:22:33 +07:00
|
|
|
|
2026-08-10 20:23:45 +03:00
|
|
|
{/* Saved but queued behind Apply & Restart: dynamic status the user
|
|
|
|
|
must see, or the missing action buttons read as a broken page. */}
|
|
|
|
|
{isAwaitingRestart && (
|
|
|
|
|
<SettingsSection divider={false}>
|
|
|
|
|
<div className="rounded-lg border p-3 border-[var(--status-warning-border)] bg-[var(--status-warning-background)]">
|
|
|
|
|
<div className="min-w-0 space-y-1">
|
|
|
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
|
|
|
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.mcp.page.status.runtimeStatus')}</span>
|
|
|
|
|
<StatusBadge status="awaiting_restart" enabled={enabled} getStatusLabel={getStatusLabel} />
|
|
|
|
|
</div>
|
|
|
|
|
<p className="typography-meta text-muted-foreground">
|
|
|
|
|
{t('settings.mcp.page.status.description.awaitingRestart')}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</SettingsSection>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
{/* Runtime Status - Simplified for connected, expanded for errors */}
|
2026-08-10 20:23:45 +03:00
|
|
|
{!isNewServer && !isAwaitingRestart && shouldShowFullStatusCard(effectiveRuntimeStatus?.status, authUrl, needsAuthorization, isAuthPolling) && (
|
2026-07-18 00:11:05 +03:00
|
|
|
<SettingsSection divider={false}>
|
2026-04-21 11:46:51 -06:00
|
|
|
<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">
|
2026-07-18 00:11:05 +03:00
|
|
|
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.mcp.page.status.runtimeStatus')}</span>
|
2026-04-26 14:03:39 +03:00
|
|
|
<StatusBadge status={effectiveRuntimeStatus?.status} enabled={enabled} getStatusLabel={getStatusLabel} />
|
2026-04-21 11:46:51 -06:00
|
|
|
</div>
|
|
|
|
|
<p className="typography-meta text-muted-foreground">{runtimeDescription}</p>
|
|
|
|
|
<p className="typography-micro text-muted-foreground/80">
|
|
|
|
|
{draftScope === 'project'
|
2026-04-26 14:03:39 +03:00
|
|
|
? t('settings.mcp.page.status.projectScopedTo', { directory: currentDirectory ?? t('settings.mcp.page.status.activeProject') })
|
|
|
|
|
: t('settings.mcp.page.status.userScoped')}
|
2026-04-21 11:46:51 -06:00
|
|
|
</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}
|
|
|
|
|
>
|
2026-04-26 14:03:39 +03:00
|
|
|
{isTestingConnection ? t('settings.mcp.page.actions.testing') : t('settings.mcp.page.actions.testConnection')}
|
2026-04-21 11:46:51 -06:00
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-08-09 19:30:25 +03:00
|
|
|
{/* Client credentials appear only when the server has said it
|
|
|
|
|
needs them. Kept as a permanent four-field form, they made
|
|
|
|
|
the rarest case the most prominent thing on the page and
|
|
|
|
|
told nobody what to put there. */}
|
|
|
|
|
{effectiveRuntimeStatus?.status === 'needs_client_registration' && (
|
|
|
|
|
<div className="space-y-3 rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-3">
|
|
|
|
|
<div>
|
|
|
|
|
<SettingsGroupTitle as="div">{t('settings.mcp.page.registration.title')}</SettingsGroupTitle>
|
|
|
|
|
<p className="mt-1 typography-micro text-muted-foreground">
|
|
|
|
|
{t('settings.mcp.page.registration.description')}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{suggestedRedirectUri && (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="typography-micro text-muted-foreground">
|
|
|
|
|
{t('settings.mcp.page.registration.callbackLabel')}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-1 flex items-start gap-2">
|
|
|
|
|
<span className="min-w-0 flex-1 break-all font-mono typography-micro text-foreground/80">
|
|
|
|
|
{suggestedRedirectUri}
|
|
|
|
|
</span>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="xs"
|
|
|
|
|
className="!font-normal"
|
|
|
|
|
onClick={() => void handleCopyRedirectUri()}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="clipboard" className="h-3.5 w-3.5" />
|
|
|
|
|
{t('settings.mcp.page.actions.copyLink')}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-3 @xl:grid-cols-2">
|
|
|
|
|
<SettingsStackedField label={t('settings.mcp.page.registration.clientId')}>
|
|
|
|
|
<Input
|
|
|
|
|
value={oauthClientId}
|
|
|
|
|
onChange={(e) => { setOauthClientId(e.target.value); setOauthEnabled(true); }}
|
|
|
|
|
className="font-mono typography-meta"
|
|
|
|
|
data-bwignore="true"
|
|
|
|
|
data-1p-ignore="true"
|
|
|
|
|
/>
|
|
|
|
|
</SettingsStackedField>
|
|
|
|
|
<SettingsStackedField label={t('settings.mcp.page.registration.clientSecret')}>
|
|
|
|
|
<Input
|
|
|
|
|
type="password"
|
|
|
|
|
value={oauthClientSecret}
|
|
|
|
|
onChange={(e) => { setOauthClientSecret(e.target.value); setOauthEnabled(true); }}
|
|
|
|
|
className="font-mono typography-meta"
|
|
|
|
|
data-bwignore="true"
|
|
|
|
|
data-1p-ignore="true"
|
|
|
|
|
/>
|
|
|
|
|
</SettingsStackedField>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p className="typography-micro text-muted-foreground">
|
|
|
|
|
{t('settings.mcp.page.registration.afterSaving')}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
{authUrl && (
|
|
|
|
|
<div className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-2">
|
|
|
|
|
<div className="space-y-2">
|
2026-04-26 14:03:39 +03:00
|
|
|
<div className="typography-micro text-muted-foreground">{t('settings.mcp.page.auth.authorizationUrl')}</div>
|
2026-04-21 11:46:51 -06:00
|
|
|
<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)}>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="external-link" className="h-3.5 w-3.5" />
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.mcp.page.actions.openInBrowser')}
|
2026-04-21 11:46:51 -06:00
|
|
|
</Button>
|
|
|
|
|
<Button variant="outline" size="xs" className="!font-normal" onClick={() => void handleCopyAuthUrl()}>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="clipboard" className="h-3.5 w-3.5" />
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.mcp.page.actions.copyLink')}
|
2026-04-21 11:46:51 -06:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-08-09 19:30:25 +03:00
|
|
|
{/* VS Code only. Everywhere else the callback returns into the
|
|
|
|
|
app on its own, so the paste box was a second, confusing way
|
|
|
|
|
to do what already happened. VS Code cannot receive that
|
|
|
|
|
redirect, so there it remains the only way to finish. */}
|
|
|
|
|
{isVSCodeAuthRuntime && mcpType === 'remote' && (needsAuthorization || isAuthPolling || authUrl) && (
|
2026-04-21 11:46:51 -06:00
|
|
|
<div className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-3">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div>
|
2026-07-18 00:11:05 +03:00
|
|
|
<SettingsGroupTitle as="div">{t('settings.mcp.page.auth.manualFallbackTitle')}</SettingsGroupTitle>
|
2026-04-21 11:46:51 -06:00
|
|
|
<p className="mt-1 typography-micro text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.mcp.page.auth.manualFallbackDescription')}
|
2026-04-21 11:46:51 -06:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Textarea
|
|
|
|
|
value={authCallbackInput}
|
|
|
|
|
onChange={(event) => setAuthCallbackInput(event.target.value)}
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('settings.mcp.page.auth.callbackInputPlaceholder')}
|
2026-04-21 11:46:51 -06:00
|
|
|
rows={3}
|
2026-08-09 19:30:25 +03:00
|
|
|
className="font-mono typography-meta"
|
2026-04-21 11:46:51 -06:00
|
|
|
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}
|
|
|
|
|
>
|
2026-04-26 14:03:39 +03:00
|
|
|
{isCompletingAuth ? t('settings.mcp.page.actions.completing') : t('settings.mcp.page.actions.completeAuthorization')}
|
2026-04-21 11:46:51 -06:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{isAuthPolling && (
|
|
|
|
|
<p className="mt-4 typography-micro text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.mcp.page.auth.waitingForOpenCode')}
|
2026-04-21 11:46:51 -06:00
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-07-18 00:11:05 +03:00
|
|
|
</SettingsSection>
|
2026-04-21 11:46:51 -06:00
|
|
|
)}
|
|
|
|
|
|
2026-07-18 00:11:05 +03:00
|
|
|
<SettingsSection
|
|
|
|
|
title={t('settings.mcp.page.server.title')}
|
|
|
|
|
divider={false}
|
|
|
|
|
settingsItem="mcp.server"
|
|
|
|
|
contentClassName="space-y-0"
|
2026-08-09 19:30:25 +03:00
|
|
|
titleAccessory={isNewServer ? (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="xs"
|
|
|
|
|
className="!font-normal gap-1.5 text-muted-foreground"
|
|
|
|
|
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>
|
|
|
|
|
) : null}
|
2026-07-18 00:11:05 +03:00
|
|
|
>
|
2026-02-24 03:28:30 +02:00
|
|
|
|
|
|
|
|
{isNewServer && (
|
2026-08-09 19:30:25 +03:00
|
|
|
<SettingsFieldRow
|
|
|
|
|
label={t('settings.mcp.page.server.name')}
|
|
|
|
|
// The scope select carries words now, not a lone icon, so the
|
|
|
|
|
// control cluster has to be allowed to bound itself and wrap.
|
|
|
|
|
// Left at its default (fit-width, no shrink) the pair ran past
|
|
|
|
|
// the edge of the settings pane in a narrow dialog.
|
|
|
|
|
controlClassName="flex-wrap @xl:w-auto @xl:flex-1"
|
|
|
|
|
>
|
2026-02-24 03:28:30 +02:00
|
|
|
<Input
|
|
|
|
|
value={draftName}
|
|
|
|
|
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))}
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('settings.mcp.page.server.namePlaceholder')}
|
2026-08-09 19:30:25 +03:00
|
|
|
className="h-7 w-48 min-w-0 max-w-full shrink font-mono px-2"
|
2026-02-24 03:28:30 +02:00
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as McpScope)}>
|
2026-08-09 19:30:25 +03:00
|
|
|
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="!h-7 min-w-0 max-w-full gap-1.5 px-2">
|
|
|
|
|
<Icon
|
|
|
|
|
name={draftScope === 'user' ? 'user-3' : 'folder'}
|
|
|
|
|
className="h-3.5 w-3.5 shrink-0"
|
|
|
|
|
/>
|
|
|
|
|
<span className="truncate">
|
|
|
|
|
{draftScope === 'user'
|
|
|
|
|
? t('settings.mcp.page.scope.everywhere')
|
|
|
|
|
: t('settings.mcp.page.scope.thisProject')}
|
|
|
|
|
</span>
|
2026-02-24 03:28:30 +02:00
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent align="end">
|
|
|
|
|
<SelectItem value="user">
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="user-3" className="h-3.5 w-3.5" />
|
2026-08-09 19:30:25 +03:00
|
|
|
<span>{t('settings.mcp.page.scope.everywhere')}</span>
|
2026-02-24 03:28:30 +02:00
|
|
|
</div>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
<SelectItem value="project">
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="folder" className="h-3.5 w-3.5" />
|
2026-08-09 19:30:25 +03:00
|
|
|
<span>{t('settings.mcp.page.scope.thisProject')}</span>
|
2026-02-24 03:28:30 +02:00
|
|
|
</div>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
2026-07-18 00:11:05 +03:00
|
|
|
</SettingsFieldRow>
|
2026-02-23 04:22:33 +07:00
|
|
|
)}
|
|
|
|
|
|
2026-07-18 00:11:05 +03:00
|
|
|
<SettingsCheckboxRow
|
|
|
|
|
checked={enabled}
|
|
|
|
|
onChange={setEnabled}
|
|
|
|
|
label={t('settings.mcp.page.server.enable')}
|
|
|
|
|
ariaLabel={t('settings.mcp.page.server.enableAria')}
|
|
|
|
|
/>
|
2026-02-23 04:22:33 +07:00
|
|
|
|
2026-07-18 00:11:05 +03:00
|
|
|
</SettingsSection>
|
2026-02-24 03:28:30 +02:00
|
|
|
|
2026-07-18 00:11:05 +03:00
|
|
|
<SettingsSection
|
2026-08-09 19:30:25 +03:00
|
|
|
title={t('settings.mcp.page.connection.title')}
|
|
|
|
|
description={t('settings.mcp.page.connection.description')}
|
2026-07-18 00:11:05 +03:00
|
|
|
settingsItem="mcp.command"
|
2026-08-09 19:30:25 +03:00
|
|
|
// The section's content wrapper carries no spacing of its own, so the
|
|
|
|
|
// kind tabs, the field and its hint would otherwise sit flush.
|
|
|
|
|
contentClassName="space-y-2"
|
2026-07-18 00:11:05 +03:00
|
|
|
>
|
2026-08-09 19:30:25 +03:00
|
|
|
{/* Pasting a link or a command still flips this for you, but the
|
|
|
|
|
choice is a control you can see and press. As one sentence with
|
|
|
|
|
an inline link it was, in practice, undiscoverable. */}
|
|
|
|
|
<SortableTabsStrip
|
|
|
|
|
items={connectionKindTabs}
|
|
|
|
|
activeId={mcpType}
|
|
|
|
|
onSelect={(id) => setMcpType(id as 'local' | 'remote')}
|
|
|
|
|
layoutMode="fit"
|
|
|
|
|
variant="active-pill"
|
|
|
|
|
activePillLowercase={false}
|
|
|
|
|
className="h-10"
|
|
|
|
|
/>
|
|
|
|
|
|
2026-02-24 03:28:30 +02:00
|
|
|
{mcpType === 'local' ? (
|
2026-04-26 14:03:39 +03:00
|
|
|
<CommandTextarea
|
|
|
|
|
value={command}
|
|
|
|
|
onChange={setCommand}
|
|
|
|
|
preview={(count) => t('settings.mcp.page.connection.previewArgs', { count })}
|
2026-08-09 19:30:25 +03:00
|
|
|
onDetectUrl={handleDetectedUrl}
|
2026-04-26 14:03:39 +03:00
|
|
|
/>
|
2026-02-24 03:28:30 +02:00
|
|
|
) : (
|
2026-02-23 04:22:33 +07:00
|
|
|
<Input
|
|
|
|
|
value={url}
|
2026-08-09 19:30:25 +03:00
|
|
|
onChange={(e) => handleUrlChange(e.target.value)}
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('settings.mcp.page.connection.serverUrlPlaceholder')}
|
2026-02-23 04:22:33 +07:00
|
|
|
className="font-mono typography-meta"
|
|
|
|
|
/>
|
2026-02-24 03:28:30 +02:00
|
|
|
)}
|
2026-08-09 19:30:25 +03:00
|
|
|
|
|
|
|
|
<p className="typography-micro text-muted-foreground">
|
|
|
|
|
{mcpType === 'local'
|
|
|
|
|
? t('settings.mcp.page.connection.hintCommand')
|
|
|
|
|
: t('settings.mcp.page.connection.hintLink')}
|
|
|
|
|
</p>
|
2026-07-18 00:11:05 +03:00
|
|
|
</SettingsSection>
|
2026-02-23 04:22:33 +07:00
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
{mcpType === 'remote' && (
|
2026-07-18 00:11:05 +03:00
|
|
|
<SettingsSection
|
|
|
|
|
title={t('settings.mcp.page.advanced.title')}
|
|
|
|
|
settingsItem="mcp.advanced"
|
|
|
|
|
>
|
2026-04-21 11:46:51 -06:00
|
|
|
<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">
|
2026-04-26 14:03:39 +03:00
|
|
|
<span className="typography-ui-label font-normal text-foreground">{t('settings.mcp.page.advanced.configure')}</span>
|
2026-04-21 11:46:51 -06:00
|
|
|
<span className="typography-micro text-muted-foreground">
|
2026-08-09 19:30:25 +03:00
|
|
|
{/* OAuth left the form, so the summary stops reporting a
|
|
|
|
|
setting the user can no longer see. */}
|
|
|
|
|
({headerEntries.length} {t('settings.mcp.page.advanced.headers')}{timeout ? ` · ${timeout}ms` : ''})
|
2026-04-21 11:46:51 -06:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{isAdvancedRemoteOptionsOpen ? (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-down-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
2026-04-21 11:46:51 -06:00
|
|
|
) : (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-right-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
2026-04-21 11:46:51 -06:00
|
|
|
)}
|
|
|
|
|
</CollapsibleTrigger>
|
|
|
|
|
<CollapsibleContent className="pt-2">
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="space-y-2">
|
2026-07-18 00:11:05 +03:00
|
|
|
<div className="flex flex-col gap-2 @xl:flex-row @xl:items-center @xl:gap-8">
|
|
|
|
|
<div className="flex min-w-0 flex-row items-center gap-1 @xl:w-56 shrink-0">
|
|
|
|
|
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.mcp.page.advanced.timeoutMs')}</span>
|
|
|
|
|
<SettingsInfoHint>{t('settings.mcp.page.advanced.timeoutHint')}</SettingsInfoHint>
|
2026-04-21 11:46:51 -06:00
|
|
|
</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>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
2026-07-18 00:11:05 +03:00
|
|
|
<SettingsGroupTitle as="div" className="mb-2">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.mcp.page.advanced.requestHeaders')}
|
2026-04-21 11:46:51 -06:00
|
|
|
{headerEntries.length > 0 && (
|
|
|
|
|
<span className="ml-1.5 typography-micro text-muted-foreground font-normal">({headerEntries.length})</span>
|
|
|
|
|
)}
|
2026-07-18 00:11:05 +03:00
|
|
|
</SettingsGroupTitle>
|
2026-04-21 11:46:51 -06:00
|
|
|
<EnvEditor
|
|
|
|
|
value={headerEntries}
|
|
|
|
|
onChange={setHeaderEntries}
|
|
|
|
|
keyTransform={(value) => value.trimStart()}
|
2026-04-26 14:03:39 +03:00
|
|
|
keyPlaceholder={t('settings.mcp.page.advanced.headerNamePlaceholder')}
|
2026-04-21 11:46:51 -06:00
|
|
|
keyInputClassName="w-36 shrink-0 font-mono typography-meta"
|
2026-04-26 14:03:39 +03:00
|
|
|
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')}
|
2026-04-21 11:46:51 -06:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
</CollapsibleContent>
|
|
|
|
|
</Collapsible>
|
2026-07-18 00:11:05 +03:00
|
|
|
</SettingsSection>
|
2026-04-21 11:46:51 -06:00
|
|
|
)}
|
|
|
|
|
|
2026-07-18 00:11:05 +03:00
|
|
|
<SettingsSection
|
|
|
|
|
title={t('settings.mcp.page.env.title')}
|
2026-08-09 19:30:25 +03:00
|
|
|
description={t('settings.mcp.page.env.description')}
|
2026-07-18 00:11:05 +03:00
|
|
|
titleAccessory={
|
|
|
|
|
envEntries.length > 0 ? (
|
|
|
|
|
<span className="typography-micro text-muted-foreground font-normal">
|
|
|
|
|
({envEntries.length})
|
|
|
|
|
</span>
|
|
|
|
|
) : null
|
|
|
|
|
}
|
|
|
|
|
settingsItem="mcp.environment"
|
|
|
|
|
>
|
2026-04-21 11:46:51 -06:00
|
|
|
{envEntries.length === 0 ? (
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="xs"
|
|
|
|
|
className="!font-normal gap-1.5"
|
|
|
|
|
onClick={() => setEnvEntries([{ key: '', value: '' }])}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="add" className="h-3.5 w-3.5" />
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.mcp.page.env.addEnvironmentVariable')}
|
2026-04-21 11:46:51 -06:00
|
|
|
</Button>
|
|
|
|
|
) : (
|
2026-04-26 14:03:39 +03:00
|
|
|
<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')}
|
|
|
|
|
/>
|
2026-04-21 11:46:51 -06:00
|
|
|
)}
|
2026-07-18 00:11:05 +03:00
|
|
|
</SettingsSection>
|
2026-02-23 04:22:33 +07:00
|
|
|
|
2026-07-18 00:11:05 +03:00
|
|
|
<div className="flex items-center gap-2 pb-8">
|
2026-03-20 01:01:03 +02:00
|
|
|
<Button
|
2026-02-24 03:28:30 +02:00
|
|
|
onClick={handleSave}
|
|
|
|
|
disabled={isSaving || (!isDirty && !isNewServer)}
|
|
|
|
|
size="xs"
|
|
|
|
|
className="!font-normal"
|
|
|
|
|
>
|
2026-04-26 14:03:39 +03:00
|
|
|
{isSaving ? t('settings.common.actions.saving') : isNewServer ? t('settings.common.actions.create') : t('settings.common.actions.saveChanges')}
|
2026-03-20 01:01:03 +02:00
|
|
|
</Button>
|
2026-02-24 03:28:30 +02:00
|
|
|
{!isNewServer && (
|
2026-03-20 01:01:03 +02:00
|
|
|
<Button
|
2026-04-21 11:46:51 -06:00
|
|
|
variant="destructive"
|
2026-02-24 03:28:30 +02:00
|
|
|
size="xs"
|
2026-04-21 11:46:51 -06:00
|
|
|
className="!font-normal"
|
2026-02-23 04:22:33 +07:00
|
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
|
|
|
>
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.common.actions.delete')}
|
2026-03-20 01:01:03 +02:00
|
|
|
</Button>
|
2026-02-24 03:28:30 +02:00
|
|
|
)}
|
2026-07-18 00:11:05 +03:00
|
|
|
</div> </SettingsPageLayout>
|
|
|
|
|
|
2026-02-23 04:22:33 +07:00
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
{/* Import JSON dialog */}
|
|
|
|
|
<Dialog
|
|
|
|
|
open={showImportDialog}
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (!open) {
|
|
|
|
|
setShowImportDialog(false);
|
|
|
|
|
setImportJsonText('');
|
|
|
|
|
setImportError(null);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-04-24 09:33:02 +03:00
|
|
|
<DialogContent className="max-w-md">
|
2026-04-21 11:46:51 -06:00
|
|
|
<DialogHeader>
|
2026-04-26 14:03:39 +03:00
|
|
|
<DialogTitle>{t('settings.mcp.page.importDialog.title')}</DialogTitle>
|
2026-04-21 11:46:51 -06:00
|
|
|
<DialogDescription>
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.mcp.page.importDialog.description')}
|
2026-04-21 11:46:51 -06:00
|
|
|
</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}
|
2026-08-09 19:30:25 +03:00
|
|
|
className="font-mono typography-meta"
|
2026-04-21 11:46:51 -06:00
|
|
|
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"
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="clipboard" className="h-3.5 w-3.5" />
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.mcp.page.importDialog.pasteFromClipboard')}
|
2026-04-21 11:46:51 -06:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setShowImportDialog(false);
|
|
|
|
|
setImportJsonText('');
|
|
|
|
|
setImportError(null);
|
|
|
|
|
}}
|
|
|
|
|
className="text-foreground"
|
|
|
|
|
>
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.common.actions.cancel')}
|
2026-04-21 11:46:51 -06:00
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleImportJson}
|
|
|
|
|
disabled={!importJsonText.trim()}
|
|
|
|
|
size="sm"
|
|
|
|
|
>
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.common.actions.import')}
|
2026-04-21 11:46:51 -06:00
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
2026-02-23 04:22:33 +07:00
|
|
|
{/* Delete confirm */}
|
|
|
|
|
<Dialog
|
|
|
|
|
open={showDeleteConfirm}
|
|
|
|
|
onOpenChange={(open) => { if (!open && !isDeleting) setShowDeleteConfirm(false); }}
|
|
|
|
|
>
|
|
|
|
|
<DialogContent className="max-w-md">
|
|
|
|
|
<DialogHeader>
|
2026-04-26 14:03:39 +03:00
|
|
|
<DialogTitle>{t('settings.mcp.page.deleteDialog.title', { name: selectedMcpName ?? '' })}</DialogTitle>
|
2026-02-23 04:22:33 +07:00
|
|
|
<DialogDescription>
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.mcp.page.deleteDialog.descriptionPrefix')}{' '}
|
|
|
|
|
<code className="text-foreground">opencode.json</code>.
|
|
|
|
|
{' '}
|
|
|
|
|
{t('settings.mcp.page.deleteDialog.descriptionSuffix')}
|
2026-02-23 04:22:33 +07:00
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button
|
2026-04-21 11:46:51 -06:00
|
|
|
variant="outline"
|
2026-02-23 04:22:33 +07:00
|
|
|
onClick={() => setShowDeleteConfirm(false)}
|
|
|
|
|
disabled={isDeleting}
|
2026-04-21 11:46:51 -06:00
|
|
|
className="text-foreground"
|
2026-02-23 04:22:33 +07:00
|
|
|
>
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.common.actions.cancel')}
|
2026-02-23 04:22:33 +07:00
|
|
|
</Button>
|
2026-04-21 11:46:51 -06:00
|
|
|
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={isDeleting}>
|
2026-04-26 14:03:39 +03:00
|
|
|
{isDeleting ? t('settings.mcp.page.actions.deleting') : t('settings.common.actions.delete')}
|
2026-03-20 01:01:03 +02:00
|
|
|
</Button>
|
2026-02-23 04:22:33 +07:00
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
2026-07-18 00:11:05 +03:00
|
|
|
|
|
|
|
|
</>
|
2026-02-23 04:22:33 +07:00
|
|
|
);
|
|
|
|
|
};
|