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 ?? {}) }