diff --git a/desktop/src/__tests__/mcpSettings.test.tsx b/desktop/src/__tests__/mcpSettings.test.tsx new file mode 100644 index 00000000..d65f5f55 --- /dev/null +++ b/desktop/src/__tests__/mcpSettings.test.tsx @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { McpSettings } from '../pages/McpSettings' +import { useMcpStore } from '../stores/mcpStore' +import { useSessionStore } from '../stores/sessionStore' +import { useSettingsStore } from '../stores/settingsStore' + +describe('McpSettings', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + useSessionStore.setState({ + sessions: [ + { + id: 'session-1', + title: 'Test Session', + createdAt: '', + modifiedAt: '', + messageCount: 0, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }, + ], + activeSessionId: 'session-1', + isLoading: false, + error: null, + selectedProjects: [], + availableProjects: [], + fetchSessions: vi.fn(), + createSession: vi.fn(), + deleteSession: vi.fn(), + renameSession: vi.fn(), + updateSessionTitle: vi.fn(), + setActiveSession: vi.fn(), + setSelectedProjects: vi.fn(), + }) + useMcpStore.setState({ + servers: [], + isLoading: false, + error: null, + fetchServers: vi.fn(), + createServer: vi.fn(), + updateServer: vi.fn(), + deleteServer: vi.fn(), + toggleServer: vi.fn(), + reconnectServer: vi.fn(), + }) + }) + + it('loads only global MCP servers on mount', () => { + const fetchServers = vi.fn() + useMcpStore.setState({ fetchServers }) + + render() + + expect(fetchServers).toHaveBeenCalledWith(undefined, undefined) + }) + + it('renders the empty state and add button', () => { + render() + + expect(screen.getByText('MCP servers')).toBeInTheDocument() + expect(screen.getByText('No MCP servers configured yet')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /add server/i })).toBeInTheDocument() + }) + + it('shows only global MCP servers on the homepage', () => { + useMcpStore.setState({ + servers: [ + { + name: 'project-private', + scope: 'local', + projectPath: '/workspace/project', + transport: 'stdio', + enabled: true, + status: 'connected', + statusLabel: 'Connected', + configLocation: '/tmp/config', + summary: 'npx demo', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + config: { type: 'stdio', command: 'npx', args: ['demo'], env: {} }, + }, + { + name: 'global-user', + scope: 'user', + transport: 'http', + enabled: true, + status: 'connected', + statusLabel: 'Connected', + configLocation: '/tmp/config', + summary: 'https://example.com/mcp', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + config: { type: 'http', url: 'https://example.com/mcp', headers: {} }, + }, + ], + }) + + render() + + expect(screen.queryByText('project-private')).not.toBeInTheDocument() + expect(screen.getByText('global-user')).toBeInTheDocument() + expect(screen.getByText('This page manages only user-global MCP servers for speed. Project-specific MCP will move into the chat slash-command experience.')).toBeInTheDocument() + }) +}) diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index ee04748b..644fdae4 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -3,6 +3,8 @@ import { fireEvent, render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import { skillsApi } from '../api/skills' +import { mcpApi } from '../api/mcp' +import { useUIStore } from '../stores/uiStore' vi.mock('../api/skills', () => ({ skillsApi: { @@ -10,6 +12,12 @@ vi.mock('../api/skills', () => ({ }, })) +vi.mock('../api/mcp', () => ({ + mcpApi: { + list: vi.fn(async () => ({ servers: [] })), + }, +})) + // Import all pages import { EmptySession } from '../pages/EmptySession' import { ActiveSession } from '../pages/ActiveSession' @@ -201,6 +209,85 @@ describe('Content-only pages render without errors', () => { useChatStore.setState({ sessions: {} }) }) + it('ActiveSession opens a local /mcp panel and clicking an item routes to settings', async () => { + const SESSION_ID = 'mcp-panel-session' + const sendMessage = vi.fn() + vi.mocked(mcpApi.list).mockResolvedValueOnce({ + servers: [ + { + name: 'deepwiki', + scope: 'user', + transport: 'http', + enabled: true, + status: 'connected', + statusLabel: 'Connected', + configLocation: '/tmp/config', + summary: 'https://mcp.deepwiki.com/mcp', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + config: { type: 'http', url: 'https://mcp.deepwiki.com/mcp', headers: {} }, + }, + ], + }) + useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID }) + useSessionStore.setState({ + sessions: [{ + id: SESSION_ID, + title: 'Test', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 0, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: SESSION_ID, + isLoading: false, + error: null, + }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [{ name: 'mcp', description: 'List available MCP tools' }], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + sendMessage, + }) + + render() + + const textarea = screen.getByPlaceholderText('Ask anything...') + fireEvent.change(textarea, { target: { value: '/mcp', selectionStart: 4 } }) + fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }) + + expect(sendMessage).not.toHaveBeenCalled() + expect(await screen.findByText('Available MCP tools')).toBeInTheDocument() + fireEvent.click(screen.getByText('deepwiki')) + expect(useTabStore.getState().activeTabId).toBe('__settings__') + expect(useUIStore.getState().pendingSettingsTab).toBe('mcp') + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + }) + it('AgentTeams renders team strip and members', () => { const { container } = render() expect(container.innerHTML).toContain('Architect') diff --git a/desktop/src/api/mcp.ts b/desktop/src/api/mcp.ts new file mode 100644 index 00000000..550c9b67 --- /dev/null +++ b/desktop/src/api/mcp.ts @@ -0,0 +1,39 @@ +import { api } from './client' +import type { McpServerRecord, McpUpsertPayload } from '../types/mcp' + +export const mcpApi = { + list: (cwd?: string) => { + const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' + return api.get<{ servers: McpServerRecord[] }>(`/api/mcp${query}`) + }, + + create: (name: string, payload: McpUpsertPayload, cwd?: string) => { + return api.post<{ server: McpServerRecord }>('/api/mcp', { + name, + ...payload, + ...(cwd ? { cwd } : {}), + }) + }, + + update: (name: string, payload: McpUpsertPayload, cwd?: string) => { + return api.put<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}`, { + ...payload, + ...(cwd ? { cwd } : {}), + }) + }, + + remove: (name: string, scope: string, cwd?: string) => { + const query = new URLSearchParams({ scope }) + if (cwd) query.set('cwd', cwd) + return api.delete<{ ok: true }>(`/api/mcp/${encodeURIComponent(name)}?${query.toString()}`) + }, + + toggle: (name: string, cwd?: string) => { + return api.post<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/toggle`, cwd ? { cwd } : {}) + }, + + reconnect: (name: string, cwd?: string) => { + return api.post<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/reconnect`, cwd ? { cwd } : {}) + }, +} + diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index b9cff77a..2b3a17c1 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -12,6 +12,7 @@ import { AttachmentGallery } from './AttachmentGallery' import { ProjectContextChip } from '../shared/ProjectContextChip' import { DirectoryPicker } from '../shared/DirectoryPicker' import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu' +import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel' import { FALLBACK_SLASH_COMMANDS, findSlashTrigger, @@ -41,6 +42,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const [plusMenuOpen, setPlusMenuOpen] = useState(false) const [slashMenuOpen, setSlashMenuOpen] = useState(false) const [fileSearchOpen, setFileSearchOpen] = useState(false) + const [localSlashPanel, setLocalSlashPanel] = useState(null) const [atFilter, setAtFilter] = useState('') const [atCursorPos, setAtCursorPos] = useState(-1) const [slashFilter, setSlashFilter] = useState('') @@ -126,6 +128,22 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { return () => document.removeEventListener('mousedown', handleClick) }, [slashMenuOpen]) + useEffect(() => { + if (!localSlashPanel) return + const handleClick = (event: MouseEvent) => { + if ( + slashMenuRef.current && + !slashMenuRef.current.contains(event.target as Node) && + textareaRef.current && + !textareaRef.current.contains(event.target as Node) + ) { + setLocalSlashPanel(null) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [localSlashPanel]) + useEffect(() => { if (!fileSearchOpen) return const handleClick = (event: MouseEvent) => { @@ -153,13 +171,20 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { )) }, [slashCommands, slashFilter]) + const exactSlashCommand = useMemo(() => { + const normalized = slashFilter.trim().toLowerCase() + if (!normalized) return null + return filteredCommands.find((command) => command.name.toLowerCase() === normalized) ?? null + }, [filteredCommands, slashFilter]) + useEffect(() => { setSlashSelectedIndex(0) }, [slashFilter]) useEffect(() => { - if (slashMenuOpen && slashItemRefs.current[slashSelectedIndex]) { - slashItemRefs.current[slashSelectedIndex]?.scrollIntoView({ block: 'nearest' }) + const activeItem = slashMenuOpen ? slashItemRefs.current[slashSelectedIndex] : null + if (activeItem && typeof activeItem.scrollIntoView === 'function') { + activeItem.scrollIntoView({ block: 'nearest' }) } }, [slashMenuOpen, slashSelectedIndex]) @@ -238,6 +263,15 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const text = input.trim() if ((!text && (!attachments.length || isMemberSession)) || isWorkspaceMissing) return + if (!isMemberSession && (text === '/mcp' || text === '/skills' || text === '/plugins')) { + setLocalSlashPanel(text.slice(1) as LocalSlashCommandName) + setInput('') + setSlashMenuOpen(false) + setFileSearchOpen(false) + setPlusMenuOpen(false) + return + } + const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ type: attachment.type, name: attachment.name, @@ -251,6 +285,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { setPlusMenuOpen(false) setSlashMenuOpen(false) setFileSearchOpen(false) + setLocalSlashPanel(null) } const handleKeyDown = (event: React.KeyboardEvent) => { @@ -286,7 +321,18 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { setSlashSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length) return } - if (event.key === 'Enter' || event.key === 'Tab') { + if (event.key === 'Enter') { + if (exactSlashCommand && slashFilter.trim().toLowerCase() === exactSlashCommand.name.toLowerCase()) { + event.preventDefault() + handleSubmit() + return + } + event.preventDefault() + const selected = filteredCommands[slashSelectedIndex] + if (selected) selectSlashCommand(selected.name) + return + } + if (event.key === 'Tab') { event.preventDefault() const selected = filteredCommands[slashSelectedIndex] if (selected) selectSlashCommand(selected.name) @@ -443,6 +489,16 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { /> )} + {!isMemberSession && localSlashPanel && ( +
+ setLocalSlashPanel(null)} + /> +
+ )} + {!isMemberSession && slashMenuOpen && filteredCommands.length > 0 && (
void +} + +function toneForStatus(status: McpServerRecord['status']) { + switch (status) { + case 'connected': + return 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' + case 'needs-auth': + return 'bg-amber-500/10 text-amber-600 border-amber-500/20' + case 'failed': + return 'bg-rose-500/10 text-rose-600 border-rose-500/20' + case 'disabled': + return 'bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] border-[var(--color-border)]' + default: + return '' + } +} + +function scopeLabel(scope: string, t: ReturnType) { + switch (scope) { + case 'user': + return t('settings.mcp.scope.user') + case 'local': + return t('settings.mcp.scope.local') + case 'project': + return t('settings.mcp.scope.project') + default: + return scope + } +} + +function projectBadge(path?: string, t?: ReturnType) { + if (!path || !t) return null + const label = path.replace(/\/$/, '').split('/').pop() || path + return t('slash.mcp.projectBadge', { name: label }) +} + +function PanelShell({ + title, + subtitle, + children, + onClose, +}: { + title: string + subtitle: string + children: React.ReactNode + onClose: () => void +}) { + return ( +
+
+
+

{title}

+

{subtitle}

+
+ +
+
{children}
+
+ ) +} + +function LoadingState({ label }: { label: string }) { + return ( +
+
+ {label} +
+ ) +} + +function EmptyState({ title, body }: { title: string; body: string }) { + return ( +
+
{title}
+
{body}
+
+ ) +} + +function ErrorState({ message }: { message: string }) { + return ( +
+ {message} +
+ ) +} + +function McpPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) { + const t = useTranslation() + const setPendingSettingsTab = useUIStore((s) => s.setPendingSettingsTab) + const selectServer = useMcpStore((s) => s.selectServer) + const [servers, setServers] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + mcpApi.list(cwd) + .then((response) => { + if (cancelled) return + setServers(response.servers.filter((server) => server.scope === 'user' || server.scope === 'local' || server.scope === 'project')) + }) + .catch((err) => { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } + }, [cwd]) + + const grouped = useMemo(() => { + const groups = new Map() + for (const server of servers ?? []) { + const key = server.scope + const existing = groups.get(key) ?? [] + existing.push(server) + groups.set(key, existing) + } + return groups + }, [servers]) + + return ( + + {error ? ( + + ) : servers === null ? ( + + ) : servers.length === 0 ? ( + + ) : ( +
+ {['user', 'local', 'project'].filter((scope) => grouped.has(scope)).map((scope) => ( +
+
+
{scopeLabel(scope, t)}
+
{grouped.get(scope)?.length ?? 0}
+
+
+ {grouped.get(scope)?.map((server) => ( + + ))} +
+
+ ))} +
+ )} +
+ ) +} + +function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) { + const t = useTranslation() + const setPendingSettingsTab = useUIStore((s) => s.setPendingSettingsTab) + const fetchSkillDetail = useSkillStore((s) => s.fetchSkillDetail) + const [skills, setSkills] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + skillsApi.list(cwd) + .then((response) => { + if (cancelled) return + setSkills(response.skills.filter((skill) => skill.userInvocable)) + }) + .catch((err) => { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } + }, [cwd]) + + return ( + + {error ? ( + + ) : skills === null ? ( + + ) : skills.length === 0 ? ( + + ) : ( +
+ {skills.map((skill) => ( + + ))} +
+ )} +
+ ) +} + +function PluginsPanel({ onClose }: { onClose: () => void }) { + const t = useTranslation() + return ( + + + + ) +} + +export function LocalSlashCommandPanel({ command, cwd, onClose }: Props) { + if (command === 'mcp') return + if (command === 'skills') return + return +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 63e738e3..be784743 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -51,6 +51,7 @@ export const en = { 'settings.tab.permissions': 'Permissions', 'settings.tab.general': 'General', 'settings.tab.skills': 'Skills', + 'settings.tab.mcp': 'MCP', // Settings > Claude Official Login 'settings.claudeOfficialLogin.intro': 'Using official Claude models requires signing in to your Claude.ai account. Click the button below to open the official Claude login page in your browser; you\'ll be returned here after authorizing.', @@ -163,6 +164,89 @@ export const en = { 'settings.adapters.platform.telegram': 'Telegram', 'settings.adapters.platform.feishu': 'Feishu', + // Settings > MCP + 'settings.mcp.title': 'MCP servers', + 'settings.mcp.description': 'Connect external tools and data sources for Claude Code. Add, edit, reconnect, and disable servers without leaving the desktop app.', + 'settings.mcp.addServer': 'Add server', + 'settings.mcp.empty': 'No MCP servers configured yet', + 'settings.mcp.emptyHint': 'Add a custom stdio, HTTP, or SSE MCP server to start extending tool access.', + 'settings.mcp.stats.total': 'Total servers', + 'settings.mcp.stats.connected': 'Connected now', + 'settings.mcp.stats.attention': 'Need attention', + 'settings.mcp.scope.project': 'Project', + 'settings.mcp.scope.local': 'Local', + 'settings.mcp.scope.user': 'User', + 'settings.mcp.scopeDesc.local': 'Private to you for one project. Stored in your user config, scoped by the selected project.', + 'settings.mcp.scopeDesc.project': 'Shared with the team through the selected project’s `.mcp.json`.', + 'settings.mcp.scopeDesc.user': 'Available in all projects for your user account.', + 'settings.mcp.scope.dynamic': 'Built-in', + 'settings.mcp.scope.enterprise': 'Enterprise', + 'settings.mcp.scope.claudeai': 'Claude.ai', + 'settings.mcp.scope.managed': 'Managed', + 'settings.mcp.transport.http': 'Streamable HTTP', + 'settings.mcp.globalOnlyHint': 'This page manages only user-global MCP servers for speed. Project-specific MCP will move into the chat slash-command experience.', + 'settings.mcp.form.back': 'Back to servers', + 'settings.mcp.form.createTitle': 'Connect to a custom MCP', + 'settings.mcp.form.createHint': 'Set up a custom MCP server with the fields supported by Claude Code today.', + 'settings.mcp.form.editTitle': 'Update {name} MCP', + 'settings.mcp.form.editHint': 'Review connection details, save changes, or reconnect this server from the desktop app.', + 'settings.mcp.form.transportLocked': 'If you would like to switch MCP server type, uninstall first.', + 'settings.mcp.form.uninstall': 'Uninstall', + 'settings.mcp.form.reconnect': 'Reconnect', + 'settings.mcp.form.save': 'Save', + 'settings.mcp.form.name': 'Name', + 'settings.mcp.form.namePlaceholder': 'MCP server name', + 'settings.mcp.form.scope': 'Config scope', + 'settings.mcp.form.transport': 'Transport', + 'settings.mcp.form.status': 'Status', + 'settings.mcp.form.location': 'Config location', + 'settings.mcp.form.rawConfig': 'Raw config', + 'settings.mcp.form.command': 'Command to launch', + 'settings.mcp.form.commandPlaceholder': 'npx', + 'settings.mcp.form.arguments': 'Arguments', + 'settings.mcp.form.argumentPlaceholder': 'chrome-devtools-mcp@latest', + 'settings.mcp.form.addArgument': 'Add argument', + 'settings.mcp.form.environmentVariables': 'Environment variables', + 'settings.mcp.form.addEnv': 'Add environment variable', + 'settings.mcp.form.keyPlaceholder': 'Key', + 'settings.mcp.form.valuePlaceholder': 'Value', + 'settings.mcp.form.url': 'URL', + 'settings.mcp.form.sseUrl': 'SSE URL', + 'settings.mcp.form.urlPlaceholder': 'https://mcp.example.com/mcp', + 'settings.mcp.form.headers': 'Headers', + 'settings.mcp.form.addHeader': 'Add header', + 'settings.mcp.form.oauthClientId': 'OAuth client ID', + 'settings.mcp.form.oauthClientIdPlaceholder': 'Optional client ID', + 'settings.mcp.form.oauthCallbackPort': 'OAuth callback port', + 'settings.mcp.form.oauthCallbackPortPlaceholder': 'Optional fixed callback port', + 'settings.mcp.form.headersHelper': 'Headers helper script', + 'settings.mcp.form.headersHelperPlaceholder': 'Optional helper command path', + 'settings.mcp.form.deleteTitle': 'Delete MCP server', + 'settings.mcp.form.cancel': 'Cancel', + 'settings.mcp.form.confirmDelete': 'Delete', + 'settings.mcp.form.deleteConfirm': 'Delete MCP server "{name}"? This cannot be undone.', + 'settings.mcp.form.deleteConfirmBody': 'Delete MCP server "{name}"? This action cannot be undone.', + 'settings.mcp.targetProject.title': 'Target project', + 'settings.mcp.targetProject.selected': 'Currently viewing local and shared MCP servers for: {path}', + 'settings.mcp.targetProject.empty': 'Choose a project to inspect project-private or project-shared MCP servers.', + 'settings.mcp.targetProject.emptyWithCurrent': 'Choose a target project. The current active session is: {path}', + 'settings.mcp.targetProject.localSelected': 'This server will be private to you inside: {path}', + 'settings.mcp.targetProject.localEmpty': 'Choose the project that should receive this private MCP server.', + 'settings.mcp.targetProject.projectSelected': 'This server will be shared through: {path}/.mcp.json', + 'settings.mcp.targetProject.projectEmpty': 'Choose the project whose `.mcp.json` should receive this shared MCP server.', + 'settings.mcp.targetProject.globalTitle': 'Global install target', + 'settings.mcp.targetProject.globalHint': 'User scope does not need a target project. It is stored in your global Claude config and is available everywhere.', + 'settings.mcp.toast.created': 'Created MCP server "{name}"', + 'settings.mcp.toast.saved': 'Saved MCP server "{name}"', + 'settings.mcp.toast.deleted': 'Deleted MCP server "{name}"', + 'settings.mcp.toast.enabled': 'Enabled MCP server "{name}"', + 'settings.mcp.toast.disabled': 'Disabled MCP server "{name}"', + 'settings.mcp.toast.reconnected': 'Reconnected MCP server "{name}"', + 'settings.mcp.toast.saveFailed': 'Failed to save MCP server', + 'settings.mcp.toast.deleteFailed': 'Failed to delete MCP server', + 'settings.mcp.toast.toggleFailed': 'Failed to update MCP server state', + 'settings.mcp.toast.reconnectFailed': 'Failed to reconnect MCP server', + // Settings > Agents 'settings.tab.agents': 'Agents', 'settings.agents.title': 'Installed Agents', @@ -320,6 +404,21 @@ export const en = { 'chat.placeholderMissing': 'This session points to a missing workspace. Create a new session or pick another project.', 'chat.addFiles': 'Add files or photos', 'chat.slashCommands': 'Slash commands', + 'slash.mcp.title': 'Available MCP tools', + 'slash.mcp.subtitle': 'Global and current-project MCP servers available in this chat context.', + 'slash.mcp.subtitleWithProject': 'Showing global plus project MCP for: {path}', + 'slash.mcp.emptyTitle': 'No MCP servers available', + 'slash.mcp.emptyBody': 'No global or project MCP servers are available for this chat context.', + 'slash.mcp.projectBadge': 'Project: {name}', + 'slash.skills.title': 'Available skills', + 'slash.skills.subtitle': 'User-invocable skills available in the current context.', + 'slash.skills.subtitleWithProject': 'Showing skills for: {path}', + 'slash.skills.emptyTitle': 'No skills available', + 'slash.skills.emptyBody': 'No user-invocable skills were found for this context.', + 'slash.plugins.title': 'Plugins', + 'slash.plugins.subtitle': 'Plugin management will move into this slash-command surface.', + 'slash.plugins.emptyTitle': 'Plugins panel coming soon', + 'slash.plugins.emptyBody': 'This shared slash-command panel is wired up now; plugin data will be connected next.', 'chat.navigate': 'navigate', 'chat.select': 'select', 'chat.dismiss': 'dismiss', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 6e353804..b5ff11ba 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -53,6 +53,7 @@ export const zh: Record = { 'settings.tab.permissions': '权限', 'settings.tab.general': '通用', 'settings.tab.skills': '技能', + 'settings.tab.mcp': 'MCP', // Settings > Claude Official Login 'settings.claudeOfficialLogin.intro': '使用官方 Claude 模型需要登录你的 Claude.ai 账号。点击下方按钮,浏览器会打开 Claude 官方登录页面,授权后自动回到这里。', @@ -165,6 +166,89 @@ export const zh: Record = { 'settings.adapters.platform.telegram': 'Telegram', 'settings.adapters.platform.feishu': '飞书', + // Settings > MCP + 'settings.mcp.title': 'MCP 服务', + 'settings.mcp.description': '在桌面端直接管理外部工具与数据源。你可以添加、编辑、重连或禁用 MCP 服务。', + 'settings.mcp.addServer': '添加服务', + 'settings.mcp.empty': '还没有配置 MCP 服务', + 'settings.mcp.emptyHint': '先添加一个自定义的 STDIO、HTTP 或 SSE MCP 服务。', + 'settings.mcp.stats.total': '服务总数', + 'settings.mcp.stats.connected': '当前已连接', + 'settings.mcp.stats.attention': '需要处理', + 'settings.mcp.scope.project': '项目共享', + 'settings.mcp.scope.local': '项目私有', + 'settings.mcp.scope.user': '全局用户', + 'settings.mcp.scopeDesc.local': '只对你自己生效,但绑定到某一个项目。配置写在用户配置里,同时带上选中的项目上下文。', + 'settings.mcp.scopeDesc.project': '写入选中项目的 `.mcp.json`,项目成员共享。', + 'settings.mcp.scopeDesc.user': '写入你的全局 Claude 配置,对所有项目生效。', + 'settings.mcp.scope.dynamic': '内置', + 'settings.mcp.scope.enterprise': '企业托管', + 'settings.mcp.scope.claudeai': 'Claude.ai', + 'settings.mcp.scope.managed': '受管控', + 'settings.mcp.transport.http': 'Streamable HTTP', + 'settings.mcp.globalOnlyHint': '这个页面只管理全局用户 MCP,以保证速度和清晰度。项目级 MCP 将放到聊天页的斜杠命令体验里。', + 'settings.mcp.form.back': '返回服务列表', + 'settings.mcp.form.createTitle': '连接自定义 MCP', + 'settings.mcp.form.createHint': '按当前 Claude Code 支持的字段添加一个自定义 MCP 服务。', + 'settings.mcp.form.editTitle': '更新 {name} MCP', + 'settings.mcp.form.editHint': '查看连接信息、保存修改,或直接在桌面端重连这个服务。', + 'settings.mcp.form.transportLocked': '如果你想切换 MCP 服务类型,请先卸载后重新添加。', + 'settings.mcp.form.uninstall': '卸载', + 'settings.mcp.form.reconnect': '重连', + 'settings.mcp.form.save': '保存', + 'settings.mcp.form.name': '名称', + 'settings.mcp.form.namePlaceholder': 'MCP 服务名称', + 'settings.mcp.form.scope': '配置范围', + 'settings.mcp.form.transport': '传输方式', + 'settings.mcp.form.status': '状态', + 'settings.mcp.form.location': '配置位置', + 'settings.mcp.form.rawConfig': '原始配置', + 'settings.mcp.form.command': '启动命令', + 'settings.mcp.form.commandPlaceholder': 'npx', + 'settings.mcp.form.arguments': '参数', + 'settings.mcp.form.argumentPlaceholder': 'chrome-devtools-mcp@latest', + 'settings.mcp.form.addArgument': '添加参数', + 'settings.mcp.form.environmentVariables': '环境变量', + 'settings.mcp.form.addEnv': '添加环境变量', + 'settings.mcp.form.keyPlaceholder': '键', + 'settings.mcp.form.valuePlaceholder': '值', + 'settings.mcp.form.url': 'URL', + 'settings.mcp.form.sseUrl': 'SSE 地址', + 'settings.mcp.form.urlPlaceholder': 'https://mcp.example.com/mcp', + 'settings.mcp.form.headers': '请求头', + 'settings.mcp.form.addHeader': '添加请求头', + 'settings.mcp.form.oauthClientId': 'OAuth Client ID', + 'settings.mcp.form.oauthClientIdPlaceholder': '可选 Client ID', + 'settings.mcp.form.oauthCallbackPort': 'OAuth 回调端口', + 'settings.mcp.form.oauthCallbackPortPlaceholder': '可选固定回调端口', + 'settings.mcp.form.headersHelper': 'Headers Helper 脚本', + 'settings.mcp.form.headersHelperPlaceholder': '可选的辅助脚本路径', + 'settings.mcp.form.deleteTitle': '删除 MCP 服务', + 'settings.mcp.form.cancel': '取消', + 'settings.mcp.form.confirmDelete': '删除', + 'settings.mcp.form.deleteConfirm': '确定删除 MCP 服务 “{name}” 吗?此操作不可撤销。', + 'settings.mcp.form.deleteConfirmBody': '确定删除 MCP 服务 “{name}” 吗?此操作不可撤销。', + 'settings.mcp.targetProject.title': '目标项目', + 'settings.mcp.targetProject.selected': '当前展示的是这个项目下的私有/共享 MCP:{path}', + 'settings.mcp.targetProject.empty': '先选择一个项目,才能查看项目私有或项目共享的 MCP。', + 'settings.mcp.targetProject.emptyWithCurrent': '请先选择目标项目。当前激活会话的项目是:{path}', + 'settings.mcp.targetProject.localSelected': '这个 MCP 会作为“项目私有”安装到:{path}', + 'settings.mcp.targetProject.localEmpty': '请选择要安装这个私有 MCP 的项目。', + 'settings.mcp.targetProject.projectSelected': '这个 MCP 会写入:{path}/.mcp.json,作为项目共享配置。', + 'settings.mcp.targetProject.projectEmpty': '请选择要写入 `.mcp.json` 的项目。', + 'settings.mcp.targetProject.globalTitle': '全局安装目标', + 'settings.mcp.targetProject.globalHint': '全局用户不需要选择项目。它会写入你的全局 Claude 配置,并对所有项目生效。', + 'settings.mcp.toast.created': '已创建 MCP 服务 “{name}”', + 'settings.mcp.toast.saved': '已保存 MCP 服务 “{name}”', + 'settings.mcp.toast.deleted': '已删除 MCP 服务 “{name}”', + 'settings.mcp.toast.enabled': '已启用 MCP 服务 “{name}”', + 'settings.mcp.toast.disabled': '已禁用 MCP 服务 “{name}”', + 'settings.mcp.toast.reconnected': '已重连 MCP 服务 “{name}”', + 'settings.mcp.toast.saveFailed': '保存 MCP 服务失败', + 'settings.mcp.toast.deleteFailed': '删除 MCP 服务失败', + 'settings.mcp.toast.toggleFailed': '更新 MCP 服务状态失败', + 'settings.mcp.toast.reconnectFailed': '重连 MCP 服务失败', + // Settings > Agents 'settings.tab.agents': 'Agents', 'settings.agents.title': '已安装的 Agents', @@ -322,6 +406,21 @@ export const zh: Record = { 'chat.placeholderMissing': '此会话指向的工作目录缺失。请新建会话或选择其他项目。', 'chat.addFiles': '添加文件或图片', 'chat.slashCommands': '斜杠命令', + 'slash.mcp.title': '可用 MCP 工具', + 'slash.mcp.subtitle': '展示当前聊天上下文里的全局 MCP 和当前项目 MCP。', + 'slash.mcp.subtitleWithProject': '当前展示全局 MCP 以及这个项目的 MCP:{path}', + 'slash.mcp.emptyTitle': '没有可用的 MCP 服务', + 'slash.mcp.emptyBody': '当前聊天上下文里没有可用的全局或项目 MCP 服务。', + 'slash.mcp.projectBadge': '项目:{name}', + 'slash.skills.title': '可用技能', + 'slash.skills.subtitle': '展示当前上下文里可直接调用的技能。', + 'slash.skills.subtitleWithProject': '当前展示项目下的技能:{path}', + 'slash.skills.emptyTitle': '没有可用技能', + 'slash.skills.emptyBody': '当前上下文里没有找到可直接调用的技能。', + 'slash.plugins.title': '插件', + 'slash.plugins.subtitle': '插件管理后续会接到这个斜杠命令面板里。', + 'slash.plugins.emptyTitle': '插件面板即将接入', + 'slash.plugins.emptyBody': '这套共享的斜杠命令面板已经接通,插件数据下一步接进来。', 'chat.navigate': '导航', 'chat.select': '选择', 'chat.dismiss': '关闭', diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index cb1ec6ee..77bad635 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -10,6 +10,7 @@ import { PermissionModeSelector } from '../components/controls/PermissionModeSel import { ModelSelector } from '../components/controls/ModelSelector' import { AttachmentGallery } from '../components/chat/AttachmentGallery' import { FileSearchMenu, type FileSearchMenuHandle } from '../components/chat/FileSearchMenu' +import { LocalSlashCommandPanel, type LocalSlashCommandName } from '../components/chat/LocalSlashCommandPanel' import { FALLBACK_SLASH_COMMANDS, findSlashToken, @@ -38,6 +39,7 @@ export function EmptySession() { const [plusMenuOpen, setPlusMenuOpen] = useState(false) const [slashMenuOpen, setSlashMenuOpen] = useState(false) const [fileSearchOpen, setFileSearchOpen] = useState(false) + const [localSlashPanel, setLocalSlashPanel] = useState(null) const [atFilter, setAtFilter] = useState('') const [atCursorPos, setAtCursorPos] = useState(-1) const [slashFilter, setSlashFilter] = useState('') @@ -86,6 +88,22 @@ export function EmptySession() { return () => document.removeEventListener('mousedown', handleClick) }, [slashMenuOpen]) + useEffect(() => { + if (!localSlashPanel) return + const handleClick = (event: MouseEvent) => { + if ( + slashMenuRef.current && + !slashMenuRef.current.contains(event.target as Node) && + textareaRef.current && + !textareaRef.current.contains(event.target as Node) + ) { + setLocalSlashPanel(null) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [localSlashPanel]) + useEffect(() => { if (!fileSearchOpen) return const handleClick = (event: MouseEvent) => { @@ -139,13 +157,19 @@ export function EmptySession() { )) }, [slashCommands, slashFilter]) + const exactSlashCommand = useMemo(() => { + const normalized = slashFilter.trim().toLowerCase() + if (!normalized) return null + return filteredCommands.find((command) => command.name.toLowerCase() === normalized) ?? null + }, [filteredCommands, slashFilter]) + useEffect(() => { setSlashSelectedIndex(0) }, [slashFilter]) useEffect(() => { const activeItem = slashMenuOpen ? slashItemRefs.current[slashSelectedIndex] : null - if (typeof activeItem?.scrollIntoView === 'function') { + if (activeItem && typeof activeItem.scrollIntoView === 'function') { activeItem.scrollIntoView({ block: 'nearest' }) } }, [slashMenuOpen, slashSelectedIndex]) @@ -154,6 +178,15 @@ export function EmptySession() { const text = input.trim() if ((!text && attachments.length === 0) || isSubmitting) return + if (text === '/mcp' || text === '/skills' || text === '/plugins') { + setLocalSlashPanel(text.slice(1) as LocalSlashCommandName) + setInput('') + setSlashMenuOpen(false) + setFileSearchOpen(false) + setPlusMenuOpen(false) + return + } + setIsSubmitting(true) try { const sessionId = await createSession(workDir || undefined) @@ -250,6 +283,15 @@ export function EmptySession() { return } if (event.key === 'Enter' || event.key === 'Tab') { + if ( + event.key === 'Enter' && + exactSlashCommand && + slashFilter.trim().toLowerCase() === exactSlashCommand.name.toLowerCase() + ) { + event.preventDefault() + void handleSubmit() + return + } event.preventDefault() const selected = filteredCommands[slashSelectedIndex] if (selected) selectSlashCommand(selected.name) @@ -413,6 +455,16 @@ export function EmptySession() { /> )} + {localSlashPanel && ( +
+ setLocalSlashPanel(null)} + /> +
+ )} + {slashMenuOpen && filteredCommands.length > 0 && (
= { + connected: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20', + 'needs-auth': 'bg-amber-500/10 text-amber-600 border-amber-500/20', + failed: 'bg-rose-500/10 text-rose-600 border-rose-500/20', + disabled: 'bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] border-[var(--color-border)]', +} + +function createId() { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID() + return `${Date.now()}-${Math.random().toString(16).slice(2)}` +} + +function createStringRow(value = ''): StringRow { + return { id: createId(), value } +} + +function createKeyValueRow(key = '', value = ''): KeyValueRow { + return { id: createId(), key, value } +} + +function createEmptyDraft(): McpDraft { + return { + name: '', + transport: 'stdio', + command: '', + args: [createStringRow('')], + env: [createKeyValueRow()], + url: '', + headers: [createKeyValueRow()], + headersHelper: '', + oauthClientId: '', + oauthCallbackPort: '', + } +} + +function isStdioConfig(config: McpServerRecord['config']): config is Extract { + return config.type === 'stdio' +} + +function isRemoteConfig(config: McpServerRecord['config']): config is Extract { + return config.type === 'http' || config.type === 'sse' +} + +function draftFromServer(server: McpServerRecord): McpDraft { + const base = createEmptyDraft() + base.name = server.name + + if (isStdioConfig(server.config)) { + return { + ...base, + transport: 'stdio', + command: server.config.command, + args: (server.config.args.length ? server.config.args : ['']).map((value) => createStringRow(value)), + env: Object.entries(server.config.env ?? {}).map(([key, value]) => createKeyValueRow(key, value)).concat( + Object.keys(server.config.env ?? {}).length === 0 ? [createKeyValueRow()] : [], + ), + } + } + + if (isRemoteConfig(server.config)) { + return { + ...base, + transport: server.config.type, + url: server.config.url, + headers: Object.entries(server.config.headers ?? {}).map(([key, value]) => createKeyValueRow(key, value)).concat( + Object.keys(server.config.headers ?? {}).length === 0 ? [createKeyValueRow()] : [], + ), + headersHelper: server.config.headersHelper ?? '', + oauthClientId: server.config.oauth?.clientId ?? '', + oauthCallbackPort: server.config.oauth?.callbackPort ? String(server.config.oauth.callbackPort) : '', + } + } + + return base +} + +function rowsToRecord(rows: KeyValueRow[]) { + const entries: Array<[string, string]> = [] + for (const row of rows) { + const key = row.key.trim() + if (!key) continue + entries.push([key, row.value]) + } + return Object.fromEntries(entries) +} + +function rowsToList(rows: StringRow[]) { + return rows.map((row) => row.value.trim()).filter(Boolean) +} + +function buildPayload(draft: McpDraft): McpUpsertPayload { + if (draft.transport === 'stdio') { + return { + scope: 'user', + config: { + type: 'stdio', + command: draft.command.trim(), + args: rowsToList(draft.args), + env: rowsToRecord(draft.env), + }, + } + } + + const oauthCallbackPort = draft.oauthCallbackPort.trim() + const callbackPortNumber = oauthCallbackPort ? Number(oauthCallbackPort) : undefined + const oauthClientId = draft.oauthClientId.trim() + + return { + scope: 'user', + config: { + type: draft.transport, + url: draft.url.trim(), + headers: rowsToRecord(draft.headers), + ...(draft.headersHelper.trim() ? { headersHelper: draft.headersHelper.trim() } : {}), + ...(oauthClientId || callbackPortNumber + ? { + oauth: { + ...(oauthClientId ? { clientId: oauthClientId } : {}), + ...(callbackPortNumber ? { callbackPort: callbackPortNumber } : {}), + }, + } + : {}), + }, + } +} + +function isDraftValid(draft: McpDraft) { + if (!draft.name.trim()) return false + if (draft.transport === 'stdio') return draft.command.trim().length > 0 + return draft.url.trim().length > 0 +} + +function transportLabel(transport: string, t: ReturnType) { + switch (transport) { + case 'stdio': + return 'STDIO' + case 'http': + return t('settings.mcp.transport.http') + case 'sse': + return 'SSE' + default: + return transport + } +} + +function ToggleSwitch({ + checked, + disabled, + onChange, +}: { + checked: boolean + disabled?: boolean + onChange: () => void +}) { + return ( + + ) +} + +function ArraySection({ + title, + rows, + onChange, + onAdd, + onRemove, + keyPlaceholder, + valuePlaceholder, + singleValue = false, + addLabel, +}: { + title: string + rows: KeyValueRow[] | StringRow[] + onChange: (id: string, field: 'key' | 'value', value: string) => void + onAdd: () => void + onRemove: (id: string) => void + keyPlaceholder?: string + valuePlaceholder: string + singleValue?: boolean + addLabel: string +}) { + return ( +
+
{title}
+
+ {rows.map((row) => ( +
+ {!singleValue && 'key' in row && ( + onChange(row.id, 'key', event.target.value)} + placeholder={keyPlaceholder} + /> + )} + onChange(row.id, 'value', event.target.value)} + placeholder={valuePlaceholder} + /> + +
+ ))} + +
+
+ ) +} + +function StatCard({ label, value, icon }: { label: string; value: number; icon: string }) { + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ) +} + +function ServerRow({ + server, + isBusy, + onOpen, + onToggle, + t, +}: { + server: McpServerRecord + isBusy: boolean + onOpen: () => void + onToggle: () => void + t: ReturnType +}) { + return ( +
+
+
+
{server.name}
+ + {server.statusLabel} + +
+
+ + {transportLabel(server.transport, t)} + + + {t('settings.mcp.scope.user')} + + {server.summary} +
+ {server.statusDetail && ( +
{server.statusDetail}
+ )} +
+ + + + +
+ ) +} + +export function McpSettings() { + const { servers, selectedServer, isLoading, error, fetchServers, createServer, updateServer, deleteServer, toggleServer, reconnectServer, selectServer } = useMcpStore() + const addToast = useUIStore((s) => s.addToast) + const t = useTranslation() + const [view, setView] = useState({ type: 'list' }) + const [draft, setDraft] = useState(createEmptyDraft) + const [isSaving, setIsSaving] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const [busyServerName, setBusyServerName] = useState(null) + const [pendingDeleteServer, setPendingDeleteServer] = useState(null) + + useEffect(() => { + void fetchServers(undefined, undefined) + }, [fetchServers]) + + const globalServers = useMemo( + () => servers.filter((server) => server.scope === 'user'), + [servers], + ) + + const stats = useMemo(() => ({ + total: globalServers.length, + connected: globalServers.filter((server) => server.status === 'connected').length, + attention: globalServers.filter((server) => server.status === 'failed' || server.status === 'needs-auth').length, + }), [globalServers]) + + const beginCreate = () => { + setDraft(createEmptyDraft()) + setView({ type: 'create' }) + } + + const beginEdit = (server: McpServerRecord) => { + selectServer(server) + if (!server.canEdit) { + setView({ type: 'details', server }) + return + } + setDraft(draftFromServer(server)) + setView({ type: 'edit', server }) + } + + useEffect(() => { + if (!selectedServer) return + if (selectedServer.canEdit) { + setDraft(draftFromServer(selectedServer)) + setView({ type: 'edit', server: selectedServer }) + } else { + setView({ type: 'details', server: selectedServer }) + } + }, [selectedServer]) + + const handleToggle = async (server: McpServerRecord) => { + setBusyServerName(server.name) + try { + const updated = await toggleServer(server.name, undefined) + addToast({ + type: 'success', + message: updated.enabled ? t('settings.mcp.toast.enabled', { name: server.name }) : t('settings.mcp.toast.disabled', { name: server.name }), + }) + } catch (error) { + addToast({ + type: 'error', + message: error instanceof Error ? error.message : t('settings.mcp.toast.toggleFailed'), + }) + } finally { + setBusyServerName(null) + } + } + + const handleReconnect = async (server: McpServerRecord) => { + setBusyServerName(server.name) + try { + const updated = await reconnectServer(server.name, undefined) + addToast({ + type: updated.status === 'connected' ? 'success' : 'warning', + message: updated.status === 'connected' + ? t('settings.mcp.toast.reconnected', { name: server.name }) + : updated.statusDetail || updated.statusLabel, + }) + if (view.type === 'edit') setView({ type: 'edit', server: updated }) + if (view.type === 'details') setView({ type: 'details', server: updated }) + } catch (error) { + addToast({ + type: 'error', + message: error instanceof Error ? error.message : t('settings.mcp.toast.reconnectFailed'), + }) + } finally { + setBusyServerName(null) + } + } + + const handleDelete = (server: McpServerRecord) => { + setPendingDeleteServer(server) + } + + const confirmDelete = async () => { + const server = pendingDeleteServer + if (!server) return + setIsDeleting(true) + try { + await deleteServer(server.name, server.scope, undefined) + addToast({ + type: 'success', + message: t('settings.mcp.toast.deleted', { name: server.name }), + }) + setView({ type: 'list' }) + selectServer(null) + setPendingDeleteServer(null) + } catch (error) { + addToast({ + type: 'error', + message: error instanceof Error ? error.message : t('settings.mcp.toast.deleteFailed'), + }) + } finally { + setIsDeleting(false) + } + } + + const handleSave = async () => { + if (!isDraftValid(draft)) return + setIsSaving(true) + try { + const payload = buildPayload(draft) + const saved = view.type === 'edit' + ? await updateServer(view.server.name, payload, undefined) + : await createServer(draft.name.trim(), payload, undefined) + + addToast({ + type: 'success', + message: view.type === 'edit' + ? t('settings.mcp.toast.saved', { name: saved.name }) + : t('settings.mcp.toast.created', { name: saved.name }), + }) + setView({ type: 'list' }) + selectServer(null) + } catch (error) { + addToast({ + type: 'error', + message: error instanceof Error ? error.message : t('settings.mcp.toast.saveFailed'), + }) + } finally { + setIsSaving(false) + } + } + + const setDraftField = (key: K, value: McpDraft[K]) => { + setDraft((current) => ({ ...current, [key]: value })) + } + + const updateStringRows = (key: 'args', id: string, value: string) => { + setDraft((current) => ({ + ...current, + [key]: current[key].map((row) => (row.id === id ? { ...row, value } : row)), + })) + } + + const updateKeyValueRows = (key: 'env' | 'headers', id: string, field: 'key' | 'value', value: string) => { + setDraft((current) => ({ + ...current, + [key]: current[key].map((row) => (row.id === id ? { ...row, [field]: value } : row)), + })) + } + + const addRow = (key: 'args' | 'env' | 'headers') => { + setDraft((current) => ({ + ...current, + [key]: [...current[key], key === 'args' ? createStringRow() : createKeyValueRow()], + })) + } + + const removeRow = (key: 'args' | 'env' | 'headers', id: string) => { + setDraft((current) => { + const next = current[key].filter((row) => row.id !== id) + return { + ...current, + [key]: next.length > 0 ? next : [key === 'args' ? createStringRow() : createKeyValueRow()], + } + }) + } + + if (view.type === 'details') { + const server = view.server + return ( +
+ + +
+
+

{server.name}

+

{server.summary}

+
+ {server.canReconnect && ( + + )} +
+ +
+
+ + + + +
+
+
{t('settings.mcp.form.rawConfig')}
+
+              {JSON.stringify(server.config, null, 2)}
+            
+
+
+
+ ) + } + + if (view.type === 'create' || view.type === 'edit') { + const editing = view.type === 'edit' + const targetServer = editing ? view.server : null + const transportLocked = editing + const isBusy = isSaving || isDeleting + + return ( +
+ + +
+
+

+ {editing ? t('settings.mcp.form.editTitle', { name: targetServer!.name }) : t('settings.mcp.form.createTitle')} +

+

+ {editing ? t('settings.mcp.form.editHint') : t('settings.mcp.form.createHint')} +

+
+ +
+ {editing && targetServer?.canReconnect && ( + + )} + {editing && targetServer?.canRemove && ( + + )} +
+
+ +
+
+ setDraftField('name', event.target.value)} + placeholder={t('settings.mcp.form.namePlaceholder')} + disabled={editing} + required + /> +
+ +
+
+ {t('settings.mcp.form.scope')} +
+

+ {t('settings.mcp.globalOnlyHint')} +

+
+ +
+
+ {(['stdio', 'http', 'sse'] as TransportKind[]).map((transport) => { + const active = draft.transport === transport + return ( + + ) + })} +
+
+ + {editing && ( +
+ {t('settings.mcp.form.transportLocked')} +
+ )} + + {draft.transport === 'stdio' ? ( + <> +
+ setDraftField('command', event.target.value)} + placeholder={t('settings.mcp.form.commandPlaceholder')} + required + /> +
+ + updateStringRows('args', id, value)} + onAdd={() => addRow('args')} + onRemove={(id) => removeRow('args', id)} + singleValue + valuePlaceholder={t('settings.mcp.form.argumentPlaceholder')} + addLabel={t('settings.mcp.form.addArgument')} + /> + + updateKeyValueRows('env', id, field, value)} + onAdd={() => addRow('env')} + onRemove={(id) => removeRow('env', id)} + keyPlaceholder={t('settings.mcp.form.keyPlaceholder')} + valuePlaceholder={t('settings.mcp.form.valuePlaceholder')} + addLabel={t('settings.mcp.form.addEnv')} + /> + + ) : ( + <> +
+ setDraftField('url', event.target.value)} + placeholder={t('settings.mcp.form.urlPlaceholder')} + required + /> +
+ + updateKeyValueRows('headers', id, field, value)} + onAdd={() => addRow('headers')} + onRemove={(id) => removeRow('headers', id)} + keyPlaceholder={t('settings.mcp.form.keyPlaceholder')} + valuePlaceholder={t('settings.mcp.form.valuePlaceholder')} + addLabel={t('settings.mcp.form.addHeader')} + /> + +
+
+ setDraftField('oauthClientId', event.target.value)} + placeholder={t('settings.mcp.form.oauthClientIdPlaceholder')} + /> + setDraftField('oauthCallbackPort', event.target.value)} + placeholder={t('settings.mcp.form.oauthCallbackPortPlaceholder')} + /> +
+
+ setDraftField('headersHelper', event.target.value)} + placeholder={t('settings.mcp.form.headersHelperPlaceholder')} + /> +
+
+ + )} + +
+ +
+
+
+ ) + } + + return ( +
+
+
+

+ {t('settings.mcp.title')} +

+

+ {t('settings.mcp.description')} +

+

+ {t('settings.mcp.globalOnlyHint')} +

+
+ +
+ +
+ + + +
+ + {isLoading && globalServers.length === 0 ? ( +
+
+
+ ) : error ? ( +
+ error +

{error}

+ +
+ ) : globalServers.length === 0 ? ( +
+ dns +

{t('settings.mcp.empty')}

+

{t('settings.mcp.emptyHint')}

+
+ ) : ( +
+
+
+ {t('settings.mcp.scope.user')} +
+
{globalServers.length}
+
+
+ {globalServers.map((server) => ( + beginEdit(server)} + onToggle={() => void handleToggle(server)} + t={t} + /> + ))} +
+
+ )} + + { + if (isDeleting) return + setPendingDeleteServer(null) + }} + title={t('settings.mcp.form.deleteTitle')} + footer={( + <> + + + + )} + > +

