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