From 49e1367659fdc2e81fc6b3b507a79ed9c61c9b1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 22 Apr 2026 15:34:36 +0800 Subject: [PATCH] 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 --- desktop/src/__tests__/mcpSettings.test.tsx | 117 ++++++++- desktop/src/api/mcp.ts | 6 +- desktop/src/pages/McpSettings.tsx | 275 +++++++++++++-------- desktop/src/stores/mcpStore.ts | 110 ++++++--- desktop/src/types/mcp.ts | 2 +- src/server/__tests__/mcp.test.ts | 79 +++++- src/server/api/mcp.ts | 97 ++++++-- 7 files changed, 521 insertions(+), 165 deletions(-) diff --git a/desktop/src/__tests__/mcpSettings.test.tsx b/desktop/src/__tests__/mcpSettings.test.tsx index 973cad37..f1e3df51 100644 --- a/desktop/src/__tests__/mcpSettings.test.tsx +++ b/desktop/src/__tests__/mcpSettings.test.tsx @@ -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() + + 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() + + 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() + + await act(async () => { + fireEvent.click(screen.getByRole('switch')) + }) + + expect(toggleServer).toHaveBeenCalledWith(server, '/workspace/project') + }) }) diff --git a/desktop/src/api/mcp.ts b/desktop/src/api/mcp.ts index 550c9b67..113aa81b 100644 --- a/desktop/src/api/mcp.ts +++ b/desktop/src/api/mcp.ts @@ -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 } : {}) }, } - diff --git a/desktop/src/pages/McpSettings.tsx b/desktop/src/pages/McpSettings.tsx index a25e23bc..a1087c36 100644 --- a/desktop/src/pages/McpSettings.tsx +++ b/desktop/src/pages/McpSettings.tsx @@ -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 = { 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) { + 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(null) const [pendingDeleteServer, setPendingDeleteServer] = useState(null) + const refreshInFlightRef = useRef(new Set()) 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 = ( + { + if (isDeleting) return + setPendingDeleteServer(null) + }} + title={t('settings.mcp.form.deleteTitle')} + footer={( + <> + + + + )} + > +

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

+
+ ) + 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 ( -
- + <> +
+ -
-
-

{server.name}

-

{server.summary}

+
+
+

{server.name}

+

{server.summary}

+
+ {server.canReconnect && ( + + )}
- {server.canReconnect && ( - - )} + +
+
+ + + + +
+
+
{t('settings.mcp.form.rawConfig')}
+
+                {JSON.stringify(server.config, null, 2)}
+              
+
+
- -
-
- - - - -
-
-
{t('settings.mcp.form.rawConfig')}
-
-              {JSON.stringify(server.config, null, 2)}
-            
-
-
-
+ {deleteModal} + ) } @@ -621,48 +705,49 @@ export function McpSettings() { const isBusy = isSaving || isDeleting return ( -
- + <> +
+ -
-
-

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

-

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

+
+
+

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

+

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

+
+ +
+ {editing && targetServer?.canReconnect && ( + + )} + {editing && targetServer?.canRemove && ( + + )} +
-
- {editing && targetServer?.canReconnect && ( - - )} - {editing && targetServer?.canRemove && ( - - )} -
-
- -
+
-
+
+ {deleteModal} + ) } @@ -882,29 +969,7 @@ export function McpSettings() { })}
)} - - { - if (isDeleting) return - setPendingDeleteServer(null) - }} - title={t('settings.mcp.form.deleteTitle')} - footer={( - <> - - - - )} - > -

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

-
+ {deleteModal}
) } diff --git a/desktop/src/stores/mcpStore.ts b/desktop/src/stores/mcpStore.ts index 18770e42..905848f0 100644 --- a/desktop/src/stores/mcpStore.ts +++ b/desktop/src/stores/mcpStore.ts @@ -9,17 +9,49 @@ type McpStore = { error: string | null fetchServers: (projectPaths?: string[], fallbackCwd?: string) => Promise createServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise - updateServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise - deleteServer: (name: string, scope: string, cwd?: string) => Promise - toggleServer: (name: string, cwd?: string) => Promise - reconnectServer: (name: string, cwd?: string) => Promise + updateServer: (server: McpServerRecord, payload: McpUpsertPayload, cwd?: string) => Promise + deleteServer: (server: McpServerRecord, cwd?: string) => Promise + toggleServer: (server: McpServerRecord, cwd?: string) => Promise + reconnectServer: (server: McpServerRecord, cwd?: string) => Promise + refreshServerStatus: (server: McpServerRecord, cwd?: string) => Promise selectServer: (server: McpServerRecord | null) => void } -function upsertByName(servers: McpServerRecord[], server: McpServerRecord) { - const index = servers.findIndex((item) => item.name === server.name) - if (index === -1) return [...servers, server] - return servers.map((item, itemIndex) => (itemIndex === index ? server : item)) +function isProjectScoped(server: Pick) { + 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, b: Pick) { + 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, + 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((set) => ({ @@ -68,57 +100,69 @@ export const useMcpStore = create((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 }), diff --git a/desktop/src/types/mcp.ts b/desktop/src/types/mcp.ts index cba6b4ff..247c05db 100644 --- a/desktop/src/types/mcp.ts +++ b/desktop/src/types/mcp.ts @@ -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 diff --git a/src/server/__tests__/mcp.test.ts b/src/server/__tests__/mcp.test.ts index 0ccb7f64..68db834e 100644 --- a/src/server/__tests__/mcp.test.ts +++ b/src/server/__tests__/mcp.test.ts @@ -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 | undefined +let getClaudeCodeMcpConfigsSpy: ReturnType | undefined +let reconnectSpy: ReturnType | 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') + }) }) diff --git a/src/server/api/mcp.ts b/src/server/api/mcp.ts index a5427268..b48989e7 100644 --- a/src/server/api/mcp.ts +++ b/src/server/api/mcp.ts @@ -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 { + 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 { + status: Pick, +): 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 { + const enabled = !isMcpServerDisabled(name) + const status = await inspectServerStatus(name, config, enabled) + return buildServerDto(name, config, status) +} + +async function resolveServerForRuntimeAction( + name: string, +): Promise { + 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 { 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 { + 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): Promise { @@ -348,7 +395,7 @@ async function createServer(body: Record): Promise { 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): Promise { @@ -391,7 +438,7 @@ async function updateServer(name: string, body: Record): 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 { @@ -409,7 +456,7 @@ async function deleteServer(name: string, url: URL): Promise { } async function toggleServer(name: string): Promise { - 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 { 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 { } async function reconnectServer(name: string): Promise { - 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 { 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 ?? {}) }