fix: avoid blocking MCP list on live health checks

Desktop MCP settings was synchronously probing every configured server from /api/mcp, which made the list page scale with connection latency and surface brittle behavior as installs accumulated more MCPs. Return lightweight snapshot rows from the list API, keep explicit status checks separate, and let the desktop UI refresh status in a constrained background lane while preserving project-aware server identity.

Constraint: MCP list must stay responsive even with many configured servers
Rejected: Probe all servers from the list view without limits | still fans out with server count and can overload slow installs
Rejected: Keep servers permanently unchecked until detail view | misses the desired loading feedback when the MCP page opens
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep /api/mcp as a configuration snapshot endpoint; do not reintroduce per-row live connect work on list load
Tested: bun test src/server/__tests__/mcp.test.ts; cd desktop && bun run test -- mcpSettings.test.tsx; cd desktop && bun x tsc --noEmit --ignoreDeprecations 5.0
Not-tested: Real desktop interaction against an environment with dozens of live MCP servers
This commit is contained in:
程序员阿江(Relakkes) 2026-04-22 15:34:36 +08:00
parent bef9596ed5
commit 49e1367659
7 changed files with 521 additions and 165 deletions

View File

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { McpSettings } from '../pages/McpSettings'
@ -38,6 +38,7 @@ describe('McpSettings', () => {
})
useMcpStore.setState({
servers: [],
selectedServer: null,
isLoading: false,
error: null,
fetchServers: vi.fn(),
@ -46,6 +47,8 @@ describe('McpSettings', () => {
deleteServer: vi.fn(),
toggleServer: vi.fn(),
reconnectServer: vi.fn(),
refreshServerStatus: vi.fn(),
selectServer: vi.fn(),
})
})
@ -109,4 +112,116 @@ describe('McpSettings', () => {
expect(screen.getByText('plugin:telegram:telegram')).toBeInTheDocument()
expect(screen.getByText('global-user')).toBeInTheDocument()
})
it('starts background status refresh after the fast list render', async () => {
const server = {
name: 'deepwiki',
scope: 'user',
transport: 'http',
enabled: true,
status: 'checking' as const,
statusLabel: 'Checking',
configLocation: '/tmp/config',
summary: 'https://example.com/mcp',
canEdit: true,
canRemove: true,
canReconnect: true,
canToggle: true,
config: { type: 'http' as const, url: 'https://example.com/mcp', headers: {} },
}
const refreshServerStatus = vi.fn().mockResolvedValue({
...server,
status: 'connected' as const,
statusLabel: 'Connected',
})
useMcpStore.setState({
servers: [server],
refreshServerStatus,
})
render(<McpSettings />)
expect(screen.getByText('Checking')).toBeInTheDocument()
await waitFor(() => {
expect(refreshServerStatus).toHaveBeenCalledWith(server, '/workspace/project')
})
})
it('opens the delete confirmation modal from the edit view and deletes with the active cwd', async () => {
const deleteServer = vi.fn().mockResolvedValue(undefined)
const server = {
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: {} },
} as const
useMcpStore.setState({
servers: [server],
deleteServer,
})
render(<McpSettings />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Open global-user' }))
})
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /uninstall/i }))
})
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Delete MCP server')).toBeInTheDocument()
expect(screen.getByText('Delete MCP server "global-user"? This action cannot be undone.')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
})
expect(deleteServer).toHaveBeenCalledWith(server, '/workspace/project')
})
it('uses the active cwd when toggling a server', async () => {
const toggleServer = vi.fn().mockResolvedValue(undefined)
const server = {
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: {} },
} as const
useMcpStore.setState({
servers: [server],
toggleServer,
})
render(<McpSettings />)
await act(async () => {
fireEvent.click(screen.getByRole('switch'))
})
expect(toggleServer).toHaveBeenCalledWith(server, '/workspace/project')
})
})

View File