+ {pendingDeleteServer ? t('settings.mcp.form.deleteConfirmBody', { name: pendingDeleteServer.name }) : ''} +

+
+
+ ) +} + +function InfoPair({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index e53d0235..bbc81a43 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -19,6 +19,7 @@ import { useSkillStore } from '../stores/skillStore' import { SkillList } from '../components/skills/SkillList' import { SkillDetail } from '../components/skills/SkillDetail' import { ComputerUseSettings } from './ComputerUseSettings' +import { McpSettings } from './McpSettings' import { useUIStore, type SettingsTab } from '../stores/uiStore' import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin' import { useUpdateStore } from '../stores/updateStore' @@ -45,6 +46,7 @@ export function Settings() { setActiveTab('permissions')} /> setActiveTab('general')} /> setActiveTab('adapters')} /> + setActiveTab('mcp')} /> setActiveTab('agents')} /> setActiveTab('skills')} /> setActiveTab('computerUse')} /> @@ -60,6 +62,7 @@ export function Settings() { {activeTab === 'permissions' && } {activeTab === 'general' && } {activeTab === 'adapters' && } + {activeTab === 'mcp' && } {activeTab === 'agents' && } {activeTab === 'skills' && } {activeTab === 'computerUse' && } diff --git a/desktop/src/stores/mcpStore.ts b/desktop/src/stores/mcpStore.ts new file mode 100644 index 00000000..18770e42 --- /dev/null +++ b/desktop/src/stores/mcpStore.ts @@ -0,0 +1,125 @@ +import { create } from 'zustand' +import { mcpApi } from '../api/mcp' +import type { McpServerRecord, McpUpsertPayload } from '../types/mcp' + +type McpStore = { + servers: McpServerRecord[] + selectedServer: McpServerRecord | null + isLoading: boolean + error: string | null + fetchServers: (projectPaths?: string[], fallbackCwd?: string) => Promise + createServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise + updateServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise + deleteServer: (name: string, scope: string, cwd?: string) => Promise + toggleServer: (name: string, cwd?: string) => Promise + reconnectServer: (name: string, cwd?: string) => Promise + selectServer: (server: McpServerRecord | null) => void +} + +function upsertByName(servers: McpServerRecord[], server: McpServerRecord) { + const index = servers.findIndex((item) => item.name === server.name) + if (index === -1) return [...servers, server] + return servers.map((item, itemIndex) => (itemIndex === index ? server : item)) +} + +export const useMcpStore = create((set) => ({ + servers: [], + selectedServer: null, + isLoading: false, + error: null, + + fetchServers: async (projectPaths, fallbackCwd) => { + set({ isLoading: true, error: null }) + try { + const normalizedPaths = Array.from(new Set((projectPaths ?? []).filter(Boolean))) + const contexts = normalizedPaths.length > 0 ? normalizedPaths : [fallbackCwd].filter(Boolean) + + const responses = await Promise.all( + (contexts.length > 0 ? contexts : [undefined]).map(async (cwd) => { + const response = await mcpApi.list(cwd) + return response.servers.map((server) => ({ + ...server, + projectPath: server.scope === 'local' || server.scope === 'project' ? cwd : undefined, + })) + }), + ) + + const deduped = new Map() + for (const group of responses) { + for (const server of group) { + const key = + server.scope === 'local' || server.scope === 'project' + ? `${server.scope}:${server.projectPath}:${server.name}` + : `${server.scope}:${server.name}` + if (!deduped.has(key)) { + deduped.set(key, server) + } + } + } + + set({ servers: [...deduped.values()], isLoading: false }) + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to load MCP servers', + }) + } + }, + + createServer: async (name, payload, cwd) => { + const response = await mcpApi.create(name, payload, cwd) + set((state) => ({ + servers: [...state.servers, response.server], + selectedServer: response.server, + error: null, + })) + return response.server + }, + + updateServer: async (name, payload, cwd) => { + const response = await mcpApi.update(name, payload, cwd) + set((state) => ({ + servers: upsertByName( + state.servers.filter((server) => server.name !== name), + response.server, + ), + selectedServer: response.server, + error: null, + })) + return response.server + }, + + deleteServer: async (name, scope, cwd) => { + await mcpApi.remove(name, scope, cwd) + set((state) => ({ + servers: state.servers.filter((server) => !(server.name === name && server.scope === scope && (server.projectPath ?? '') === (cwd ?? ''))), + selectedServer: + state.selectedServer?.name === name && state.selectedServer?.scope === scope + ? null + : state.selectedServer, + error: null, + })) + }, + + toggleServer: async (name, cwd) => { + const response = await mcpApi.toggle(name, cwd) + set((state) => ({ + servers: upsertByName(state.servers, response.server), + selectedServer: state.selectedServer?.name === name ? response.server : state.selectedServer, + error: null, + })) + return response.server + }, + + reconnectServer: async (name, cwd) => { + const response = await mcpApi.reconnect(name, cwd) + set((state) => ({ + servers: upsertByName(state.servers, response.server), + selectedServer: state.selectedServer?.name === name ? response.server : state.selectedServer, + error: null, + })) + return response.server + }, + + selectServer: (server) => set({ selectedServer: server }), +})) diff --git a/desktop/src/stores/uiStore.ts b/desktop/src/stores/uiStore.ts index 27903371..17b00750 100644 --- a/desktop/src/stores/uiStore.ts +++ b/desktop/src/stores/uiStore.ts @@ -28,7 +28,7 @@ export type Toast = { duration?: number } -export type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'agents' | 'skills' | 'computerUse' | 'about' +export type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'mcp' | 'agents' | 'skills' | 'computerUse' | 'about' type ActiveView = 'code' | 'scheduled' | 'terminal' | 'history' | 'settings' diff --git a/desktop/src/types/mcp.ts b/desktop/src/types/mcp.ts new file mode 100644 index 00000000..cba6b4ff --- /dev/null +++ b/desktop/src/types/mcp.ts @@ -0,0 +1,43 @@ +export type McpEditableConfig = + | { + type: 'stdio' + command: string + args: string[] + env: Record + } + | { + type: 'http' | 'sse' + url: string + headers: Record + headersHelper?: string + oauth?: { + clientId?: string + callbackPort?: number + } + } + | { + type: string + } + +export type McpServerRecord = { + name: string + scope: string + transport: string + enabled: boolean + status: 'connected' | 'needs-auth' | 'failed' | 'disabled' + statusLabel: string + statusDetail?: string + configLocation: string + summary: string + canEdit: boolean + canRemove: boolean + canReconnect: boolean + canToggle: boolean + config: McpEditableConfig + projectPath?: string +} + +export type McpUpsertPayload = { + scope: string + config: McpEditableConfig +} diff --git a/src/server/__tests__/mcp.test.ts b/src/server/__tests__/mcp.test.ts new file mode 100644 index 00000000..0ccb7f64 --- /dev/null +++ b/src/server/__tests__/mcp.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test' +import * as fs from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import * as mcpClient from '../../services/mcp/client.js' +import { handleMcpApi } from '../api/mcp.js' + +let tmpDir: string +let projectRoot: string +let originalConfigDir: string | undefined +let connectSpy: ReturnType | undefined + +async function setup() { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-mcp-test-')) + projectRoot = path.join(tmpDir, 'project') + await fs.mkdir(path.join(projectRoot, '.claude'), { recursive: true }) + + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = tmpDir +} + +async function teardown() { + if (originalConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } else { + delete process.env.CLAUDE_CONFIG_DIR + } + + await fs.rm(tmpDir, { recursive: true, force: true }) +} + +function makeRequest( + method: string, + urlStr: string, + body?: Record, +): { req: Request; url: URL; segments: string[] } { + const url = new URL(urlStr, 'http://localhost:3456') + const init: RequestInit = { method } + if (body) { + init.headers = { 'Content-Type': 'application/json' } + init.body = JSON.stringify(body) + } + const req = new Request(url.toString(), init) + return { + req, + url, + segments: url.pathname.split('/').filter(Boolean), + } +} + +describe('MCP API', () => { + beforeEach(async () => { + await setup() + + connectSpy = spyOn(mcpClient, 'connectToServer').mockImplementation(async (name, config) => ({ + name, + type: 'connected', + client: {} as never, + capabilities: {}, + config, + cleanup: mock(async () => {}), + })) + }) + + afterEach(async () => { + connectSpy?.mockRestore() + connectSpy = undefined + await teardown() + }) + + it('creates and lists local MCP servers for the requested cwd', async () => { + const create = makeRequest('POST', '/api/mcp', { + cwd: projectRoot, + name: 'chrome-devtools', + scope: 'local', + config: { + type: 'stdio', + command: 'npx', + args: ['chrome-devtools-mcp@latest'], + env: { + DEBUG: '1', + }, + }, + }) + + const createRes = await handleMcpApi(create.req, create.url, create.segments) + expect(createRes.status).toBe(201) + const createdBody = await createRes.json() + expect(createdBody.server.name).toBe('chrome-devtools') + expect(createdBody.server.transport).toBe('stdio') + + const list = makeRequest('GET', `/api/mcp?cwd=${encodeURIComponent(projectRoot)}`) + const listRes = await handleMcpApi(list.req, list.url, list.segments) + expect(listRes.status).toBe(200) + const listBody = await listRes.json() + + expect(listBody.servers).toHaveLength(1) + expect(listBody.servers[0].name).toBe('chrome-devtools') + expect(listBody.servers[0].status).toBe('connected') + expect(listBody.servers[0].config.command).toBe('npx') + }) + + it('updates, toggles, and deletes MCP servers', async () => { + const create = makeRequest('POST', '/api/mcp', { + cwd: projectRoot, + name: 'context7', + scope: 'local', + config: { + type: 'stdio', + command: 'npx', + args: ['@upstash/context7-mcp'], + env: {}, + }, + }) + await handleMcpApi(create.req, create.url, create.segments) + + const update = makeRequest('PUT', '/api/mcp/context7', { + cwd: projectRoot, + scope: 'user', + config: { + type: 'http', + url: 'https://mcp.example.com/mcp', + headers: { + Authorization: 'Bearer demo', + }, + }, + }) + const updateRes = await handleMcpApi(update.req, update.url, update.segments) + expect(updateRes.status).toBe(200) + const updatedBody = await updateRes.json() + expect(updatedBody.server.transport).toBe('http') + expect(updatedBody.server.scope).toBe('user') + + const disable = makeRequest('POST', '/api/mcp/context7/toggle', { cwd: projectRoot }) + const disableRes = await handleMcpApi(disable.req, disable.url, disable.segments) + expect(disableRes.status).toBe(200) + const disabledBody = await disableRes.json() + expect(disabledBody.server.enabled).toBe(false) + expect(disabledBody.server.status).toBe('disabled') + + const enable = makeRequest('POST', '/api/mcp/context7/toggle', { cwd: projectRoot }) + const enableRes = await handleMcpApi(enable.req, enable.url, enable.segments) + expect(enableRes.status).toBe(200) + const enabledBody = await enableRes.json() + expect(enabledBody.server.enabled).toBe(true) + + const remove = makeRequest('DELETE', `/api/mcp/context7?scope=user&cwd=${encodeURIComponent(projectRoot)}`) + const removeRes = await handleMcpApi(remove.req, remove.url, remove.segments) + expect(removeRes.status).toBe(200) + + const list = makeRequest('GET', `/api/mcp?cwd=${encodeURIComponent(projectRoot)}`) + const listRes = await handleMcpApi(list.req, list.url, list.segments) + const listBody = await listRes.json() + expect(listBody.servers.some((server: { name: string }) => server.name === 'context7')).toBe(false) + }) +}) diff --git a/src/server/api/mcp.ts b/src/server/api/mcp.ts new file mode 100644 index 00000000..a5427268 --- /dev/null +++ b/src/server/api/mcp.ts @@ -0,0 +1,507 @@ +import { + clearMcpClientConfig, + clearServerTokensFromLocalStorage, +} from '../../services/mcp/auth.js' +import { + clearServerCache, + connectToServer, + getMcpServerConnectionBatchSize, + reconnectMcpServerImpl, +} from '../../services/mcp/client.js' +import { + addMcpConfig, + getClaudeCodeMcpConfigs, + getMcpConfigByName, + isMcpServerDisabled, + removeMcpConfig, + setMcpServerEnabled, +} from '../../services/mcp/config.js' +import type { + ConfigScope, + McpHTTPServerConfig, + McpSSEServerConfig, + McpServerConfig, + McpStdioServerConfig, + ScopedMcpServerConfig, +} from '../../services/mcp/types.js' +import { describeMcpConfigFilePath, ensureConfigScope } from '../../services/mcp/utils.js' +import { enableConfigs } from '../../utils/config.js' +import { getCwd, runWithCwdOverride } from '../../utils/cwd.js' +import { ApiError, errorResponse } from '../middleware/errorHandler.js' + +type McpEditableConfigDto = + | { + type: 'stdio' + command: string + args: string[] + env: Record + } + | { + type: 'http' | 'sse' + url: string + headers: Record + headersHelper?: string + oauth?: { + clientId?: string + callbackPort?: number + } + } + | { + type: string + } + +type McpServerDto = { + name: string + scope: string + transport: string + enabled: boolean + status: 'connected' | 'needs-auth' | 'failed' | 'disabled' + statusLabel: string + statusDetail?: string + configLocation: string + summary: string + canEdit: boolean + canRemove: boolean + canReconnect: boolean + canToggle: boolean + config: McpEditableConfigDto +} + +type McpMutationBody = { + cwd?: string + scope?: string + config?: unknown +} + +const EDITABLE_SCOPES = new Set(['local', 'project', 'user']) + +function parseJsonBody(req: Request): Promise> { + return req + .json() + .then((body) => body as Record) + .catch(() => { + throw ApiError.badRequest('Invalid JSON body') + }) +} + +function resolveRequestCwd(url: URL, body?: Record): string { + const cwd = url.searchParams.get('cwd') || (typeof body?.cwd === 'string' ? body.cwd : undefined) + return cwd || getCwd() +} + +function stripScope(config: ScopedMcpServerConfig): McpServerConfig { + const { scope: _scope, pluginSource: _pluginSource, ...rest } = config + return rest +} + +function isVisibleServer(name: string, config: ScopedMcpServerConfig): boolean { + if (name === 'ide') return false + if (config.type === 'sse-ide' || config.type === 'ws-ide') return false + return true +} + +function serializeEditableConfig(config: ScopedMcpServerConfig): McpEditableConfigDto { + if (!config.type || config.type === 'stdio') { + const stdioConfig = config as McpStdioServerConfig + return { + type: 'stdio', + command: stdioConfig.command, + args: Array.isArray(stdioConfig.args) ? stdioConfig.args : [], + env: stdioConfig.env ?? {}, + } + } + + if (config.type === 'http' || config.type === 'sse') { + const remoteConfig = config as McpHTTPServerConfig | McpSSEServerConfig + return { + type: config.type, + url: remoteConfig.url, + headers: remoteConfig.headers ?? {}, + headersHelper: remoteConfig.headersHelper, + oauth: remoteConfig.oauth + ? { + clientId: remoteConfig.oauth.clientId, + callbackPort: remoteConfig.oauth.callbackPort, + } + : undefined, + } + } + + return { type: config.type } +} + +function getSummary(config: ScopedMcpServerConfig): string { + if (!config.type || config.type === 'stdio') { + const stdioConfig = config as McpStdioServerConfig + return [stdioConfig.command, ...(stdioConfig.args ?? [])].join(' ').trim() + } + + if ('url' in config && typeof config.url === 'string') { + return config.url + } + + return config.type +} + +function getStatusLabel(status: McpServerDto['status']): string { + switch (status) { + case 'connected': + return 'Connected' + case 'needs-auth': + return 'Needs auth' + case 'failed': + return 'Unavailable' + case 'disabled': + return 'Disabled' + default: + return status + } +} + +async function inspectServerStatus( + name: string, + config: ScopedMcpServerConfig, + enabled: boolean, +): Promise> { + if (!enabled) { + return { + status: 'disabled', + statusLabel: getStatusLabel('disabled'), + statusDetail: 'Server disabled for the current project', + } + } + + try { + const client = await connectToServer(name, config) + await clearServerCache(name, config).catch(() => {}) + + const status: McpServerDto['status'] = + client.type === 'connected' + ? 'connected' + : client.type === 'needs-auth' + ? 'needs-auth' + : 'failed' + + return { + status, + statusLabel: getStatusLabel(status), + statusDetail: 'error' in client ? client.error : undefined, + } + } catch (error) { + await clearServerCache(name, config).catch(() => {}) + return { + status: 'failed', + statusLabel: getStatusLabel('failed'), + statusDetail: error instanceof Error ? error.message : String(error), + } + } +} + +async function serializeServer( + name: string, + config: ScopedMcpServerConfig, +): Promise { + const enabled = !isMcpServerDisabled(name) + const status = await inspectServerStatus(name, config, enabled) + const transport = config.type ?? 'stdio' + const canEdit = EDITABLE_SCOPES.has(config.scope) && (transport === 'stdio' || transport === 'http' || transport === 'sse') + + return { + name, + scope: config.scope, + transport, + enabled, + status: status.status, + statusLabel: status.statusLabel, + statusDetail: status.statusDetail, + configLocation: describeMcpConfigFilePath(config.scope), + summary: getSummary(config), + canEdit, + canRemove: EDITABLE_SCOPES.has(config.scope), + canReconnect: enabled, + canToggle: true, + config: serializeEditableConfig(config), + } +} + +function buildServerConfig(config: unknown): McpServerConfig { + if (!config || typeof config !== 'object') { + throw ApiError.badRequest('Missing or invalid "config" in request body') + } + + const raw = config as Record + const type = raw.type + + if (!type || type === 'stdio') { + const command = typeof raw.command === 'string' ? raw.command.trim() : '' + if (!command) { + throw ApiError.badRequest('Command is required for stdio MCP servers') + } + + const args = Array.isArray(raw.args) + ? raw.args.filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + : [] + + const envEntries = raw.env && typeof raw.env === 'object' + ? Object.entries(raw.env as Record).filter( + (entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string' && entry[0].trim().length > 0, + ) + : [] + + return { + type: 'stdio', + command, + args, + ...(envEntries.length > 0 ? { env: Object.fromEntries(envEntries) } : {}), + } + } + + if (type === 'http' || type === 'sse') { + const url = typeof raw.url === 'string' ? raw.url.trim() : '' + if (!url) { + throw ApiError.badRequest('URL is required for remote MCP servers') + } + + const headersEntries = raw.headers && typeof raw.headers === 'object' + ? Object.entries(raw.headers as Record).filter( + (entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string' && entry[0].trim().length > 0, + ) + : [] + + const oauthRaw = raw.oauth && typeof raw.oauth === 'object' ? (raw.oauth as Record) : undefined + const clientId = typeof oauthRaw?.clientId === 'string' ? oauthRaw.clientId.trim() : '' + const callbackPort = + typeof oauthRaw?.callbackPort === 'number' + ? oauthRaw.callbackPort + : typeof oauthRaw?.callbackPort === 'string' && oauthRaw.callbackPort.trim() + ? Number(oauthRaw.callbackPort) + : undefined + + return { + type, + url, + ...(headersEntries.length > 0 ? { headers: Object.fromEntries(headersEntries) } : {}), + ...(typeof raw.headersHelper === 'string' && raw.headersHelper.trim() + ? { headersHelper: raw.headersHelper.trim() } + : {}), + ...(clientId || callbackPort + ? { + oauth: { + ...(clientId ? { clientId } : {}), + ...(callbackPort ? { callbackPort } : {}), + }, + } + : {}), + } + } + + throw ApiError.badRequest(`Unsupported MCP transport: ${String(type)}`) +} + +function cleanupSecureStorage(name: string, config: ScopedMcpServerConfig) { + if (config.type !== 'sse' && config.type !== 'http') return + clearServerTokensFromLocalStorage(name, config) + clearMcpClientConfig(name, config) +} + +async function listServers(): Promise { + const { servers } = await getClaudeCodeMcpConfigs() + const visibleServers = Object.entries(servers) + .filter(([name, config]) => isVisibleServer(name, config)) + .sort((a, b) => a[0].localeCompare(b[0])) + + const concurrency = Math.max(1, getMcpServerConnectionBatchSize()) + const items: McpServerDto[] = [] + + for (let index = 0; index < visibleServers.length; index += concurrency) { + const chunk = visibleServers.slice(index, index + concurrency) + const chunkItems = await Promise.all( + chunk.map(([name, config]) => serializeServer(name, config)), + ) + items.push(...chunkItems) + } + + return Response.json({ servers: items }) +} + +async function createServer(body: Record): Promise { + const name = typeof body.name === 'string' ? body.name.trim() : '' + if (!name) { + throw ApiError.badRequest('Missing or invalid "name" in request body') + } + + const scope = ensureConfigScope(typeof body.scope === 'string' ? body.scope : undefined) + const config = buildServerConfig(body.config) + + try { + await addMcpConfig(name, config, scope) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (message.includes('already exists')) { + throw ApiError.conflict(message) + } + throw ApiError.badRequest(message) + } + + const created = getMcpConfigByName(name) + if (!created) { + throw ApiError.internal(`Created MCP server "${name}" could not be reloaded`) + } + + return Response.json({ server: await serializeServer(name, created) }, { status: 201 }) +} + +async function updateServer(name: string, body: Record): Promise { + const existing = getMcpConfigByName(name) + if (!existing) { + throw ApiError.notFound(`MCP server not found: ${name}`) + } + + if (!EDITABLE_SCOPES.has(existing.scope)) { + throw ApiError.badRequest(`MCP server "${name}" cannot be edited from scope "${existing.scope}"`) + } + + const nextScope = ensureConfigScope(typeof body.scope === 'string' ? body.scope : existing.scope) + const nextConfig = buildServerConfig(body.config) + const previousConfig = stripScope(existing) + const previousScope = existing.scope + + try { + await removeMcpConfig(name, previousScope) + await addMcpConfig(name, nextConfig, nextScope) + } catch (error) { + try { + const restored = getMcpConfigByName(name) + if (!restored) { + await addMcpConfig(name, previousConfig, previousScope) + } + } catch { + // Preserve the original update error below. + } + + const message = error instanceof Error ? error.message : String(error) + if (message.includes('already exists')) { + throw ApiError.conflict(message) + } + throw ApiError.badRequest(message) + } + + const updated = getMcpConfigByName(name) + if (!updated) { + throw ApiError.internal(`Updated MCP server "${name}" could not be reloaded`) + } + + return Response.json({ server: await serializeServer(name, updated) }) +} + +async function deleteServer(name: string, url: URL): Promise { + const scope = ensureConfigScope(url.searchParams.get('scope') || undefined) + const existing = getMcpConfigByName(name) + if (!existing) { + throw ApiError.notFound(`MCP server not found: ${name}`) + } + + await removeMcpConfig(name, scope) + cleanupSecureStorage(name, existing) + await clearServerCache(name, existing).catch(() => {}) + + return Response.json({ ok: true }) +} + +async function toggleServer(name: string): Promise { + const existing = getMcpConfigByName(name) + if (!existing) { + throw ApiError.notFound(`MCP server not found: ${name}`) + } + + const enabled = isMcpServerDisabled(name) + setMcpServerEnabled(name, enabled) + + if (!enabled) { + await clearServerCache(name, existing).catch(() => {}) + const updated = await serializeServer(name, existing) + return Response.json({ server: updated }) + } + + const result = await reconnectMcpServerImpl(name, existing) + await clearServerCache(name, existing).catch(() => {}) + + const updated = await serializeServer(name, existing) + const statusDetail = + result.client.type === 'failed' && 'error' in result.client ? result.client.error : undefined + + return Response.json({ + server: { + ...updated, + ...(statusDetail ? { statusDetail } : {}), + }, + }) +} + +async function reconnectServer(name: string): Promise { + const existing = getMcpConfigByName(name) + if (!existing) { + throw ApiError.notFound(`MCP server not found: ${name}`) + } + + const result = await reconnectMcpServerImpl(name, existing) + await clearServerCache(name, existing).catch(() => {}) + + const server = await serializeServer(name, existing) + const statusDetail = + result.client.type === 'failed' && 'error' in result.client ? result.client.error : undefined + + return Response.json({ + server: { + ...server, + ...(statusDetail ? { statusDetail } : {}), + }, + }) +} + +export async function handleMcpApi( + req: Request, + url: URL, + segments: string[], +): Promise { + try { + enableConfigs() + + const serverName = segments[2] ? decodeURIComponent(segments[2]) : undefined + const action = segments[3] + const body = + req.method === 'POST' || req.method === 'PUT' + ? await parseJsonBody(req) + : undefined + + return await runWithCwdOverride(resolveRequestCwd(url, body), async () => { + if (req.method === 'GET' && !serverName) { + return listServers() + } + + if (req.method === 'POST' && !serverName) { + return createServer(body ?? {}) + } + + if (req.method === 'PUT' && serverName) { + return updateServer(serverName, body ?? {}) + } + + if (req.method === 'DELETE' && serverName && !action) { + return deleteServer(serverName, url) + } + + if (req.method === 'POST' && serverName && action === 'toggle') { + return toggleServer(serverName) + } + + if (req.method === 'POST' && serverName && action === 'reconnect') { + return reconnectServer(serverName) + } + + throw new ApiError(405, `Method ${req.method} not allowed`, 'METHOD_NOT_ALLOWED') + }) + } catch (error) { + return errorResponse(error) + } +} diff --git a/src/server/router.ts b/src/server/router.ts index 472808d6..2e6c7b3d 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -17,6 +17,7 @@ import { handleAdaptersApi } from './api/adapters.js' import { handleSkillsApi } from './api/skills.js' import { handleComputerUseApi } from './api/computer-use.js' import { handleHahaOAuthApi } from './api/haha-oauth.js' +import { handleMcpApi } from './api/mcp.js' export async function handleApiRequest(req: Request, url: URL): Promise { const path = url.pathname @@ -76,6 +77,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise