mirror of
https://github.com/NanmiCoder/cc-haha.git
synced 2026-05-06 23:31:17 +08:00
Make desktop MCP management and slash entry points directly operable
Desktop MCP management now has a working server API, a global-only settings surface, and slash-command entry points that surface MCP and skills from the composer before routing users into the right settings view. Constraint: Project-scoped MCP browsing in settings was too slow and noisy because it scanned multiple workdirs Rejected: Keep project MCP aggregation on the settings homepage | duplicated entries and poor responsiveness Rejected: Route /mcp directly on Enter without an intermediate card | removed the user's ability to choose a specific target first Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep settings focused on global MCP; add project-scoped MCP affordances in the chat-context slash surfaces instead of re-expanding the settings homepage Tested: bun test src/server/__tests__/mcp.test.ts; cd desktop && bun run test -- pages.test.tsx mcpSettings.test.tsx; cd desktop && bun x tsc --noEmit --ignoreDeprecations 5.0 Not-tested: Manual IAB verification after this final commit/merge cycle
This commit is contained in:
112
desktop/src/__tests__/mcpSettings.test.tsx
Normal file
112
desktop/src/__tests__/mcpSettings.test.tsx
Normal file
@@ -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(<McpSettings />)
|
||||
|
||||
expect(fetchServers).toHaveBeenCalledWith(undefined, undefined)
|
||||
})
|
||||
|
||||
it('renders the empty state and add button', () => {
|
||||
render(<McpSettings />)
|
||||
|
||||
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(<McpSettings />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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(<ActiveSession />)
|
||||
|
||||
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(<AgentTeams />)
|
||||
expect(container.innerHTML).toContain('Architect')
|
||||
|
||||
39
desktop/src/api/mcp.ts
Normal file
39
desktop/src/api/mcp.ts
Normal file
@@ -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 } : {})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<LocalSlashCommandName | null>(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 && (
|
||||
<div ref={slashMenuRef}>
|
||||
<LocalSlashCommandPanel
|
||||
command={localSlashPanel}
|
||||
cwd={gitInfo?.workDir || activeSession?.workDir || undefined}
|
||||
onClose={() => setLocalSlashPanel(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMemberSession && slashMenuOpen && filteredCommands.length > 0 && (
|
||||
<div
|
||||
ref={slashMenuRef}
|
||||
|
||||
285
desktop/src/components/chat/LocalSlashCommandPanel.tsx
Normal file
285
desktop/src/components/chat/LocalSlashCommandPanel.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { skillsApi } from '../../api/skills'
|
||||
import { mcpApi } from '../../api/mcp'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
|
||||
import { useMcpStore } from '../../stores/mcpStore'
|
||||
import { useSkillStore } from '../../stores/skillStore'
|
||||
import type { McpServerRecord } from '../../types/mcp'
|
||||
import type { SkillMeta } from '../../types/skill'
|
||||
|
||||
export type LocalSlashCommandName = 'mcp' | 'skills' | 'plugins'
|
||||
|
||||
type Props = {
|
||||
command: LocalSlashCommandName
|
||||
cwd?: string
|
||||
onClose: () => 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<typeof useTranslation>) {
|
||||
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<typeof useTranslation>) {
|
||||
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 (
|
||||
<div className="absolute bottom-full left-0 right-0 z-50 mb-3 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[var(--color-border)] px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">{title}</h3>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-tertiary)]">{subtitle}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[420px] overflow-y-auto px-5 py-4">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingState({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-tertiary)]">
|
||||
<div className="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-10 text-center">
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{title}</div>
|
||||
<div className="mt-2 text-xs leading-6 text-[var(--color-text-tertiary)]">{body}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--color-error)]/20 bg-[var(--color-error)]/8 px-5 py-4 text-sm text-[var(--color-error)]">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<McpServerRecord[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(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<string, McpServerRecord[]>()
|
||||
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 (
|
||||
<PanelShell
|
||||
title={t('slash.mcp.title')}
|
||||
subtitle={cwd ? t('slash.mcp.subtitleWithProject', { path: cwd }) : t('slash.mcp.subtitle')}
|
||||
onClose={onClose}
|
||||
>
|
||||
{error ? (
|
||||
<ErrorState message={error} />
|
||||
) : servers === null ? (
|
||||
<LoadingState label={t('common.loading')} />
|
||||
) : servers.length === 0 ? (
|
||||
<EmptyState title={t('slash.mcp.emptyTitle')} body={t('slash.mcp.emptyBody')} />
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{['user', 'local', 'project'].filter((scope) => grouped.has(scope)).map((scope) => (
|
||||
<section key={scope}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{scopeLabel(scope, t)}</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)]">{grouped.get(scope)?.length ?? 0}</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
{grouped.get(scope)?.map((server) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${server.scope}:${server.projectPath ?? 'global'}:${server.name}`}
|
||||
onClick={() => {
|
||||
selectServer(server)
|
||||
setPendingSettingsTab('mcp')
|
||||
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
|
||||
onClose()
|
||||
}}
|
||||
className="block w-full border-t border-[var(--color-border)] px-4 py-4 text-left first:border-t-0 hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{server.name}</div>
|
||||
<span className={`inline-flex items-center rounded-full border px-2 py-1 text-[11px] font-semibold ${toneForStatus(server.status)}`}>
|
||||
{server.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span className="rounded-full bg-[var(--color-surface-hover)] px-2 py-1">{server.transport}</span>
|
||||
{server.projectPath && (
|
||||
<span className="rounded-full bg-[var(--color-surface-hover)] px-2 py-1" title={server.projectPath}>
|
||||
{projectBadge(server.projectPath, t)}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{server.summary}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PanelShell>
|
||||
)
|
||||
}
|
||||
|
||||
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<SkillMeta[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<PanelShell
|
||||
title={t('slash.skills.title')}
|
||||
subtitle={cwd ? t('slash.skills.subtitleWithProject', { path: cwd }) : t('slash.skills.subtitle')}
|
||||
onClose={onClose}
|
||||
>
|
||||
{error ? (
|
||||
<ErrorState message={error} />
|
||||
) : skills === null ? (
|
||||
<LoadingState label={t('common.loading')} />
|
||||
) : skills.length === 0 ? (
|
||||
<EmptyState title={t('slash.skills.emptyTitle')} body={t('slash.skills.emptyBody')} />
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
{skills.map((skill) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${skill.source}:${skill.name}`}
|
||||
onClick={async () => {
|
||||
await fetchSkillDetail(skill.source, skill.name, cwd)
|
||||
setPendingSettingsTab('skills')
|
||||
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
|
||||
onClose()
|
||||
}}
|
||||
className="block w-full border-t border-[var(--color-border)] px-4 py-4 text-left first:border-t-0 hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)]">/{skill.name}</div>
|
||||
<span className="rounded-full bg-[var(--color-surface-hover)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)]">
|
||||
{skill.source}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-6 text-[var(--color-text-tertiary)]">{skill.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PanelShell>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginsPanel({ onClose }: { onClose: () => void }) {
|
||||
const t = useTranslation()
|
||||
return (
|
||||
<PanelShell
|
||||
title={t('slash.plugins.title')}
|
||||
subtitle={t('slash.plugins.subtitle')}
|
||||
onClose={onClose}
|
||||
>
|
||||
<EmptyState title={t('slash.plugins.emptyTitle')} body={t('slash.plugins.emptyBody')} />
|
||||
</PanelShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function LocalSlashCommandPanel({ command, cwd, onClose }: Props) {
|
||||
if (command === 'mcp') return <McpPanel cwd={cwd} onClose={onClose} />
|
||||
if (command === 'skills') return <SkillsPanel cwd={cwd} onClose={onClose} />
|
||||
return <PluginsPanel onClose={onClose} />
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -53,6 +53,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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<TranslationKey, string> = {
|
||||
'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<TranslationKey, string> = {
|
||||
'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': '关闭',
|
||||
|
||||
@@ -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<LocalSlashCommandName | null>(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 && (
|
||||
<div ref={slashMenuRef}>
|
||||
<LocalSlashCommandPanel
|
||||
command={localSlashPanel}
|
||||
cwd={workDir || undefined}
|
||||
onClose={() => setLocalSlashPanel(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{slashMenuOpen && filteredCommands.length > 0 && (
|
||||
<div
|
||||
ref={slashMenuRef}
|
||||
|
||||
860
desktop/src/pages/McpSettings.tsx
Normal file
860
desktop/src/pages/McpSettings.tsx
Normal file
@@ -0,0 +1,860 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button } from '../components/shared/Button'
|
||||
import { Input } from '../components/shared/Input'
|
||||
import { Modal } from '../components/shared/Modal'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import { useMcpStore } from '../stores/mcpStore'
|
||||
import type { McpServerRecord, McpUpsertPayload } from '../types/mcp'
|
||||
|
||||
type EditorMode =
|
||||
| { type: 'list' }
|
||||
| { type: 'create' }
|
||||
| { type: 'edit'; server: McpServerRecord }
|
||||
| { type: 'details'; server: McpServerRecord }
|
||||
|
||||
type TransportKind = 'stdio' | 'http' | 'sse'
|
||||
|
||||
type StringRow = {
|
||||
id: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type KeyValueRow = {
|
||||
id: string
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type McpDraft = {
|
||||
name: string
|
||||
transport: TransportKind
|
||||
command: string
|
||||
args: StringRow[]
|
||||
env: KeyValueRow[]
|
||||
url: string
|
||||
headers: KeyValueRow[]
|
||||
headersHelper: string
|
||||
oauthClientId: string
|
||||
oauthCallbackPort: string
|
||||
}
|
||||
|
||||
const STATUS_TONE: Record<McpServerRecord['status'], string> = {
|
||||
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<McpServerRecord['config'], { type: 'stdio' }> {
|
||||
return config.type === 'stdio'
|
||||
}
|
||||
|
||||
function isRemoteConfig(config: McpServerRecord['config']): config is Extract<McpServerRecord['config'], { type: 'http' | 'sse' }> {
|
||||
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<typeof useTranslation>) {
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={onChange}
|
||||
className={`relative inline-flex h-8 w-14 items-center rounded-full transition-colors ${
|
||||
checked ? 'bg-[#90c1f7]' : 'bg-[var(--color-border)]'
|
||||
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-6 w-6 transform rounded-full bg-white transition-transform ${
|
||||
checked ? 'translate-x-7' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)] mb-4">{title}</div>
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className={`grid gap-3 ${singleValue ? 'grid-cols-[minmax(0,1fr)_32px]' : 'grid-cols-[minmax(0,1fr)_minmax(0,1fr)_32px]'}`}>
|
||||
{!singleValue && 'key' in row && (
|
||||
<Input
|
||||
value={row.key}
|
||||
onChange={(event) => onChange(row.id, 'key', event.target.value)}
|
||||
placeholder={keyPlaceholder}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
value={row.value}
|
||||
onChange={(event) => onChange(row.id, 'value', event.target.value)}
|
||||
placeholder={valuePlaceholder}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(row.id)}
|
||||
className="mt-1 flex h-10 w-8 items-center justify-center rounded-[var(--radius-md)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
aria-label={addLabel}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-[var(--radius-lg)] bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">add</span>
|
||||
{addLabel}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon }: { label: string; value: number; icon: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-5 py-4">
|
||||
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)] mb-2">
|
||||
<span className="material-symbols-outlined text-[18px]">{icon}</span>
|
||||
<span className="text-xs uppercase tracking-[0.18em] font-semibold">{label}</span>
|
||||
</div>
|
||||
<div className="text-3xl font-semibold text-[var(--color-text-primary)]">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerRow({
|
||||
server,
|
||||
isBusy,
|
||||
onOpen,
|
||||
onToggle,
|
||||
t,
|
||||
}: {
|
||||
server: McpServerRecord
|
||||
isBusy: boolean
|
||||
onOpen: () => void
|
||||
onToggle: () => void
|
||||
t: ReturnType<typeof useTranslation>
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-4 px-6 py-5 border-t border-[var(--color-border)] first:border-t-0">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2 min-w-0">
|
||||
<div className="text-[1.05rem] font-semibold text-[var(--color-text-primary)] truncate">{server.name}</div>
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold ${STATUS_TONE[server.status]}`}>
|
||||
{server.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span className="rounded-full bg-[var(--color-surface-hover)] px-2 py-1 font-medium text-[var(--color-text-secondary)]">
|
||||
{transportLabel(server.transport, t)}
|
||||
</span>
|
||||
<span className="rounded-full bg-[var(--color-surface-hover)] px-2 py-1 font-medium text-[var(--color-text-secondary)]">
|
||||
{t('settings.mcp.scope.user')}
|
||||
</span>
|
||||
<span className="truncate">{server.summary}</span>
|
||||
</div>
|
||||
{server.statusDetail && (
|
||||
<div className="mt-2 text-xs text-[var(--color-text-tertiary)] truncate">{server.statusDetail}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
aria-label={`Open ${server.name}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">settings</span>
|
||||
</button>
|
||||
|
||||
<ToggleSwitch checked={server.enabled} disabled={isBusy || !server.canToggle} onChange={onToggle} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<EditorMode>({ type: 'list' })
|
||||
const [draft, setDraft] = useState<McpDraft>(createEmptyDraft)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [busyServerName, setBusyServerName] = useState<string | null>(null)
|
||||
const [pendingDeleteServer, setPendingDeleteServer] = useState<McpServerRecord | null>(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 = <K extends keyof McpDraft>(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 (
|
||||
<div className="max-w-5xl min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView({ type: 'list' })}
|
||||
className="mb-5 inline-flex items-center gap-2 text-sm text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
|
||||
{t('settings.mcp.form.back')}
|
||||
</button>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h2 className="text-[2.2rem] font-semibold tracking-[-0.03em] text-[var(--color-text-primary)]">{server.name}</h2>
|
||||
<p className="mt-3 text-base text-[var(--color-text-secondary)]">{server.summary}</p>
|
||||
</div>
|
||||
{server.canReconnect && (
|
||||
<Button variant="secondary" onClick={() => handleReconnect(server)} loading={busyServerName === server.name}>
|
||||
<span className="material-symbols-outlined text-[16px]">sync</span>
|
||||
{t('settings.mcp.form.reconnect')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<InfoPair label={t('settings.mcp.form.transport')} value={transportLabel(server.transport, t)} />
|
||||
<InfoPair label={t('settings.mcp.form.scope')} value={t('settings.mcp.scope.user')} />
|
||||
<InfoPair label={t('settings.mcp.form.status')} value={server.statusLabel} />
|
||||
<InfoPair label={t('settings.mcp.form.location')} value={server.configLocation} />
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">{t('settings.mcp.form.rawConfig')}</div>
|
||||
<pre className="overflow-x-auto rounded-[var(--radius-lg)] bg-[var(--color-surface-hover)] p-4 text-xs text-[var(--color-text-secondary)]">
|
||||
{JSON.stringify(server.config, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="max-w-5xl min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView({ type: 'list' })}
|
||||
className="mb-5 inline-flex items-center gap-2 text-sm text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
|
||||
{t('settings.mcp.form.back')}
|
||||
</button>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h2 className="text-[2.2rem] font-semibold tracking-[-0.03em] text-[var(--color-text-primary)]">
|
||||
{editing ? t('settings.mcp.form.editTitle', { name: targetServer!.name }) : t('settings.mcp.form.createTitle')}
|
||||
</h2>
|
||||
<p className="mt-3 text-base text-[var(--color-text-secondary)]">
|
||||
{editing ? t('settings.mcp.form.editHint') : t('settings.mcp.form.createHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{editing && targetServer?.canReconnect && (
|
||||
<Button variant="secondary" onClick={() => handleReconnect(targetServer)} loading={busyServerName === targetServer.name}>
|
||||
<span className="material-symbols-outlined text-[16px]">sync</span>
|
||||
{t('settings.mcp.form.reconnect')}
|
||||
</Button>
|
||||
)}
|
||||
{editing && targetServer?.canRemove && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-[var(--color-error)] hover:text-[var(--color-error)] hover:bg-[var(--color-error)]/8"
|
||||
onClick={() => handleDelete(targetServer)}
|
||||
loading={isDeleting}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||
{t('settings.mcp.form.uninstall')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
|
||||
<Input
|
||||
label={t('settings.mcp.form.name')}
|
||||
value={draft.name}
|
||||
onChange={(event) => setDraftField('name', event.target.value)}
|
||||
placeholder={t('settings.mcp.form.namePlaceholder')}
|
||||
disabled={editing}
|
||||
required
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">
|
||||
{t('settings.mcp.form.scope')}
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">
|
||||
{t('settings.mcp.globalOnlyHint')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
|
||||
<div className="grid grid-cols-3">
|
||||
{(['stdio', 'http', 'sse'] as TransportKind[]).map((transport) => {
|
||||
const active = draft.transport === transport
|
||||
return (
|
||||
<button
|
||||
key={transport}
|
||||
type="button"
|
||||
disabled={transportLocked}
|
||||
onClick={() => setDraftField('transport', transport)}
|
||||
className={`h-14 text-sm font-semibold transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
|
||||
: 'bg-[var(--color-surface)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
} ${transportLocked ? 'cursor-not-allowed opacity-70' : ''}`}
|
||||
>
|
||||
{transport === 'stdio' ? 'STDIO' : transportLabel(transport, t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{editing && (
|
||||
<div className="text-sm text-[var(--color-text-tertiary)]">
|
||||
{t('settings.mcp.form.transportLocked')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft.transport === 'stdio' ? (
|
||||
<>
|
||||
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
|
||||
<Input
|
||||
label={t('settings.mcp.form.command')}
|
||||
value={draft.command}
|
||||
onChange={(event) => setDraftField('command', event.target.value)}
|
||||
placeholder={t('settings.mcp.form.commandPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</section>
|
||||
|
||||
<ArraySection
|
||||
title={t('settings.mcp.form.arguments')}
|
||||
rows={draft.args}
|
||||
onChange={(id, _field, value) => 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')}
|
||||
/>
|
||||
|
||||
<ArraySection
|
||||
title={t('settings.mcp.form.environmentVariables')}
|
||||
rows={draft.env}
|
||||
onChange={(id, field, value) => 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')}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
|
||||
<Input
|
||||
label={draft.transport === 'http' ? t('settings.mcp.form.url') : t('settings.mcp.form.sseUrl')}
|
||||
value={draft.url}
|
||||
onChange={(event) => setDraftField('url', event.target.value)}
|
||||
placeholder={t('settings.mcp.form.urlPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</section>
|
||||
|
||||
<ArraySection
|
||||
title={t('settings.mcp.form.headers')}
|
||||
rows={draft.headers}
|
||||
onChange={(id, field, value) => 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')}
|
||||
/>
|
||||
|
||||
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
label={t('settings.mcp.form.oauthClientId')}
|
||||
value={draft.oauthClientId}
|
||||
onChange={(event) => setDraftField('oauthClientId', event.target.value)}
|
||||
placeholder={t('settings.mcp.form.oauthClientIdPlaceholder')}
|
||||
/>
|
||||
<Input
|
||||
label={t('settings.mcp.form.oauthCallbackPort')}
|
||||
value={draft.oauthCallbackPort}
|
||||
onChange={(event) => setDraftField('oauthCallbackPort', event.target.value)}
|
||||
placeholder={t('settings.mcp.form.oauthCallbackPortPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Input
|
||||
label={t('settings.mcp.form.headersHelper')}
|
||||
value={draft.headersHelper}
|
||||
onChange={(event) => setDraftField('headersHelper', event.target.value)}
|
||||
placeholder={t('settings.mcp.form.headersHelperPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} disabled={!isDraftValid(draft) || isBusy} loading={isSaving}>
|
||||
{t('settings.mcp.form.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl min-w-0">
|
||||
<div className="flex items-start justify-between gap-6 mb-8">
|
||||
<div>
|
||||
<h2 className="text-[2.2rem] font-semibold tracking-[-0.03em] text-[var(--color-text-primary)]">
|
||||
{t('settings.mcp.title')}
|
||||
</h2>
|
||||
<p className="mt-3 text-base text-[var(--color-text-secondary)]">
|
||||
{t('settings.mcp.description')}
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-[var(--color-text-tertiary)]">
|
||||
{t('settings.mcp.globalOnlyHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="lg" onClick={beginCreate}>
|
||||
<span className="material-symbols-outlined text-[18px]">add</span>
|
||||
{t('settings.mcp.addServer')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3 mb-8">
|
||||
<StatCard label={t('settings.mcp.stats.total')} value={stats.total} icon="dns" />
|
||||
<StatCard label={t('settings.mcp.stats.connected')} value={stats.connected} icon="check_circle" />
|
||||
<StatCard label={t('settings.mcp.stats.attention')} value={stats.attention} icon="error" />
|
||||
</div>
|
||||
|
||||
{isLoading && globalServers.length === 0 ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="animate-spin h-6 w-6 rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-16 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
|
||||
<span className="material-symbols-outlined text-[40px] text-[var(--color-error)] mb-3 block">error</span>
|
||||
<p className="text-sm text-[var(--color-error)] mb-3">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fetchServers(undefined, undefined)}
|
||||
className="text-sm text-[var(--color-text-accent)] hover:underline"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : globalServers.length === 0 ? (
|
||||
<div className="text-center py-16 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
|
||||
<span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-3 block">dns</span>
|
||||
<p className="text-sm text-[var(--color-text-secondary)] mb-1">{t('settings.mcp.empty')}</p>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.mcp.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-[1.35rem] font-semibold text-[var(--color-text-primary)]">
|
||||
{t('settings.mcp.scope.user')}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--color-text-tertiary)]">{globalServers.length}</div>
|
||||
</div>
|
||||
<div className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
|
||||
{globalServers.map((server) => (
|
||||
<ServerRow
|
||||
key={`${server.scope}:${server.name}`}
|
||||
server={server}
|
||||
isBusy={busyServerName === server.name}
|
||||
onOpen={() => beginEdit(server)}
|
||||
onToggle={() => void handleToggle(server)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={pendingDeleteServer !== null}
|
||||
onClose={() => {
|
||||
if (isDeleting) return
|
||||
setPendingDeleteServer(null)
|
||||
}}
|
||||
title={t('settings.mcp.form.deleteTitle')}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setPendingDeleteServer(null)} disabled={isDeleting}>
|
||||
{t('settings.mcp.form.cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={confirmDelete} loading={isDeleting}>
|
||||
{t('settings.mcp.form.confirmDelete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{pendingDeleteServer ? t('settings.mcp.form.deleteConfirmBody', { name: pendingDeleteServer.name }) : ''}
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoPair({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-lg)] bg-[var(--color-surface-hover)] px-4 py-3">
|
||||
<div className="text-xs uppercase tracking-[0.16em] font-semibold text-[var(--color-text-tertiary)] mb-2">{label}</div>
|
||||
<div className="text-sm text-[var(--color-text-primary)] break-all">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
<TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
|
||||
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
|
||||
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
|
||||
<TabButton icon="dns" label={t('settings.tab.mcp')} active={activeTab === 'mcp'} onClick={() => setActiveTab('mcp')} />
|
||||
<TabButton icon="smart_toy" label={t('settings.tab.agents')} active={activeTab === 'agents'} onClick={() => setActiveTab('agents')} />
|
||||
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
|
||||
<TabButton icon="mouse" label={t('settings.tab.computerUse')} active={activeTab === 'computerUse'} onClick={() => setActiveTab('computerUse')} />
|
||||
@@ -60,6 +62,7 @@ export function Settings() {
|
||||
{activeTab === 'permissions' && <PermissionSettings />}
|
||||
{activeTab === 'general' && <GeneralSettings />}
|
||||
{activeTab === 'adapters' && <AdapterSettings />}
|
||||
{activeTab === 'mcp' && <McpSettings />}
|
||||
{activeTab === 'agents' && <AgentsSettings />}
|
||||
{activeTab === 'skills' && <SkillSettings />}
|
||||
{activeTab === 'computerUse' && <ComputerUseSettings />}
|
||||
|
||||
125
desktop/src/stores/mcpStore.ts
Normal file
125
desktop/src/stores/mcpStore.ts
Normal file
@@ -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<void>
|
||||
createServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
|
||||
updateServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
|
||||
deleteServer: (name: string, scope: string, cwd?: string) => Promise<void>
|
||||
toggleServer: (name: string, cwd?: string) => Promise<McpServerRecord>
|
||||
reconnectServer: (name: string, cwd?: string) => Promise<McpServerRecord>
|
||||
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<McpStore>((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<string, McpServerRecord>()
|
||||
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 }),
|
||||
}))
|
||||
@@ -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'
|
||||
|
||||
|
||||
43
desktop/src/types/mcp.ts
Normal file
43
desktop/src/types/mcp.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type McpEditableConfig =
|
||||
| {
|
||||
type: 'stdio'
|
||||
command: string
|
||||
args: string[]
|
||||
env: Record<string, string>
|
||||
}
|
||||
| {
|
||||
type: 'http' | 'sse'
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
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
|
||||
}
|
||||
156
src/server/__tests__/mcp.test.ts
Normal file
156
src/server/__tests__/mcp.test.ts
Normal file
@@ -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<typeof spyOn> | 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<string, unknown>,
|
||||
): { 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)
|
||||
})
|
||||
})
|
||||
507
src/server/api/mcp.ts
Normal file
507
src/server/api/mcp.ts
Normal file
@@ -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<string, string>
|
||||
}
|
||||
| {
|
||||
type: 'http' | 'sse'
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
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<ConfigScope>(['local', 'project', 'user'])
|
||||
|
||||
function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
return req
|
||||
.json()
|
||||
.then((body) => body as Record<string, unknown>)
|
||||
.catch(() => {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
})
|
||||
}
|
||||
|
||||
function resolveRequestCwd(url: URL, body?: Record<string, unknown>): 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<Pick<McpServerDto, 'status' | 'statusDetail' | 'statusLabel'>> {
|
||||
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<McpServerDto> {
|
||||
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<string, unknown>
|
||||
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<string, unknown>).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<string, unknown>).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<string, unknown>) : 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<Response> {
|
||||
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<string, unknown>): Promise<Response> {
|
||||
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<string, unknown>): Promise<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<Response> {
|
||||
const path = url.pathname
|
||||
@@ -76,6 +77,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'skills':
|
||||
return handleSkillsApi(req, url, segments)
|
||||
|
||||
case 'mcp':
|
||||
return handleMcpApi(req, url, segments)
|
||||
|
||||
case 'computer-use':
|
||||
return handleComputerUseApi(req, url, segments)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user