@ -7,6 +7,11 @@ export const mcpApi = {
return api.get<{ servers: McpServerRecord[] }>(`/api/mcp${query}`)
},
status: (name: string, cwd?: string) => {
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
return api.get<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/status${query}`)
},
create: (name: string, payload: McpUpsertPayload, cwd?: string) => {
return api.post<{ server: McpServerRecord }>('/api/mcp', {
name,
@ -36,4 +41,3 @@ export const mcpApi = {
return api.post<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/reconnect`, cwd ? { cwd } : {})
},
}

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '../components/shared/Button'
import { Input } from '../components/shared/Input'
import { Modal } from '../components/shared/Modal'
@ -63,6 +63,7 @@ const MCP_GROUP_ORDER: McpGroupKey[] = [
const STATUS_TONE: Record<McpServerRecord['status'], string> = {
connected: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
checking: 'bg-slate-500/10 text-slate-600 border-slate-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)]',
@ -228,6 +229,14 @@ function scopeLabel(server: McpServerRecord, t: ReturnType<typeof useTranslation
return t(`settings.mcp.scope.${group}`)
}
function getServerIdentityKey(server: Pick<McpServerRecord, 'name' | 'scope' | 'projectPath'>) {
if (server.scope === 'local' || server.scope === 'project') {
return `${server.scope}:${server.projectPath ?? ''}:${server.name}`
}
return `${server.scope}:${server.name}`
}
function ToggleSwitch({
checked,
disabled,
@ -382,7 +391,7 @@ function ServerRow({
}
export function McpSettings() {
const { servers, selectedServer, isLoading, error, fetchServers, createServer, updateServer, deleteServer, toggleServer, reconnectServer, selectServer } = useMcpStore()
const { servers, selectedServer, isLoading, error, fetchServers, createServer, updateServer, deleteServer, toggleServer, reconnectServer, refreshServerStatus, selectServer } = useMcpStore()
const addToast = useUIStore((s) => s.addToast)
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
@ -393,9 +402,11 @@ export function McpSettings() {
const [isDeleting, setIsDeleting] = useState(false)
const [busyServerName, setBusyServerName] = useState<string | null>(null)
const [pendingDeleteServer, setPendingDeleteServer] = useState<McpServerRecord | null>(null)
const refreshInFlightRef = useRef(new Set<string>())
const activeSession = sessions.find((session) => session.id === activeSessionId)
const currentWorkDir = activeSession?.workDir || undefined
const resolveOperationCwd = (server?: McpServerRecord) => server?.projectPath ?? currentWorkDir
useEffect(() => {
void fetchServers(undefined, currentWorkDir)
@ -441,10 +452,55 @@ export function McpSettings() {
}
}, [selectedServer])
useEffect(() => {
const pendingServers = servers.filter((server) => (
server.enabled &&
server.status === 'checking' &&
!refreshInFlightRef.current.has(getServerIdentityKey(server))
))
if (pendingServers.length === 0) return
let cancelled = false
const queue = [...pendingServers]
const workerCount = Math.min(2, queue.length)
const runWorker = async () => {
while (!cancelled) {
const server = queue.shift()
if (!server) return
const key = getServerIdentityKey(server)
refreshInFlightRef.current.add(key)
try {
const updated = await refreshServerStatus(server, resolveOperationCwd(server))
if (cancelled) return
setView((current) => {
if (current.type !== 'details' && current.type !== 'edit') return current
if (getServerIdentityKey(current.server) !== key) return current
return { ...current, server: updated }
})
} catch {
// Keep passive checks silent. Explicit reconnect remains the action that
// surfaces failures to the user.
} finally {
refreshInFlightRef.current.delete(key)
}
}
}
void Promise.all(Array.from({ length: workerCount }, () => runWorker()))
return () => {
cancelled = true
}
}, [servers, refreshServerStatus, currentWorkDir])
const handleToggle = async (server: McpServerRecord) => {
setBusyServerName(server.name)
try {
const updated = await toggleServer(server.name, undefined)
const updated = await toggleServer(server, resolveOperationCwd(server))
addToast({
type: 'success',
message: updated.enabled ? t('settings.mcp.toast.enabled', { name: server.name }) : t('settings.mcp.toast.disabled', { name: server.name }),
@ -462,7 +518,7 @@ export function McpSettings() {
const handleReconnect = async (server: McpServerRecord) => {
setBusyServerName(server.name)
try {
const updated = await reconnectServer(server.name, undefined)
const updated = await reconnectServer(server, resolveOperationCwd(server))
addToast({
type: updated.status === 'connected' ? 'success' : 'warning',
message: updated.status === 'connected'
@ -490,7 +546,7 @@ export function McpSettings() {
if (!server) return
setIsDeleting(true)
try {
await deleteServer(server.name, server.scope, undefined)
await deleteServer(server, resolveOperationCwd(server))
addToast({
type: 'success',
message: t('settings.mcp.toast.deleted', { name: server.name }),
@ -508,14 +564,39 @@ export function McpSettings() {
}
}
const deleteModal = (
<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>
)
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)
? await updateServer(view.server, payload, resolveOperationCwd(view.server))
: await createServer(draft.name.trim(), payload, currentWorkDir)
addToast({
type: 'success',
@ -573,44 +654,47 @@ export function McpSettings() {
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="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 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>
{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>
)}
<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={scopeLabel(server, t)} />
<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>
<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={scopeLabel(server, t)} />
<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>
{deleteModal}
</>
)
}
@ -621,48 +705,49 @@ export function McpSettings() {
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="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 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="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">
<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')}
@ -802,7 +887,9 @@ export function McpSettings() {
</Button>
</div>
</div>
</div>
</div>
{deleteModal}
</>
)
}
@ -882,29 +969,7 @@ export function McpSettings() {
})}
</div>
)}
<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>
{deleteModal}
</div>
)
}

View File

@ -9,17 +9,49 @@ type McpStore = {
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>
updateServer: (server: McpServerRecord, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
deleteServer: (server: McpServerRecord, cwd?: string) => Promise<void>
toggleServer: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
reconnectServer: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
refreshServerStatus: (server: McpServerRecord, 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))
function isProjectScoped(server: Pick<McpServerRecord, 'scope'>) {
return server.scope === 'local' || server.scope === 'project'
}
function attachProjectPath(server: McpServerRecord, cwd?: string) {
if (!isProjectScoped(server)) {
return {
...server,
projectPath: undefined,
}
}
return {
...server,
projectPath: cwd ?? server.projectPath,
}
}
function isSameServer(a: Pick<McpServerRecord, 'name' | 'scope' | 'projectPath'>, b: Pick<McpServerRecord, 'name' | 'scope' | 'projectPath'>) {
if (a.name !== b.name || a.scope !== b.scope) return false
if (!isProjectScoped(a) && !isProjectScoped(b)) return true
return (a.projectPath ?? '') === (b.projectPath ?? '')
}
function replaceServer(
servers: McpServerRecord[],
previous: Pick<McpServerRecord, 'name' | 'scope' | 'projectPath'>,
next: McpServerRecord,
cwd?: string,
) {
const normalizedNext = attachProjectPath(next, cwd)
const index = servers.findIndex((item) => isSameServer(item, previous))
if (index === -1) return [...servers, normalizedNext]
return servers.map((item, itemIndex) => (itemIndex === index ? normalizedNext : item))
}
export const useMcpStore = create<McpStore>((set) => ({
@ -68,57 +100,69 @@ export const useMcpStore = create<McpStore>((set) => ({
createServer: async (name, payload, cwd) => {
const response = await mcpApi.create(name, payload, cwd)
const created = attachProjectPath(response.server, cwd)
set((state) => ({
servers: [...state.servers, response.server],
selectedServer: response.server,
servers: [...state.servers, created],
selectedServer: created,
error: null,
}))
return response.server
return created
},
updateServer: async (name, payload, cwd) => {
const response = await mcpApi.update(name, payload, cwd)
updateServer: async (server, payload, cwd) => {
const response = await mcpApi.update(server.name, payload, cwd)
const updated = attachProjectPath(response.server, cwd ?? server.projectPath)
set((state) => ({
servers: upsertByName(
state.servers.filter((server) => server.name !== name),
response.server,
),
selectedServer: response.server,
servers: replaceServer(state.servers, server, updated, cwd ?? server.projectPath),
selectedServer: state.selectedServer && isSameServer(state.selectedServer, server) ? updated : state.selectedServer,
error: null,
}))
return response.server
return updated
},
deleteServer: async (name, scope, cwd) => {
await mcpApi.remove(name, scope, cwd)
deleteServer: async (server, cwd) => {
await mcpApi.remove(server.name, server.scope, cwd)
set((state) => ({
servers: state.servers.filter((server) => !(server.name === name && server.scope === scope && (server.projectPath ?? '') === (cwd ?? ''))),
servers: state.servers.filter((item) => !isSameServer(item, server)),
selectedServer:
state.selectedServer?.name === name && state.selectedServer?.scope === scope
state.selectedServer && isSameServer(state.selectedServer, server)
? null
: state.selectedServer,
error: null,
}))
},
toggleServer: async (name, cwd) => {
const response = await mcpApi.toggle(name, cwd)
toggleServer: async (server, cwd) => {
const response = await mcpApi.toggle(server.name, cwd)
const updated = attachProjectPath(response.server, cwd ?? server.projectPath)
set((state) => ({
servers: upsertByName(state.servers, response.server),
selectedServer: state.selectedServer?.name === name ? response.server : state.selectedServer,
servers: replaceServer(state.servers, server, updated, cwd ?? server.projectPath),
selectedServer: state.selectedServer && isSameServer(state.selectedServer, server) ? updated : state.selectedServer,
error: null,
}))
return response.server
return updated
},
reconnectServer: async (name, cwd) => {
const response = await mcpApi.reconnect(name, cwd)
reconnectServer: async (server, cwd) => {
const response = await mcpApi.reconnect(server.name, cwd)
const updated = attachProjectPath(response.server, cwd ?? server.projectPath)
set((state) => ({
servers: upsertByName(state.servers, response.server),
selectedServer: state.selectedServer?.name === name ? response.server : state.selectedServer,
servers: replaceServer(state.servers, server, updated, cwd ?? server.projectPath),
selectedServer: state.selectedServer && isSameServer(state.selectedServer, server) ? updated : state.selectedServer,
error: null,
}))
return response.server
return updated
},
refreshServerStatus: async (server, cwd) => {
const response = await mcpApi.status(server.name, cwd)
const updated = attachProjectPath(response.server, cwd ?? server.projectPath)
set((state) => ({
servers: replaceServer(state.servers, server, updated, cwd ?? server.projectPath),
selectedServer: state.selectedServer && isSameServer(state.selectedServer, server) ? updated : state.selectedServer,
error: null,
}))
return updated
},
selectServer: (server) => set({ selectedServer: server }),

View File

@ -24,7 +24,7 @@ export type McpServerRecord = {
scope: string
transport: string
enabled: boolean
status: 'connected' | 'needs-auth' | 'failed' | 'disabled'
status: 'connected' | 'needs-auth' | 'failed' | 'disabled' | 'checking'
statusLabel: string
statusDetail?: string
configLocation: string

View File

@ -3,12 +3,15 @@ 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 * as mcpConfig from '../../services/mcp/config.js'
import { handleMcpApi } from '../api/mcp.js'
let tmpDir: string
let projectRoot: string
let originalConfigDir: string | undefined
let connectSpy: ReturnType<typeof spyOn> | undefined
let getClaudeCodeMcpConfigsSpy: ReturnType<typeof spyOn> | undefined
let reconnectSpy: ReturnType<typeof spyOn> | undefined
async function setup() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-mcp-test-'))
@ -65,6 +68,10 @@ describe('MCP API', () => {
afterEach(async () => {
connectSpy?.mockRestore()
connectSpy = undefined
getClaudeCodeMcpConfigsSpy?.mockRestore()
getClaudeCodeMcpConfigsSpy = undefined
reconnectSpy?.mockRestore()
reconnectSpy = undefined
await teardown()
})
@ -88,6 +95,7 @@ describe('MCP API', () => {
const createdBody = await createRes.json()
expect(createdBody.server.name).toBe('chrome-devtools')
expect(createdBody.server.transport).toBe('stdio')
expect(createdBody.server.status).toBe('checking')
const list = makeRequest('GET', `/api/mcp?cwd=${encodeURIComponent(projectRoot)}`)
const listRes = await handleMcpApi(list.req, list.url, list.segments)
@ -96,8 +104,32 @@ describe('MCP API', () => {
expect(listBody.servers).toHaveLength(1)
expect(listBody.servers[0].name).toBe('chrome-devtools')
expect(listBody.servers[0].status).toBe('connected')
expect(listBody.servers[0].status).toBe('checking')
expect(listBody.servers[0].config.command).toBe('npx')
expect(connectSpy).not.toHaveBeenCalled()
})
it('checks a single server status on demand', async () => {
const create = makeRequest('POST', '/api/mcp', {
cwd: projectRoot,
name: 'deepwiki',
scope: 'user',
config: {
type: 'http',
url: 'https://mcp.example.com/mcp',
headers: {},
},
})
await handleMcpApi(create.req, create.url, create.segments)
const status = makeRequest('GET', `/api/mcp/deepwiki/status?cwd=${encodeURIComponent(projectRoot)}`)
const statusRes = await handleMcpApi(status.req, status.url, status.segments)
expect(statusRes.status).toBe(200)
const body = await statusRes.json()
expect(body.server.name).toBe('deepwiki')
expect(body.server.status).toBe('connected')
expect(connectSpy).toHaveBeenCalled()
})
it('updates, toggles, and deletes MCP servers', async () => {
@ -153,4 +185,49 @@ describe('MCP API', () => {
const listBody = await listRes.json()
expect(listBody.servers.some((server: { name: string }) => server.name === 'context7')).toBe(false)
})
it('reconnects plugin-scoped MCP servers exposed via the merged server list', async () => {
const pluginServerName = 'plugin:telegram:telegram'
const pluginServerConfig = {
scope: 'dynamic',
type: 'stdio',
command: 'bun',
args: ['run', 'start'],
env: {
CLAUDE_PLUGIN_ROOT: '/tmp/telegram-plugin',
},
pluginSource: 'telegram@claude-plugins-official',
} as const
getClaudeCodeMcpConfigsSpy = spyOn(mcpConfig, 'getClaudeCodeMcpConfigs').mockResolvedValue({
servers: {
[pluginServerName]: pluginServerConfig,
},
errors: [],
})
reconnectSpy = spyOn(mcpClient, 'reconnectMcpServerImpl').mockResolvedValue({
name: pluginServerName,
client: {
name: pluginServerName,
type: 'connected',
client: {} as never,
capabilities: {},
config: pluginServerConfig,
cleanup: mock(async () => {}),
},
})
const reconnect = makeRequest('POST', `/api/mcp/${encodeURIComponent(pluginServerName)}/reconnect`, {
cwd: projectRoot,
})
const reconnectRes = await handleMcpApi(reconnect.req, reconnect.url, reconnect.segments)
expect(reconnectRes.status).toBe(200)
expect(reconnectSpy).toHaveBeenCalledWith(pluginServerName, pluginServerConfig)
const body = await reconnectRes.json()
expect(body.server.name).toBe(pluginServerName)
expect(body.server.scope).toBe('dynamic')
})
})

View File

@ -5,7 +5,6 @@ import {
import {
clearServerCache,
connectToServer,
getMcpServerConnectionBatchSize,
reconnectMcpServerImpl,
} from '../../services/mcp/client.js'
import {
@ -55,7 +54,7 @@ type McpServerDto = {
scope: string
transport: string
enabled: boolean
status: 'connected' | 'needs-auth' | 'failed' | 'disabled'
status: 'connected' | 'needs-auth' | 'failed' | 'disabled' | 'checking'
statusLabel: string
statusDetail?: string
configLocation: string
@ -153,11 +152,30 @@ function getStatusLabel(status: McpServerDto['status']): string {
return 'Unavailable'
case 'disabled':
return 'Disabled'
case 'checking':
return 'Checking'
default:
return status
}
}
function getInitialStatus(
enabled: boolean,
): Pick<McpServerDto, 'status' | 'statusDetail' | 'statusLabel'> {
if (!enabled) {
return {
status: 'disabled',
statusLabel: getStatusLabel('disabled'),
statusDetail: 'Server disabled for the current project',
}
}
return {
status: 'checking',
statusLabel: getStatusLabel('checking'),
}
}
async function inspectServerStatus(
name: string,
config: ScopedMcpServerConfig,
@ -197,12 +215,12 @@ async function inspectServerStatus(
}
}
async function serializeServer(
function buildServerDto(
name: string,
config: ScopedMcpServerConfig,
): Promise<McpServerDto> {
status: Pick<McpServerDto, 'status' | 'statusDetail' | 'statusLabel'>,
): 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')
@ -210,7 +228,7 @@ async function serializeServer(
name,
scope: config.scope,
transport,
enabled,
enabled: !isMcpServerDisabled(name),
status: status.status,
statusLabel: status.statusLabel,
statusDetail: status.statusDetail,
@ -224,6 +242,34 @@ async function serializeServer(
}
}
function serializeServerSnapshot(
name: string,
config: ScopedMcpServerConfig,
): McpServerDto {
return buildServerDto(name, config, getInitialStatus(!isMcpServerDisabled(name)))
}
async function serializeServerWithLiveStatus(
name: string,
config: ScopedMcpServerConfig,
): Promise<McpServerDto> {
const enabled = !isMcpServerDisabled(name)
const status = await inspectServerStatus(name, config, enabled)
return buildServerDto(name, config, status)
}
async function resolveServerForRuntimeAction(
name: string,
): Promise<ScopedMcpServerConfig | null> {
const configured = getMcpConfigByName(name)
if (configured) {
return configured
}
const { servers } = await getClaudeCodeMcpConfigs()
return servers[name] ?? null
}
function buildServerConfig(config: unknown): McpServerConfig {
if (!config || typeof config !== 'object') {
throw ApiError.badRequest('Missing or invalid "config" in request body')
@ -309,19 +355,20 @@ async function listServers(): Promise<Response> {
const visibleServers = Object.entries(servers)
.filter(([name, config]) => isVisibleServer(name, config))
.sort((a, b) => a[0].localeCompare(b[0]))
return Response.json({
servers: visibleServers.map(([name, config]) => serializeServerSnapshot(name, config)),
})
}
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)
async function getServerStatus(name: string): Promise<Response> {
const existing = await resolveServerForRuntimeAction(name)
if (!existing) {
throw ApiError.notFound(`MCP server not found: ${name}`)
}
return Response.json({ servers: items })
return Response.json({
server: await serializeServerWithLiveStatus(name, existing),
})
}
async function createServer(body: Record<string, unknown>): Promise<Response> {
@ -348,7 +395,7 @@ async function createServer(body: Record<string, unknown>): Promise<Response> {
throw ApiError.internal(`Created MCP server "${name}" could not be reloaded`)
}
return Response.json({ server: await serializeServer(name, created) }, { status: 201 })
return Response.json({ server: serializeServerSnapshot(name, created) }, { status: 201 })
}
async function updateServer(name: string, body: Record<string, unknown>): Promise<Response> {
@ -391,7 +438,7 @@ async function updateServer(name: string, body: Record<string, unknown>): Promis
throw ApiError.internal(`Updated MCP server "${name}" could not be reloaded`)
}
return Response.json({ server: await serializeServer(name, updated) })
return Response.json({ server: serializeServerSnapshot(name, updated) })
}
async function deleteServer(name: string, url: URL): Promise<Response> {
@ -409,7 +456,7 @@ async function deleteServer(name: string, url: URL): Promise<Response> {
}
async function toggleServer(name: string): Promise<Response> {
const existing = getMcpConfigByName(name)
const existing = await resolveServerForRuntimeAction(name)
if (!existing) {
throw ApiError.notFound(`MCP server not found: ${name}`)
}
@ -419,14 +466,14 @@ async function toggleServer(name: string): Promise<Response> {
if (!enabled) {
await clearServerCache(name, existing).catch(() => {})
const updated = await serializeServer(name, existing)
const updated = serializeServerSnapshot(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 updated = await serializeServerWithLiveStatus(name, existing)
const statusDetail =
result.client.type === 'failed' && 'error' in result.client ? result.client.error : undefined
@ -439,7 +486,7 @@ async function toggleServer(name: string): Promise<Response> {
}
async function reconnectServer(name: string): Promise<Response> {
const existing = getMcpConfigByName(name)
const existing = await resolveServerForRuntimeAction(name)
if (!existing) {
throw ApiError.notFound(`MCP server not found: ${name}`)
}
@ -447,7 +494,7 @@ async function reconnectServer(name: string): Promise<Response> {
const result = await reconnectMcpServerImpl(name, existing)
await clearServerCache(name, existing).catch(() => {})
const server = await serializeServer(name, existing)
const server = await serializeServerWithLiveStatus(name, existing)
const statusDetail =
result.client.type === 'failed' && 'error' in result.client ? result.client.error : undefined
@ -479,6 +526,10 @@ export async function handleMcpApi(
return listServers()
}
if (req.method === 'GET' && serverName && action === 'status') {
return getServerStatus(serverName)
}
if (req.method === 'POST' && !serverName) {
return createServer(body ?? {})
}