feat(mcp): add MCP Config Manager UI (#473)
* feat(session-folders): drag-to-folder DnD, sort by activity, and UX improvements - Add DraggableSessionRow wrapping each session row so the whole row is draggable; stopPropagation prevents outer group-reorder DnD from firing - Add DroppableFolderWrapper + SessionFolderDndScope (inner DndContext scoped per group) with closestCenter collision detection - DragOverlay matches exact width/height of dragged row so cursor stays aligned - Folder header highlights (ring + primary colour) when a session hovers over it during drag - + button on folder header opens a dropdown: 'New session' / 'New folder' - + button on each folder row creates a session scoped to that folder - Empty folders are no longer auto-deleted (removed .filter(sessionIds.length>0) from addSessionToFolder / removeSessionFromFolder / cleanupSessions) - Sessions inside a folder are sorted by most-recent activity (same compareSessionsByPinnedAndTime logic used everywhere else) - Sort comparator now takes sessionAttentionStates so lastUserMessageAt / lastStatusChangeAt is used when newer than session.time.updated; all sort call-sites and their useMemo/useCallback deps updated accordingly - Remove foldersMap from cleanup effect deps to prevent cascade re-renders when folders change; read current value via getState() instead * fix(session-folders): new session is placed into the correct folder sendMessage() was calling useSessionManagementStore.createSession() directly, bypassing the targetFolderId logic in useSessionStore.createSession. Fix: read targetFolderId from draft at the top of the draft branch in sendMessage, then call addSessionToFolder immediately after the session is created and before the draft is closed. Also propagate targetFolderId through openNewSessionDraft options and NewSessionDraftState type. * feat(session-folders): add sub-folder support (one level deep) - SessionFolder gains optional parentId field for hierarchy - createFolder accepts parentId to create sub-folders - deleteFolder cascades to remove all child sub-folders - SessionFolderItem renders sub-folders before sessions in body; new sub-folder button (RiFolderAddLine) visible at depth 0 only - renderOneFolderItem in SessionSidebar builds the tree recursively; sub-folders are indented via depth prop (ml-3 on root's children) - Persist/hydrate parentId correctly from localStorage * feat(session): add delete confirm dialogs and improve subtitle UX - Add confirmation dialogs before deleting sessions or folders - Show relative time (e.g., '2h ago', '35min ago') for recent sessions - Replace +/- diff numbers with file change count (e.g., '3 files changed') - New folders use default name without forcing rename - Cleaner, less cluttered session list UI * fix(session-folders): skip folder cleanup while sessions are loading Prevents race condition on reload where cleanupSessions() runs before the server returns the full session list, causing folder-session assignments to be incorrectly wiped from localStorage. * feat(mcp): add MCP Config Manager UI - Backend: CRUD lib (mcp.js) + 5 REST routes (GET/POST/PATCH/DELETE /api/config/mcp/:name) - Frontend: Zustand store (useMcpConfigStore), McpSidebar with status dots, McpPage with redesigned UX - Textarea command editor: paste full shell commands, auto-split into args, one-arg-per-line view - Compact env editor: wide value column, show/hide toggle, paste .env format support - Header card: name, type badge, enabled toggle, connect/disconnect button - Navigation: 'mcp' added to sidebar sections in SettingsView - TypeScript: all packages pass type-check clean * fix(mcp): remove constant truthiness lint error in McpPage Replace '(isNewServer || true) &&' with unconditional render — type selector should always be visible so the user can switch between stdio and remote without recreating the server. * fix: add MCP server management to VS Code backend - Implement CRUD operations for MCP servers via bridge API - Support local and remote MCP server configurations with validation - Add VS Code webview endpoints for MCP server management * feat: add project-level MCP server configuration --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
5fc4feee42
commit
d0e4dc2704
@@ -0,0 +1,748 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui';
|
||||
import {
|
||||
useMcpConfigStore,
|
||||
envRecordToArray,
|
||||
type McpDraft,
|
||||
type McpScope,
|
||||
} from '@/stores/useMcpConfigStore';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import {
|
||||
RiAddLine,
|
||||
RiClipboardLine,
|
||||
RiDeleteBinLine,
|
||||
RiEyeLine,
|
||||
RiEyeOffLine,
|
||||
RiFolderLine,
|
||||
RiPlugLine,
|
||||
RiSaveLine,
|
||||
RiUser3Line,
|
||||
} from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// CommandTextarea — one arg per line, paste-friendly
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
interface CommandTextareaProps {
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
const CommandTextarea: React.FC<CommandTextareaProps> = ({ value, onChange }) => {
|
||||
// Internal: one arg per line
|
||||
const [text, setText] = React.useState(() => value.join('\n'));
|
||||
|
||||
// Sync when external value changes (e.g. switching servers)
|
||||
const prevValueRef = React.useRef(value);
|
||||
React.useEffect(() => {
|
||||
if (JSON.stringify(prevValueRef.current) !== JSON.stringify(value)) {
|
||||
prevValueRef.current = value;
|
||||
setText(value.join('\n'));
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const commit = (raw: string) => {
|
||||
const lines = raw.split('\n').filter((l) => l.trim().length > 0);
|
||||
onChange(lines);
|
||||
};
|
||||
|
||||
const handlePasteFromClipboard = async () => {
|
||||
try {
|
||||
const raw = await navigator.clipboard.readText();
|
||||
const trimmed = raw.trim();
|
||||
// If it looks like a multi-line list, keep as-is; otherwise parse as shell command
|
||||
const lines = trimmed.includes('\n')
|
||||
? trimmed.split('\n').filter((l) => l.trim())
|
||||
: parseShellCommand(trimmed);
|
||||
setText(lines.join('\n'));
|
||||
onChange(lines);
|
||||
toast.success(`Pasted ${lines.length} argument${lines.length !== 1 ? 's' : ''}`);
|
||||
} catch {
|
||||
toast.error('Cannot read clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
One argument per line. Blank lines are ignored.
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-2 typography-micro text-muted-foreground"
|
||||
onClick={handlePasteFromClipboard}
|
||||
type="button"
|
||||
title="Paste full command from clipboard and auto-split"
|
||||
>
|
||||
<RiClipboardLine className="h-3 w-3" />
|
||||
Paste command
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
commit(e.target.value);
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Normalise on blur: strip trailing spaces from each line
|
||||
const cleaned = text
|
||||
.split('\n')
|
||||
.map((l) => l.trimEnd())
|
||||
.join('\n');
|
||||
setText(cleaned);
|
||||
commit(cleaned);
|
||||
}}
|
||||
placeholder={
|
||||
'npx\n-y\n@modelcontextprotocol/server-postgres\npostgresql://user:pass@host/db'
|
||||
}
|
||||
rows={Math.max(4, value.length + 1)}
|
||||
className="font-mono typography-meta resize-y min-h-[80px]"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
{/* Formatted preview of what will be saved */}
|
||||
{value.length > 0 && (
|
||||
<details className="group">
|
||||
<summary className="typography-micro text-muted-foreground/60 cursor-pointer select-none hover:text-muted-foreground">
|
||||
Preview ({value.length} args)
|
||||
</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;
|
||||
}
|
||||
|
||||
const EnvEditor: React.FC<EnvEditorProps> = ({ value, onChange }) => {
|
||||
const [revealedKeys, setRevealedKeys] = React.useState<Set<number>>(new Set());
|
||||
|
||||
const addRow = () => onChange([...value, { key: '', value: '' }]);
|
||||
|
||||
const removeRow = (idx: number) => {
|
||||
onChange(value.filter((_, i) => i !== idx));
|
||||
setRevealedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(idx);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateRow = (idx: number, field: 'key' | 'value', val: string) => {
|
||||
const next = [...value];
|
||||
next[idx] = { ...next[idx], [field]: val };
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const toggleReveal = (idx: number) => {
|
||||
setRevealedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(idx)) next.delete(idx); else next.add(idx);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handlePasteDotEnv = async () => {
|
||||
try {
|
||||
const raw = await navigator.clipboard.readText();
|
||||
const parsed: EnvEntry[] = [];
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
let val = trimmed.slice(eqIdx + 1).trim();
|
||||
// Strip surrounding quotes
|
||||
if ((val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
if (key) parsed.push({ key, value: val });
|
||||
}
|
||||
if (parsed.length === 0) {
|
||||
toast.error('No KEY=VALUE pairs found in clipboard');
|
||||
return;
|
||||
}
|
||||
// Merge: update existing keys, append new ones
|
||||
const merged = [...value];
|
||||
for (const p of parsed) {
|
||||
const existing = merged.findIndex((e) => e.key === p.key);
|
||||
if (existing !== -1) merged[existing] = p;
|
||||
else merged.push(p);
|
||||
}
|
||||
onChange(merged);
|
||||
toast.success(`Imported ${parsed.length} variable${parsed.length !== 1 ? 's' : ''}`);
|
||||
} catch {
|
||||
toast.error('Cannot read clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const hasSensitiveValues = value.some((e) => e.value.length > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="typography-micro text-muted-foreground w-32 shrink-0">Key</span>
|
||||
<span className="typography-micro text-muted-foreground">Value</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-2 typography-micro text-muted-foreground"
|
||||
onClick={handlePasteDotEnv}
|
||||
type="button"
|
||||
title="Paste KEY=VALUE lines from clipboard"
|
||||
>
|
||||
<RiClipboardLine className="h-3 w-3" />
|
||||
Paste .env
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div className="space-y-1.5">
|
||||
{value.map((entry, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
{/* KEY — fixed narrow width */}
|
||||
<Input
|
||||
value={entry.key}
|
||||
onChange={(e) => updateRow(idx, 'key', e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '_'))}
|
||||
placeholder="API_KEY"
|
||||
className="w-36 shrink-0 font-mono typography-meta uppercase"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{/* VALUE — takes remaining space */}
|
||||
<div className="relative flex-1 flex items-center">
|
||||
<Input
|
||||
type={revealedKeys.has(idx) ? 'text' : 'password'}
|
||||
value={entry.value}
|
||||
onChange={(e) => updateRow(idx, 'value', e.target.value)}
|
||||
placeholder="value"
|
||||
className="font-mono typography-meta pr-8 w-full"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleReveal(idx)}
|
||||
className="absolute right-2 text-muted-foreground/60 hover:text-muted-foreground"
|
||||
title={revealedKeys.has(idx) ? 'Hide' : 'Show'}
|
||||
>
|
||||
{revealedKeys.has(idx)
|
||||
? <RiEyeOffLine className="h-3.5 w-3.5" />
|
||||
: <RiEyeLine className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
{/* Remove */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeRow(idx)}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 h-7 typography-meta"
|
||||
onClick={addRow}
|
||||
type="button"
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add variable
|
||||
</Button>
|
||||
|
||||
{hasSensitiveValues && (
|
||||
<p className="typography-micro text-muted-foreground/60">
|
||||
⚠ Values are stored as plain text in opencode.json
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Status badge
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
connected: 'Connected',
|
||||
failed: 'Failed',
|
||||
needs_auth: 'Needs auth',
|
||||
needs_client_registration: 'Needs registration',
|
||||
};
|
||||
|
||||
const StatusBadge: React.FC<{ status: string | undefined; enabled: boolean }> = ({ status, enabled }) => {
|
||||
if (!enabled) {
|
||||
return <span className="typography-micro text-muted-foreground/50">Disabled</span>;
|
||||
}
|
||||
if (!status) return null;
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
connected: 'text-green-600 dark:text-green-400',
|
||||
failed: 'text-destructive',
|
||||
needs_auth: 'text-yellow-600 dark:text-yellow-400',
|
||||
needs_client_registration: 'text-yellow-600 dark:text-yellow-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={cn('typography-micro font-medium', colorMap[status] ?? 'text-muted-foreground')}>
|
||||
● {STATUS_LABEL[status] ?? status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// McpPage
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export const McpPage: React.FC = () => {
|
||||
const {
|
||||
selectedMcpName,
|
||||
mcpServers,
|
||||
mcpDraft,
|
||||
setMcpDraft,
|
||||
setSelectedMcp,
|
||||
getMcpByName,
|
||||
createMcp,
|
||||
updateMcp,
|
||||
deleteMcp,
|
||||
} = useMcpConfigStore();
|
||||
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
|
||||
const refreshStatus = useMcpStore((state) => state.refresh);
|
||||
const connectMcp = useMcpStore((state) => state.connect);
|
||||
const disconnectMcp = useMcpStore((state) => state.disconnect);
|
||||
|
||||
const selectedServer = selectedMcpName ? getMcpByName(selectedMcpName) : null;
|
||||
const isNewServer = Boolean(mcpDraft && mcpDraft.name === selectedMcpName && !selectedServer);
|
||||
|
||||
// ── form state ──
|
||||
const [draftName, setDraftName] = React.useState('');
|
||||
const [draftScope, setDraftScope] = React.useState<McpScope>('user');
|
||||
const [mcpType, setMcpType] = React.useState<'local' | 'remote'>('local');
|
||||
const [command, setCommand] = React.useState<string[]>([]);
|
||||
const [url, setUrl] = React.useState('');
|
||||
const [envEntries, setEnvEntries] = React.useState<Array<{ key: string; value: string }>>([]);
|
||||
const [enabled, setEnabled] = React.useState(true);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const [isConnecting, setIsConnecting] = React.useState(false);
|
||||
|
||||
const initialRef = React.useRef<{
|
||||
mcpType: 'local' | 'remote'; command: string[]; url: string;
|
||||
envEntries: Array<{ key: string; value: string }>; enabled: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// 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);
|
||||
setEnabled(mcpDraft.enabled);
|
||||
initialRef.current = {
|
||||
mcpType: mcpDraft.type, command: mcpDraft.command,
|
||||
url: mcpDraft.url, envEntries: mcpDraft.environment, enabled: mcpDraft.enabled,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (selectedServer) {
|
||||
setDraftScope(selectedServer.scope === 'project' ? 'project' : 'user');
|
||||
const envArr = envRecordToArray(selectedServer.environment);
|
||||
const t = selectedServer.type;
|
||||
const cmd = t === 'local' ? ((selectedServer as { command?: string[] }).command ?? []) : [];
|
||||
const u = t === 'remote' ? ((selectedServer as { url?: string }).url ?? '') : '';
|
||||
setMcpType(t); setCommand(cmd); setUrl(u); setEnvEntries(envArr); setEnabled(selectedServer.enabled);
|
||||
initialRef.current = { mcpType: t, command: cmd, url: u, envEntries: envArr, enabled: selectedServer.enabled };
|
||||
}
|
||||
}, [selectedServer, isNewServer, mcpDraft]);
|
||||
|
||||
const isDirty = React.useMemo(() => {
|
||||
const init = initialRef.current;
|
||||
if (!init) return false;
|
||||
return (
|
||||
mcpType !== init.mcpType ||
|
||||
enabled !== init.enabled ||
|
||||
JSON.stringify(command) !== JSON.stringify(init.command) ||
|
||||
url !== init.url ||
|
||||
JSON.stringify(envEntries) !== JSON.stringify(init.envEntries)
|
||||
);
|
||||
}, [mcpType, command, url, envEntries, enabled]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const name = isNewServer ? draftName.trim() : selectedMcpName ?? '';
|
||||
if (!name) { toast.error('Name is required'); return; }
|
||||
if (isNewServer && mcpServers.some((s) => s.name === name)) {
|
||||
toast.error('A server with this name already exists'); return;
|
||||
}
|
||||
if (mcpType === 'local' && command.filter(Boolean).length === 0) {
|
||||
toast.error('Command cannot be empty for a local server'); return;
|
||||
}
|
||||
if (mcpType === 'remote' && !url.trim()) {
|
||||
toast.error('URL cannot be empty for a remote server'); return;
|
||||
}
|
||||
|
||||
const draft: McpDraft = { name, scope: draftScope, type: mcpType, command, url, environment: envEntries, enabled };
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const success = isNewServer ? await createMcp(draft) : await updateMcp(name, draft);
|
||||
if (success) {
|
||||
if (isNewServer) { setMcpDraft(null); setSelectedMcp(name); }
|
||||
toast.success(isNewServer ? 'MCP server created. OpenCode reloading…' : 'Saved. OpenCode reloading…');
|
||||
} else {
|
||||
toast.error('Failed to save');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedMcpName) return;
|
||||
setIsDeleting(true);
|
||||
const ok = await deleteMcp(selectedMcpName);
|
||||
if (ok) { toast.success(`"${selectedMcpName}" deleted`); setShowDeleteConfirm(false); }
|
||||
else toast.error('Failed to delete');
|
||||
setIsDeleting(false);
|
||||
};
|
||||
|
||||
const handleToggleConnect = async () => {
|
||||
if (!selectedMcpName) return;
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const isConnected = mcpStatus[selectedMcpName]?.status === 'connected';
|
||||
if (isConnected) {
|
||||
await disconnectMcp(selectedMcpName, currentDirectory);
|
||||
toast.success('Disconnected');
|
||||
} else {
|
||||
await connectMcp(selectedMcpName, currentDirectory);
|
||||
toast.success('Connected');
|
||||
}
|
||||
await refreshStatus({ directory: currentDirectory, silent: true });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Connection failed');
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Empty state ──
|
||||
if (!selectedMcpName) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiPlugLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">Select an MCP server from the sidebar</p>
|
||||
<p className="typography-meta mt-1 opacity-75">or add a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const runtimeStatus = mcpStatus[selectedMcpName];
|
||||
const isConnected = runtimeStatus?.status === 'connected';
|
||||
|
||||
return (
|
||||
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto max-w-2xl space-y-5 p-6">
|
||||
|
||||
{/* ── Header card: name + status + enabled + connect ── */}
|
||||
<div className="rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-4 py-3 space-y-3">
|
||||
|
||||
{/* Row 1: name + connect button */}
|
||||
<div className="flex items-center justify-between gap-3 min-w-0">
|
||||
<div className="min-w-0">
|
||||
{isNewServer ? (
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))}
|
||||
placeholder="my-mcp-server"
|
||||
className="font-mono text-base h-8 w-64"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<h1 className="typography-ui-header font-semibold truncate">{selectedMcpName}</h1>
|
||||
)}
|
||||
{isNewServer && (
|
||||
<p className="typography-micro text-muted-foreground mt-0.5">
|
||||
Lowercase, numbers, hyphens and underscores only
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isNewServer && (
|
||||
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as McpScope)}>
|
||||
<SelectTrigger className="!h-8 w-auto gap-1.5">
|
||||
{draftScope === 'user' ? (
|
||||
<RiUser3Line className="h-4 w-4" />
|
||||
) : (
|
||||
<RiFolderLine className="h-4 w-4" />
|
||||
)}
|
||||
<span className="capitalize">{draftScope}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="user" className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiUser3Line className="h-4 w-4" />
|
||||
<span>User</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">Available in all projects</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="project" className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiFolderLine className="h-4 w-4" />
|
||||
<span>Project</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">Only in current project</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{!isNewServer && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isConnected ? 'outline' : 'default'}
|
||||
onClick={handleToggleConnect}
|
||||
disabled={isConnecting || !enabled}
|
||||
className="h-7 shrink-0"
|
||||
>
|
||||
{isConnecting ? 'Working…' : isConnected ? 'Disconnect' : 'Connect'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: status + type badge + enabled toggle */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={runtimeStatus?.status} enabled={enabled} />
|
||||
<span className="typography-micro text-muted-foreground/40">·</span>
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1.5 py-0.5 rounded border border-border/50">
|
||||
{mcpType === 'local' ? 'stdio' : 'remote'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Enabled toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('typography-micro', enabled ? 'text-foreground' : 'text-muted-foreground/60')}>
|
||||
{enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
enabled ? 'bg-primary' : 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform',
|
||||
enabled ? 'translate-x-4' : 'translate-x-0',
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: type selector — always visible so user can switch type */}
|
||||
<div className="flex gap-1 pt-1 border-t border-[var(--interactive-border)]">
|
||||
<ButtonSmall
|
||||
variant={mcpType === 'local' ? 'default' : 'outline'}
|
||||
onClick={() => setMcpType('local')}
|
||||
className={cn(mcpType !== 'local' && 'text-foreground')}
|
||||
>
|
||||
Local · stdio
|
||||
</ButtonSmall>
|
||||
<ButtonSmall
|
||||
variant={mcpType === 'remote' ? 'default' : 'outline'}
|
||||
onClick={() => setMcpType('remote')}
|
||||
className={cn(mcpType !== 'remote' && 'text-foreground')}
|
||||
>
|
||||
Remote · SSE
|
||||
</ButtonSmall>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Connection ── */}
|
||||
<div className="space-y-2">
|
||||
{mcpType === 'local' ? (
|
||||
<>
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Command
|
||||
</label>
|
||||
<CommandTextarea value={command} onChange={setCommand} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Server URL
|
||||
</label>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://mcp.example.com/mcp"
|
||||
className="font-mono typography-meta"
|
||||
/>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
SSE endpoint URL of the remote MCP server
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Environment Variables ── */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Environment Variables
|
||||
{envEntries.length > 0 && (
|
||||
<span className="ml-1.5 typography-micro text-muted-foreground font-normal">
|
||||
({envEntries.length})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<EnvEditor value={envEntries} onChange={setEnvEntries} />
|
||||
</div>
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div className="flex items-center justify-between border-t border-[var(--interactive-border)] pt-4 gap-4">
|
||||
{!isNewServer ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="h-7 gap-1.5 typography-meta text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
) : <div />}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || (!isDirty && !isNewServer)}
|
||||
className="h-7 gap-1.5 typography-meta"
|
||||
>
|
||||
<RiSaveLine className="h-3.5 w-3.5" />
|
||||
{isSaving ? 'Saving…' : isNewServer ? 'Create' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirm */}
|
||||
<Dialog
|
||||
open={showDeleteConfirm}
|
||||
onOpenChange={(open) => { if (!open && !isDeleting) setShowDeleteConfirm(false); }}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete "{selectedMcpName}"?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This removes the server from <code className="text-foreground">opencode.json</code>.
|
||||
OpenCode will need to reload.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={isDeleting}
|
||||
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonLarge onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
</ButtonLarge>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { RiAddLine, RiDeleteBinLine, RiMore2Line, RiPlugLine, RiServerLine } from '@remixicon/react';
|
||||
import { useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
interface McpSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
// ---- Status dot ----
|
||||
type StatusTone = 'success' | 'error' | 'warning' | 'idle';
|
||||
|
||||
const statusToneFromMcp = (status: string | undefined): StatusTone => {
|
||||
switch (status) {
|
||||
case 'connected': return 'success';
|
||||
case 'failed': return 'error';
|
||||
case 'needs_auth':
|
||||
case 'needs_client_registration': return 'warning';
|
||||
default: return 'idle';
|
||||
}
|
||||
};
|
||||
|
||||
const StatusDot: React.FC<{ tone: StatusTone; enabled: boolean }> = ({ tone, enabled }) => {
|
||||
if (!enabled) {
|
||||
return (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-muted-foreground/30 flex-shrink-0" />
|
||||
);
|
||||
}
|
||||
const classes: Record<StatusTone, string> = {
|
||||
success: 'bg-green-500',
|
||||
error: 'bg-destructive',
|
||||
warning: 'bg-yellow-500',
|
||||
idle: 'bg-muted-foreground/40',
|
||||
};
|
||||
return (
|
||||
<span className={cn('inline-block h-2 w-2 rounded-full flex-shrink-0', classes[tone])} />
|
||||
);
|
||||
};
|
||||
|
||||
export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
|
||||
|
||||
const { mcpServers, selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
|
||||
useMcpConfigStore();
|
||||
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<McpServerConfig | null>(null);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadMcpConfigs();
|
||||
}, [loadMcpConfigs]);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
const baseName = 'new-mcp-server';
|
||||
let newName = baseName;
|
||||
let counter = 1;
|
||||
while (mcpServers.some((s) => s.name === newName)) {
|
||||
newName = `${baseName}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
const draft: McpDraft = {
|
||||
name: newName,
|
||||
scope: 'user',
|
||||
type: 'local',
|
||||
command: [],
|
||||
url: '',
|
||||
environment: [],
|
||||
enabled: true,
|
||||
};
|
||||
setMcpDraft(draft);
|
||||
setSelectedMcp(newName);
|
||||
onItemSelect?.();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setIsDeleting(true);
|
||||
const success = await deleteMcp(deleteTarget.name);
|
||||
if (success) {
|
||||
toast.success(`MCP server "${deleteTarget.name}" deleted`);
|
||||
} else {
|
||||
toast.error('Failed to delete MCP server');
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
setIsDeleting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
{/* Header */}
|
||||
<div className="border-b px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{mcpServers.length} server{mcpServers.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 -my-1 text-muted-foreground"
|
||||
onClick={handleCreateNew}
|
||||
title="Add MCP server"
|
||||
>
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
|
||||
{mcpServers.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiPlugLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">No MCP servers configured</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Use the + button above to add one</p>
|
||||
</div>
|
||||
) : (
|
||||
mcpServers.map((server) => {
|
||||
const runtimeStatus = mcpStatus[server.name];
|
||||
const tone = statusToneFromMcp(runtimeStatus?.status);
|
||||
const isSelected = selectedMcpName === server.name;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={server.name}
|
||||
className={cn(
|
||||
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
|
||||
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedMcp(server.name);
|
||||
setMcpDraft(null);
|
||||
onItemSelect?.();
|
||||
}}
|
||||
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot tone={tone} enabled={server.enabled} />
|
||||
<span className="typography-ui-label font-normal truncate text-foreground">
|
||||
{server.name}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
{server.type}
|
||||
</span>
|
||||
{!server.enabled && (
|
||||
<span className="typography-micro text-muted-foreground/60 flex-shrink-0">
|
||||
off
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground/60 truncate leading-tight pl-4">
|
||||
{server.type === 'local'
|
||||
? (server as { command?: string[] }).command?.join(' ') ?? ''
|
||||
: (server as { url?: string }).url ?? ''}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
|
||||
>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(server);
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
|
||||
{/* Delete confirm dialog */}
|
||||
<Dialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => { if (!open && !isDeleting) setDeleteTarget(null); }}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete MCP Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{deleteTarget?.name}"? This will remove it from{' '}
|
||||
<code className="text-foreground">opencode.json</code>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
disabled={isDeleting}
|
||||
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonLarge onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
</ButtonLarge>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Re-export for easy sidebar icon usage
|
||||
export { RiServerLine as McpIcon };
|
||||
@@ -17,12 +17,15 @@ import { useAgentsStore } from '@/stores/useAgentsStore';
|
||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
|
||||
import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
|
||||
import { AgentsPage } from '@/components/sections/agents/AgentsPage';
|
||||
import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar';
|
||||
import { CommandsPage } from '@/components/sections/commands/CommandsPage';
|
||||
import { SkillsSidebar } from '@/components/sections/skills/SkillsSidebar';
|
||||
import { SkillsPage } from '@/components/sections/skills/SkillsPage';
|
||||
import { McpSidebar } from '@/components/sections/mcp/McpSidebar';
|
||||
import { McpPage } from '@/components/sections/mcp/McpPage';
|
||||
import { ProvidersSidebar } from '@/components/sections/providers/ProvidersSidebar';
|
||||
import { ProvidersPage } from '@/components/sections/providers/ProvidersPage';
|
||||
import { UsageSidebar } from '@/components/sections/usage/UsageSidebar';
|
||||
@@ -190,6 +193,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
void useSkillsStore.getState().loadSkills();
|
||||
void useSkillsCatalogStore.getState().loadCatalog();
|
||||
}
|
||||
|
||||
if (activeTab === 'mcp') {
|
||||
void useMcpConfigStore.getState().loadMcpConfigs();
|
||||
}
|
||||
}, [activeProjectId, activeTab]);
|
||||
|
||||
// Update proportional width on window resize (if not manually resized)
|
||||
@@ -306,6 +313,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return <CommandsSidebar onItemSelect={handleMobileSidebarClick} />;
|
||||
case 'skills':
|
||||
return <SkillsSidebar onItemSelect={handleMobileSidebarClick} />;
|
||||
case 'mcp':
|
||||
return <McpSidebar onItemSelect={handleMobileSidebarClick} />;
|
||||
case 'providers':
|
||||
return <ProvidersSidebar onItemSelect={handleMobileSidebarClick} />;
|
||||
case 'usage':
|
||||
@@ -327,6 +336,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return <CommandsPage />;
|
||||
case 'skills':
|
||||
return <SkillsPage />;
|
||||
case 'mcp':
|
||||
return <McpPage />;
|
||||
case 'providers':
|
||||
return <ProvidersPage />;
|
||||
case 'usage':
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RiBrainAi3Line, RiChatAi3Line, RiCommandLine, RiGitBranchLine, RiSettings3Line, RiStackLine, RiBookLine, RiBarChart2Line } from '@remixicon/react';
|
||||
import { RiBrainAi3Line, RiChatAi3Line, RiCommandLine, RiGitBranchLine, RiSettings3Line, RiStackLine, RiBookLine, RiBarChart2Line, RiPlugLine } from '@remixicon/react';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'skills' | 'providers' | 'usage' | 'git-identities' | 'settings';
|
||||
export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'skills' | 'mcp' | 'providers' | 'usage' | 'git-identities' | 'settings';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type IconComponent = ComponentType<any>;
|
||||
@@ -38,6 +38,12 @@ export const SIDEBAR_SECTIONS: SidebarSectionConfig[] = [
|
||||
description: 'Create reusable instruction files for agents to load on-demand.',
|
||||
icon: RiBookLine,
|
||||
},
|
||||
{
|
||||
id: 'mcp',
|
||||
label: 'MCP',
|
||||
description: 'Manage Model Context Protocol servers and their configurations.',
|
||||
icon: RiPlugLine,
|
||||
},
|
||||
{
|
||||
id: 'providers',
|
||||
label: 'Providers',
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { getSafeStorage } from './utils/safeStorage';
|
||||
import {
|
||||
startConfigUpdate,
|
||||
finishConfigUpdate,
|
||||
} from '@/lib/configUpdate';
|
||||
import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
|
||||
export type McpScope = 'user' | 'project';
|
||||
|
||||
const getConfigDirectory = (): string | null => {
|
||||
try {
|
||||
const projectsStore = useProjectsStore.getState();
|
||||
const activeProject = projectsStore.getActiveProject?.();
|
||||
if (activeProject?.path?.trim()) {
|
||||
return activeProject.path.trim();
|
||||
}
|
||||
|
||||
const clientDir = opencodeClient.getDirectory();
|
||||
if (clientDir?.trim()) {
|
||||
return clientDir.trim();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[McpConfigStore] Error resolving config directory:', err);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// ============== TYPES ==============
|
||||
|
||||
export interface McpLocalConfig {
|
||||
type: 'local';
|
||||
command: string[];
|
||||
environment?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface McpRemoteConfig {
|
||||
type: 'remote';
|
||||
url: string;
|
||||
environment?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type McpServerConfig = (McpLocalConfig | McpRemoteConfig) & { name: string };
|
||||
export type McpServerWithScope = McpServerConfig & { scope?: McpScope | null };
|
||||
|
||||
export interface McpDraft {
|
||||
name: string;
|
||||
scope: McpScope;
|
||||
type: 'local' | 'remote';
|
||||
command: string[];
|
||||
url: string;
|
||||
environment: Array<{ key: string; value: string }>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// ============== HELPERS ==============
|
||||
|
||||
export const envRecordToArray = (env?: Record<string, string>): Array<{ key: string; value: string }> => {
|
||||
if (!env) return [];
|
||||
return Object.entries(env).map(([key, value]) => ({ key, value }));
|
||||
};
|
||||
|
||||
export const envArrayToRecord = (arr: Array<{ key: string; value: string }>): Record<string, string> | undefined => {
|
||||
const filtered = arr.filter((e) => e.key.trim());
|
||||
if (filtered.length === 0) return undefined;
|
||||
return Object.fromEntries(filtered.map((e) => [e.key.trim(), e.value]));
|
||||
};
|
||||
|
||||
const CLIENT_RELOAD_DELAY_MS = 800;
|
||||
|
||||
// ============== STORE ==============
|
||||
|
||||
interface McpConfigStore {
|
||||
mcpServers: McpServerWithScope[];
|
||||
selectedMcpName: string | null;
|
||||
isLoading: boolean;
|
||||
mcpDraft: McpDraft | null;
|
||||
|
||||
setSelectedMcp: (name: string | null) => void;
|
||||
setMcpDraft: (draft: McpDraft | null) => void;
|
||||
loadMcpConfigs: () => Promise<boolean>;
|
||||
createMcp: (config: McpDraft) => Promise<boolean>;
|
||||
updateMcp: (name: string, config: Partial<McpDraft>) => Promise<boolean>;
|
||||
deleteMcp: (name: string) => Promise<boolean>;
|
||||
getMcpByName: (name: string) => McpServerWithScope | undefined;
|
||||
}
|
||||
|
||||
export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
mcpServers: [],
|
||||
selectedMcpName: null,
|
||||
isLoading: false,
|
||||
mcpDraft: null,
|
||||
|
||||
setSelectedMcp: (name) => set({ selectedMcpName: name }),
|
||||
|
||||
setMcpDraft: (draft) => set({ mcpDraft: draft }),
|
||||
|
||||
loadMcpConfigs: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const configDirectory = getConfigDirectory();
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
const response = await fetch(`/api/config/mcp${queryParams}`, {
|
||||
headers: configDirectory ? { 'x-opencode-directory': configDirectory } : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load MCP configs');
|
||||
}
|
||||
const data: McpServerWithScope[] = await response.json();
|
||||
set({ mcpServers: data, isLoading: false });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[McpConfigStore] Failed to load MCP configs:', error);
|
||||
set({ isLoading: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
createMcp: async (config: McpDraft) => {
|
||||
startConfigUpdate('Creating MCP server configuration…');
|
||||
let requiresReload = false;
|
||||
try {
|
||||
const body = buildMcpBody(config);
|
||||
const configDirectory = getConfigDirectory();
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
const response = await fetch(`/api/config/mcp/${encodeURIComponent(config.name)}${queryParams}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || 'Failed to create MCP server');
|
||||
}
|
||||
|
||||
if (payload?.requiresReload) {
|
||||
requiresReload = true;
|
||||
await refreshAfterOpenCodeRestart({
|
||||
message: payload.message,
|
||||
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
|
||||
scopes: ['all'],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
await get().loadMcpConfigs();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[McpConfigStore] Failed to create MCP:', error);
|
||||
return false;
|
||||
} finally {
|
||||
if (!requiresReload) finishConfigUpdate();
|
||||
}
|
||||
},
|
||||
|
||||
updateMcp: async (name: string, config: Partial<McpDraft>) => {
|
||||
startConfigUpdate('Updating MCP server configuration…');
|
||||
let requiresReload = false;
|
||||
try {
|
||||
const body = buildMcpBody(config);
|
||||
const configDirectory = getConfigDirectory();
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
const response = await fetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || 'Failed to update MCP server');
|
||||
}
|
||||
|
||||
if (payload?.requiresReload) {
|
||||
requiresReload = true;
|
||||
await refreshAfterOpenCodeRestart({
|
||||
message: payload.message,
|
||||
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
|
||||
scopes: ['all'],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
await get().loadMcpConfigs();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[McpConfigStore] Failed to update MCP:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (!requiresReload) finishConfigUpdate();
|
||||
}
|
||||
},
|
||||
|
||||
deleteMcp: async (name: string) => {
|
||||
startConfigUpdate('Deleting MCP server configuration…');
|
||||
let requiresReload = false;
|
||||
try {
|
||||
const configDirectory = getConfigDirectory();
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
const response = await fetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, {
|
||||
method: 'DELETE',
|
||||
headers: configDirectory ? { 'x-opencode-directory': configDirectory } : undefined,
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || 'Failed to delete MCP server');
|
||||
}
|
||||
|
||||
if (payload?.requiresReload) {
|
||||
requiresReload = true;
|
||||
await refreshAfterOpenCodeRestart({
|
||||
message: payload.message,
|
||||
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
|
||||
scopes: ['all'],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (get().selectedMcpName === name) {
|
||||
set({ selectedMcpName: null });
|
||||
}
|
||||
await get().loadMcpConfigs();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[McpConfigStore] Failed to delete MCP:', error);
|
||||
return false;
|
||||
} finally {
|
||||
if (!requiresReload) finishConfigUpdate();
|
||||
}
|
||||
},
|
||||
|
||||
getMcpByName: (name: string) => {
|
||||
return get().mcpServers.find((s) => s.name === name);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mcp-config-store',
|
||||
storage: createJSONStorage(() => getSafeStorage()),
|
||||
partialize: (state) => ({ selectedMcpName: state.selectedMcpName }),
|
||||
},
|
||||
),
|
||||
{ name: 'mcp-config-store' },
|
||||
),
|
||||
);
|
||||
|
||||
// ============== HELPERS ==============
|
||||
|
||||
function buildMcpBody(config: Partial<McpDraft>): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {};
|
||||
|
||||
if (config.scope !== undefined) body.scope = config.scope;
|
||||
|
||||
if (config.type !== undefined) body.type = config.type;
|
||||
|
||||
if (config.type === 'local' || config.command !== undefined) {
|
||||
body.command = (config.command ?? []).filter((s) => s.trim());
|
||||
}
|
||||
|
||||
if (config.type === 'remote' || config.url !== undefined) {
|
||||
body.url = config.url?.trim() ?? '';
|
||||
}
|
||||
|
||||
if (config.environment !== undefined) {
|
||||
body.environment = envArrayToRecord(config.environment) ?? {};
|
||||
}
|
||||
|
||||
if (config.enabled !== undefined) {
|
||||
body.enabled = config.enabled;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import * as fs from 'fs';
|
||||
import { spawn, execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { type OpenCodeManager } from './opencode';
|
||||
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, type SkillSource, type DiscoveredSkill, SKILL_SCOPE, getProviderSources, removeProviderConfig } from './opencodeConfig';
|
||||
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, type SkillSource, type DiscoveredSkill, SKILL_SCOPE, getProviderSources, removeProviderConfig, listMcpConfigs, getMcpConfig, createMcpConfig, updateMcpConfig, deleteMcpConfig } from './opencodeConfig';
|
||||
import { getProviderAuth, removeProviderAuth } from './opencodeAuth';
|
||||
import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders';
|
||||
import * as gitService from './gitService';
|
||||
@@ -1970,6 +1970,84 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:config/mcp': {
|
||||
const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown>; directory?: string };
|
||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||
const mcpName = typeof name === 'string' ? name.trim() : '';
|
||||
|
||||
const workingDirectory = (typeof directory === 'string' && directory.trim())
|
||||
? directory.trim()
|
||||
: (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath);
|
||||
|
||||
if (normalizedMethod === 'GET' && !mcpName) {
|
||||
const configs = listMcpConfigs(workingDirectory);
|
||||
return { id, type, success: true, data: configs };
|
||||
}
|
||||
|
||||
if (!mcpName) {
|
||||
return { id, type, success: false, error: 'MCP server name is required' };
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'GET') {
|
||||
const config = getMcpConfig(mcpName, workingDirectory);
|
||||
if (!config) {
|
||||
return { id, type, success: false, error: `MCP server "${mcpName}" not found` };
|
||||
}
|
||||
return { id, type, success: true, data: config };
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'POST') {
|
||||
const scope = body?.scope as 'user' | 'project' | undefined;
|
||||
createMcpConfig(mcpName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${mcpName}" created. Reloading interface…`,
|
||||
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PATCH') {
|
||||
updateMcpConfig(mcpName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${mcpName}" updated. Reloading interface…`,
|
||||
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
deleteMcpConfig(mcpName, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${mcpName}" deleted. Reloading interface…`,
|
||||
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:config/skills': {
|
||||
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> };
|
||||
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
|
||||
@@ -382,9 +382,192 @@ const writeConfig = (config: Record<string, unknown>, filePath: string = CONFIG_
|
||||
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
|
||||
};
|
||||
|
||||
export type McpLocalConfig = {
|
||||
type: 'local';
|
||||
command?: string[];
|
||||
environment?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type McpRemoteConfig = {
|
||||
type: 'remote';
|
||||
url?: string;
|
||||
environment?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type McpConfigPayload = McpLocalConfig | McpRemoteConfig;
|
||||
|
||||
export type McpConfigEntry = {
|
||||
name: string;
|
||||
scope?: AgentScope | null;
|
||||
type: 'local' | 'remote';
|
||||
command?: string[];
|
||||
url?: string;
|
||||
environment?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const resolveMcpScopeFromPath = (layers: ReturnType<typeof readConfigLayers>, sourcePath?: string | null): AgentScope | null => {
|
||||
if (!sourcePath) return null;
|
||||
return sourcePath === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
|
||||
};
|
||||
|
||||
const ensureProjectMcpConfigPath = (workingDirectory: string): string => {
|
||||
const projectConfigDir = path.join(workingDirectory, '.opencode');
|
||||
if (!fs.existsSync(projectConfigDir)) {
|
||||
fs.mkdirSync(projectConfigDir, { recursive: true });
|
||||
}
|
||||
return path.join(projectConfigDir, 'opencode.json');
|
||||
};
|
||||
|
||||
const validateMcpName = (name: string): void => {
|
||||
if (!name || typeof name !== 'string') {
|
||||
throw new Error('MCP server name is required');
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$/.test(name)) {
|
||||
throw new Error('MCP server name must be lowercase alphanumeric with hyphens/underscores');
|
||||
}
|
||||
};
|
||||
|
||||
const buildMcpEntry = (data: Record<string, unknown>): Omit<McpConfigEntry, 'name'> => {
|
||||
const entry: Omit<McpConfigEntry, 'name'> = {
|
||||
type: data.type === 'remote' ? 'remote' : 'local',
|
||||
enabled: data.enabled !== false,
|
||||
};
|
||||
|
||||
if (entry.type === 'local') {
|
||||
if (Array.isArray(data.command) && data.command.length > 0) {
|
||||
entry.command = data.command.map((value) => String(value));
|
||||
}
|
||||
} else if (typeof data.url === 'string' && data.url.trim()) {
|
||||
entry.url = data.url.trim();
|
||||
}
|
||||
|
||||
if (isPlainObject(data.environment)) {
|
||||
const cleaned: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(data.environment)) {
|
||||
if (key && value != null) {
|
||||
cleaned[key] = String(value);
|
||||
}
|
||||
}
|
||||
if (Object.keys(cleaned).length > 0) {
|
||||
entry.environment = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
};
|
||||
|
||||
export const listMcpConfigs = (workingDirectory?: string): McpConfigEntry[] => {
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
const merged = (layers.mergedConfig as Record<string, unknown>) || {};
|
||||
const mcp = isPlainObject(merged.mcp) ? merged.mcp : {};
|
||||
return Object.entries(mcp)
|
||||
.filter(([, value]) => isPlainObject(value))
|
||||
.map(([name, value]) => {
|
||||
const source = getJsonEntrySource(layers, 'mcp', name);
|
||||
return {
|
||||
name,
|
||||
...buildMcpEntry(value as Record<string, unknown>),
|
||||
scope: resolveMcpScopeFromPath(layers, source.path),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const getMcpConfig = (name: string, workingDirectory?: string): McpConfigEntry | null => {
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
const merged = (layers.mergedConfig as Record<string, unknown>) || {};
|
||||
const mcp = isPlainObject(merged.mcp) ? merged.mcp : {};
|
||||
const entry = mcp[name];
|
||||
if (!isPlainObject(entry)) {
|
||||
return null;
|
||||
}
|
||||
const source = getJsonEntrySource(layers, 'mcp', name);
|
||||
return {
|
||||
name,
|
||||
...buildMcpEntry(entry as Record<string, unknown>),
|
||||
scope: resolveMcpScopeFromPath(layers, source.path),
|
||||
};
|
||||
};
|
||||
|
||||
export const createMcpConfig = (
|
||||
name: string,
|
||||
mcpConfig: Record<string, unknown>,
|
||||
workingDirectory?: string,
|
||||
scope?: AgentScope,
|
||||
): void => {
|
||||
validateMcpName(name);
|
||||
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
const source = getJsonEntrySource(layers, 'mcp', name);
|
||||
if (source.exists) {
|
||||
throw new Error(`MCP server "${name}" already exists`);
|
||||
}
|
||||
|
||||
let targetPath = CONFIG_FILE;
|
||||
let config: Record<string, unknown> = {};
|
||||
|
||||
if (scope === AGENT_SCOPE.PROJECT) {
|
||||
if (!workingDirectory) {
|
||||
throw new Error('Project scope requires working directory');
|
||||
}
|
||||
targetPath = ensureProjectMcpConfigPath(workingDirectory);
|
||||
config = readConfigFile(targetPath);
|
||||
} else {
|
||||
const jsonTarget = getJsonWriteTarget(layers, AGENT_SCOPE.USER);
|
||||
targetPath = jsonTarget.path || CONFIG_FILE;
|
||||
config = (jsonTarget.config || {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const mcp = isPlainObject(config.mcp) ? { ...config.mcp } : {};
|
||||
|
||||
const { name: _ignoredName, ...entryData } = mcpConfig;
|
||||
void _ignoredName;
|
||||
mcp[name] = buildMcpEntry(entryData);
|
||||
config.mcp = mcp;
|
||||
writeConfig(config, targetPath);
|
||||
};
|
||||
|
||||
export const updateMcpConfig = (name: string, updates: Record<string, unknown>, workingDirectory?: string): void => {
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
const source = getJsonEntrySource(layers, 'mcp', name);
|
||||
const targetPath = source.path || CONFIG_FILE;
|
||||
const config = (source.config || readConfigFile(targetPath)) as Record<string, unknown>;
|
||||
const mcp = isPlainObject(config.mcp) ? { ...config.mcp } : {};
|
||||
const existing = isPlainObject(mcp[name]) ? mcp[name] : {};
|
||||
|
||||
const { name: _ignoredName, ...updateData } = updates;
|
||||
void _ignoredName;
|
||||
mcp[name] = buildMcpEntry({ ...(existing as Record<string, unknown>), ...updateData });
|
||||
config.mcp = mcp;
|
||||
writeConfig(config, targetPath);
|
||||
};
|
||||
|
||||
export const deleteMcpConfig = (name: string, workingDirectory?: string): void => {
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
const source = getJsonEntrySource(layers, 'mcp', name);
|
||||
const targetPath = source.path || CONFIG_FILE;
|
||||
const config = (source.config || readConfigFile(targetPath)) as Record<string, unknown>;
|
||||
const mcp = isPlainObject(config.mcp) ? { ...config.mcp } : {};
|
||||
|
||||
if (mcp[name] === undefined) {
|
||||
throw new Error(`MCP server "${name}" not found`);
|
||||
}
|
||||
|
||||
delete mcp[name];
|
||||
if (Object.keys(mcp).length === 0) {
|
||||
delete config.mcp;
|
||||
} else {
|
||||
config.mcp = mcp;
|
||||
}
|
||||
|
||||
writeConfig(config, targetPath);
|
||||
};
|
||||
|
||||
const getJsonEntrySource = (
|
||||
layers: ReturnType<typeof readConfigLayers>,
|
||||
sectionKey: 'agent' | 'command',
|
||||
sectionKey: 'agent' | 'command' | 'mcp',
|
||||
entryName: string
|
||||
) => {
|
||||
const { userConfig, projectConfig, customConfig, paths } = layers;
|
||||
|
||||
@@ -556,6 +556,74 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/config/mcp') {
|
||||
const verb = ((init?.method || 'GET') as string).toUpperCase();
|
||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||
const queryDirectory = url.searchParams.get('directory') || undefined;
|
||||
const headerDirectory = (() => {
|
||||
const headers = init?.headers;
|
||||
if (!headers) return undefined;
|
||||
if (headers instanceof Headers) {
|
||||
return headers.get('x-opencode-directory') || undefined;
|
||||
}
|
||||
if (Array.isArray(headers)) {
|
||||
const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory');
|
||||
return found?.[1] || undefined;
|
||||
}
|
||||
if (typeof headers === 'object') {
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
const directory = queryDirectory || headerDirectory;
|
||||
try {
|
||||
const data = await sendBridgeMessage('api:config/mcp', { method: verb, body, directory });
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/config/mcp/')) {
|
||||
const encodedName = pathname.slice('/api/config/mcp/'.length);
|
||||
const name = decodeURIComponent(encodedName);
|
||||
const verb = ((init?.method || 'GET') as string).toUpperCase();
|
||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||
const queryDirectory = url.searchParams.get('directory') || undefined;
|
||||
const headerDirectory = (() => {
|
||||
const headers = init?.headers;
|
||||
if (!headers) return undefined;
|
||||
if (headers instanceof Headers) {
|
||||
return headers.get('x-opencode-directory') || undefined;
|
||||
}
|
||||
if (Array.isArray(headers)) {
|
||||
const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory');
|
||||
return found?.[1] || undefined;
|
||||
}
|
||||
if (typeof headers === 'object') {
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
const directory = queryDirectory || headerDirectory;
|
||||
try {
|
||||
const data = await sendBridgeMessage('api:config/mcp', { method: verb, name, body, directory });
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
// Skills file operations: /api/config/skills/:name/files/:filePath
|
||||
const skillsFilesMatch = pathname.match(/^\/api\/config\/skills\/([^/]+)\/files\/(.+)$/);
|
||||
if (skillsFilesMatch) {
|
||||
|
||||
@@ -5710,6 +5710,7 @@ async function main(options = {}) {
|
||||
if (
|
||||
req.path.startsWith('/api/config/agents') ||
|
||||
req.path.startsWith('/api/config/commands') ||
|
||||
req.path.startsWith('/api/config/mcp') ||
|
||||
req.path.startsWith('/api/config/settings') ||
|
||||
req.path.startsWith('/api/config/skills') ||
|
||||
req.path.startsWith('/api/fs') ||
|
||||
@@ -6771,7 +6772,12 @@ async function main(options = {}) {
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
AGENT_SCOPE,
|
||||
COMMAND_SCOPE
|
||||
COMMAND_SCOPE,
|
||||
listMcpConfigs,
|
||||
getMcpConfig,
|
||||
createMcpConfig,
|
||||
updateMcpConfig,
|
||||
deleteMcpConfig,
|
||||
} = await import('./lib/opencode/index.js');
|
||||
|
||||
app.get('/api/config/agents/:name', async (req, res) => {
|
||||
@@ -6899,6 +6905,116 @@ async function main(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// MCP Config Routes
|
||||
// ============================================================
|
||||
|
||||
app.get('/api/config/mcp', async (req, res) => {
|
||||
try {
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
const configs = listMcpConfigs(directory);
|
||||
res.json(configs);
|
||||
} catch (error) {
|
||||
console.error('[API:GET /api/config/mcp] Failed:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to list MCP configs' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/config/mcp/:name', async (req, res) => {
|
||||
try {
|
||||
const name = req.params.name;
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
const config = getMcpConfig(name, directory);
|
||||
if (!config) {
|
||||
return res.status(404).json({ error: `MCP server "${name}" not found` });
|
||||
}
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
console.error('[API:GET /api/config/mcp/:name] Failed:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get MCP config' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/config/mcp/:name', async (req, res) => {
|
||||
try {
|
||||
const name = req.params.name;
|
||||
const { scope, ...config } = req.body || {};
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
console.log(`[API:POST /api/config/mcp] Creating MCP server: ${name}`);
|
||||
|
||||
createMcpConfig(name, config, directory, scope);
|
||||
await refreshOpenCodeAfterConfigChange('mcp creation', { mcpName: name });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${name}" created. Reloading interface…`,
|
||||
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API:POST /api/config/mcp/:name] Failed:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to create MCP server' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/config/mcp/:name', async (req, res) => {
|
||||
try {
|
||||
const name = req.params.name;
|
||||
const updates = req.body;
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
console.log(`[API:PATCH /api/config/mcp] Updating MCP server: ${name}`);
|
||||
|
||||
updateMcpConfig(name, updates, directory);
|
||||
await refreshOpenCodeAfterConfigChange('mcp update');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${name}" updated. Reloading interface…`,
|
||||
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API:PATCH /api/config/mcp/:name] Failed:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to update MCP server' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/config/mcp/:name', async (req, res) => {
|
||||
try {
|
||||
const name = req.params.name;
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
console.log(`[API:DELETE /api/config/mcp] Deleting MCP server: ${name}`);
|
||||
|
||||
deleteMcpConfig(name, directory);
|
||||
await refreshOpenCodeAfterConfigChange('mcp deletion');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${name}" deleted. Reloading interface…`,
|
||||
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API:DELETE /api/config/mcp/:name] Failed:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to delete MCP server' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/config/commands/:name', async (req, res) => {
|
||||
try {
|
||||
const commandName = req.params.name;
|
||||
|
||||
@@ -56,3 +56,11 @@ export {
|
||||
} from './auth.js';
|
||||
|
||||
export { createUiAuth } from './ui-auth.js';
|
||||
|
||||
export {
|
||||
listMcpConfigs,
|
||||
getMcpConfig,
|
||||
createMcpConfig,
|
||||
updateMcpConfig,
|
||||
deleteMcpConfig,
|
||||
} from './mcp.js';
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
CONFIG_FILE,
|
||||
AGENT_SCOPE,
|
||||
readConfigFile,
|
||||
readConfigLayers,
|
||||
getJsonEntrySource,
|
||||
getJsonWriteTarget,
|
||||
writeConfig,
|
||||
} from './shared.js';
|
||||
|
||||
// ============== MCP CONFIG HELPERS ==============
|
||||
|
||||
/**
|
||||
* Validate MCP server name
|
||||
*/
|
||||
function validateMcpName(name) {
|
||||
if (!name || typeof name !== 'string') {
|
||||
throw new Error('MCP server name is required');
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$/.test(name)) {
|
||||
throw new Error('MCP server name must be lowercase alphanumeric with hyphens/underscores');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all MCP server configs from user-level opencode.json
|
||||
*/
|
||||
function resolveMcpScopeFromPath(layers, sourcePath) {
|
||||
if (!sourcePath) return null;
|
||||
return sourcePath === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
|
||||
}
|
||||
|
||||
function ensureProjectMcpConfigPath(workingDirectory) {
|
||||
const configDir = path.join(workingDirectory, '.opencode');
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
return path.join(configDir, 'opencode.json');
|
||||
}
|
||||
|
||||
function listMcpConfigs(workingDirectory) {
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
const mcp = layers?.mergedConfig?.mcp || {};
|
||||
|
||||
return Object.entries(mcp)
|
||||
.filter(([, entry]) => entry && typeof entry === 'object' && !Array.isArray(entry))
|
||||
.map(([name, entry]) => {
|
||||
const source = getJsonEntrySource(layers, 'mcp', name);
|
||||
return {
|
||||
name,
|
||||
...buildMcpEntry(entry),
|
||||
scope: resolveMcpScopeFromPath(layers, source.path),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single MCP server config by name
|
||||
*/
|
||||
function getMcpConfig(name, workingDirectory) {
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
const entry = layers?.mergedConfig?.mcp?.[name];
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const source = getJsonEntrySource(layers, 'mcp', name);
|
||||
return {
|
||||
name,
|
||||
...buildMcpEntry(entry),
|
||||
scope: resolveMcpScopeFromPath(layers, source.path),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new MCP server config entry
|
||||
*/
|
||||
function createMcpConfig(name, mcpConfig, workingDirectory, scope) {
|
||||
validateMcpName(name);
|
||||
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
const source = getJsonEntrySource(layers, 'mcp', name);
|
||||
if (source.exists) {
|
||||
throw new Error(`MCP server "${name}" already exists`);
|
||||
}
|
||||
|
||||
let targetPath = CONFIG_FILE;
|
||||
let config = {};
|
||||
|
||||
if (scope === AGENT_SCOPE.PROJECT) {
|
||||
if (!workingDirectory) {
|
||||
throw new Error('Project scope requires working directory');
|
||||
}
|
||||
targetPath = ensureProjectMcpConfigPath(workingDirectory);
|
||||
config = fs.existsSync(targetPath) ? readConfigFile(targetPath) : {};
|
||||
} else {
|
||||
const jsonTarget = getJsonWriteTarget(layers, AGENT_SCOPE.USER);
|
||||
targetPath = jsonTarget.path || CONFIG_FILE;
|
||||
config = jsonTarget.config || {};
|
||||
}
|
||||
|
||||
if (!config.mcp || typeof config.mcp !== 'object' || Array.isArray(config.mcp)) {
|
||||
config.mcp = {};
|
||||
}
|
||||
|
||||
const { name: _ignoredName, ...entryData } = mcpConfig;
|
||||
config.mcp[name] = buildMcpEntry(entryData);
|
||||
|
||||
writeConfig(config, targetPath);
|
||||
console.log(`Created MCP server config: ${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing MCP server config entry
|
||||
*/
|
||||
function updateMcpConfig(name, updates, workingDirectory) {
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
const source = getJsonEntrySource(layers, 'mcp', name);
|
||||
const targetPath = source.path || CONFIG_FILE;
|
||||
const config = source.config || (fs.existsSync(targetPath) ? readConfigFile(targetPath) : {});
|
||||
|
||||
if (!config.mcp || typeof config.mcp !== 'object' || Array.isArray(config.mcp)) {
|
||||
config.mcp = {};
|
||||
}
|
||||
|
||||
const existing = config.mcp[name] ?? {};
|
||||
const { name: _ignoredName, ...updateData } = updates;
|
||||
|
||||
config.mcp[name] = buildMcpEntry({ ...existing, ...updateData });
|
||||
|
||||
writeConfig(config, targetPath);
|
||||
console.log(`Updated MCP server config: ${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an MCP server config entry
|
||||
*/
|
||||
function deleteMcpConfig(name, workingDirectory) {
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
const source = getJsonEntrySource(layers, 'mcp', name);
|
||||
const targetPath = source.path || CONFIG_FILE;
|
||||
const config = source.config || (fs.existsSync(targetPath) ? readConfigFile(targetPath) : {});
|
||||
|
||||
if (!config.mcp || typeof config.mcp !== 'object' || config.mcp[name] === undefined) {
|
||||
throw new Error(`MCP server "${name}" not found`);
|
||||
}
|
||||
|
||||
delete config.mcp[name];
|
||||
|
||||
if (Object.keys(config.mcp).length === 0) {
|
||||
delete config.mcp;
|
||||
}
|
||||
|
||||
writeConfig(config, targetPath);
|
||||
console.log(`Deleted MCP server config: ${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a clean MCP entry object, omitting undefined/null values
|
||||
*/
|
||||
function buildMcpEntry(data) {
|
||||
const entry = {};
|
||||
|
||||
// type is required
|
||||
entry.type = data.type === 'remote' ? 'remote' : 'local';
|
||||
|
||||
if (entry.type === 'local') {
|
||||
// command must be a non-empty array of strings
|
||||
if (Array.isArray(data.command) && data.command.length > 0) {
|
||||
entry.command = data.command.map(String);
|
||||
}
|
||||
} else {
|
||||
// remote: url required
|
||||
if (data.url && typeof data.url === 'string') {
|
||||
entry.url = data.url.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// environment: flat Record<string, string>
|
||||
if (data.environment && typeof data.environment === 'object' && !Array.isArray(data.environment)) {
|
||||
const cleaned = {};
|
||||
for (const [k, v] of Object.entries(data.environment)) {
|
||||
if (k && v !== undefined && v !== null) {
|
||||
cleaned[k] = String(v);
|
||||
}
|
||||
}
|
||||
if (Object.keys(cleaned).length > 0) {
|
||||
entry.environment = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
// enabled defaults to true
|
||||
entry.enabled = data.enabled !== false;
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
export {
|
||||
listMcpConfigs,
|
||||
getMcpConfig,
|
||||
createMcpConfig,
|
||||
updateMcpConfig,
|
||||
deleteMcpConfig,
|
||||
};
|
||||
Reference in New Issue
Block a user