mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
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:
parent
bef9596ed5
commit
49e1367659
@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
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 '@testing-library/jest-dom'
|
||||||
|
|
||||||
import { McpSettings } from '../pages/McpSettings'
|
import { McpSettings } from '../pages/McpSettings'
|
||||||
@ -38,6 +38,7 @@ describe('McpSettings', () => {
|
|||||||
})
|
})
|
||||||
useMcpStore.setState({
|
useMcpStore.setState({
|
||||||
servers: [],
|
servers: [],
|
||||||
|
selectedServer: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
fetchServers: vi.fn(),
|
fetchServers: vi.fn(),
|
||||||
@ -46,6 +47,8 @@ describe('McpSettings', () => {
|
|||||||
deleteServer: vi.fn(),
|
deleteServer: vi.fn(),
|
||||||
toggleServer: vi.fn(),
|
toggleServer: vi.fn(),
|
||||||
reconnectServer: 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('plugin:telegram:telegram')).toBeInTheDocument()
|
||||||
expect(screen.getByText('global-user')).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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -7,6 +7,11 @@ export const mcpApi = {
|
|||||||
return api.get<{ servers: McpServerRecord[] }>(`/api/mcp${query}`)
|
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) => {
|
create: (name: string, payload: McpUpsertPayload, cwd?: string) => {
|
||||||
return api.post<{ server: McpServerRecord }>('/api/mcp', {
|
return api.post<{ server: McpServerRecord }>('/api/mcp', {
|
||||||
name,
|
name,
|
||||||
@ -36,4 +41,3 @@ export const mcpApi = {
|
|||||||
return api.post<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/reconnect`, cwd ? { cwd } : {})
|
return api.post<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/reconnect`, cwd ? { cwd } : {})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Button } from '../components/shared/Button'
|
import { Button } from '../components/shared/Button'
|
||||||
import { Input } from '../components/shared/Input'
|
import { Input } from '../components/shared/Input'
|
||||||
import { Modal } from '../components/shared/Modal'
|
import { Modal } from '../components/shared/Modal'
|
||||||
@ -63,6 +63,7 @@ const MCP_GROUP_ORDER: McpGroupKey[] = [
|
|||||||
|
|
||||||
const STATUS_TONE: Record<McpServerRecord['status'], string> = {
|
const STATUS_TONE: Record<McpServerRecord['status'], string> = {
|
||||||
connected: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
|
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',
|
'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',
|
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)]',
|
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}`)
|
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({
|
function ToggleSwitch({
|
||||||
checked,
|
checked,
|
||||||
disabled,
|
disabled,
|
||||||
@ -382,7 +391,7 @@ function ServerRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function McpSettings() {
|
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 addToast = useUIStore((s) => s.addToast)
|
||||||
const sessions = useSessionStore((s) => s.sessions)
|
const sessions = useSessionStore((s) => s.sessions)
|
||||||
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||||
@ -393,9 +402,11 @@ export function McpSettings() {
|
|||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [busyServerName, setBusyServerName] = useState<string | null>(null)
|
const [busyServerName, setBusyServerName] = useState<string | null>(null)
|
||||||
const [pendingDeleteServer, setPendingDeleteServer] = useState<McpServerRecord | null>(null)
|
const [pendingDeleteServer, setPendingDeleteServer] = useState<McpServerRecord | null>(null)
|
||||||
|
const refreshInFlightRef = useRef(new Set<string>())
|
||||||
|
|
||||||
const activeSession = sessions.find((session) => session.id === activeSessionId)
|
const activeSession = sessions.find((session) => session.id === activeSessionId)
|
||||||
const currentWorkDir = activeSession?.workDir || undefined
|
const currentWorkDir = activeSession?.workDir || undefined
|
||||||
|
const resolveOperationCwd = (server?: McpServerRecord) => server?.projectPath ?? currentWorkDir
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchServers(undefined, currentWorkDir)
|
void fetchServers(undefined, currentWorkDir)
|
||||||
@ -441,10 +452,55 @@ export function McpSettings() {
|
|||||||
}
|
}
|
||||||
}, [selectedServer])
|
}, [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) => {
|
const handleToggle = async (server: McpServerRecord) => {
|
||||||
setBusyServerName(server.name)
|
setBusyServerName(server.name)
|
||||||
try {
|
try {
|
||||||
const updated = await toggleServer(server.name, undefined)
|
const updated = await toggleServer(server, resolveOperationCwd(server))
|
||||||
addToast({
|
addToast({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: updated.enabled ? t('settings.mcp.toast.enabled', { name: server.name }) : t('settings.mcp.toast.disabled', { name: server.name }),
|
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) => {
|
const handleReconnect = async (server: McpServerRecord) => {
|
||||||
setBusyServerName(server.name)
|
setBusyServerName(server.name)
|
||||||
try {
|
try {
|
||||||
const updated = await reconnectServer(server.name, undefined)
|
const updated = await reconnectServer(server, resolveOperationCwd(server))
|
||||||
addToast({
|
addToast({
|
||||||
type: updated.status === 'connected' ? 'success' : 'warning',
|
type: updated.status === 'connected' ? 'success' : 'warning',
|
||||||
message: updated.status === 'connected'
|
message: updated.status === 'connected'
|
||||||
@ -490,7 +546,7 @@ export function McpSettings() {
|
|||||||
if (!server) return
|
if (!server) return
|
||||||
setIsDeleting(true)
|
setIsDeleting(true)
|
||||||
try {
|
try {
|
||||||
await deleteServer(server.name, server.scope, undefined)
|
await deleteServer(server, resolveOperationCwd(server))
|
||||||
addToast({
|
addToast({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: t('settings.mcp.toast.deleted', { name: server.name }),
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (!isDraftValid(draft)) return
|
if (!isDraftValid(draft)) return
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
const payload = buildPayload(draft)
|
const payload = buildPayload(draft)
|
||||||
const saved = view.type === 'edit'
|
const saved = view.type === 'edit'
|
||||||
? await updateServer(view.server.name, payload, undefined)
|
? await updateServer(view.server, payload, resolveOperationCwd(view.server))
|
||||||
: await createServer(draft.name.trim(), payload, undefined)
|
: await createServer(draft.name.trim(), payload, currentWorkDir)
|
||||||
|
|
||||||
addToast({
|
addToast({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
@ -573,6 +654,7 @@ export function McpSettings() {
|
|||||||
if (view.type === 'details') {
|
if (view.type === 'details') {
|
||||||
const server = view.server
|
const server = view.server
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="max-w-5xl min-w-0">
|
<div className="max-w-5xl min-w-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -611,6 +693,8 @@ export function McpSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
{deleteModal}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -621,6 +705,7 @@ export function McpSettings() {
|
|||||||
const isBusy = isSaving || isDeleting
|
const isBusy = isSaving || isDeleting
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="max-w-5xl min-w-0">
|
<div className="max-w-5xl min-w-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -803,6 +888,8 @@ export function McpSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{deleteModal}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -882,29 +969,7 @@ export function McpSettings() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,17 +9,49 @@ type McpStore = {
|
|||||||
error: string | null
|
error: string | null
|
||||||
fetchServers: (projectPaths?: string[], fallbackCwd?: string) => Promise<void>
|
fetchServers: (projectPaths?: string[], fallbackCwd?: string) => Promise<void>
|
||||||
createServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
|
createServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
|
||||||
updateServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
|
updateServer: (server: McpServerRecord, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
|
||||||
deleteServer: (name: string, scope: string, cwd?: string) => Promise<void>
|
deleteServer: (server: McpServerRecord, cwd?: string) => Promise<void>
|
||||||
toggleServer: (name: string, cwd?: string) => Promise<McpServerRecord>
|
toggleServer: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
|
||||||
reconnectServer: (name: string, cwd?: string) => Promise<McpServerRecord>
|
reconnectServer: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
|
||||||
|
refreshServerStatus: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
|
||||||
selectServer: (server: McpServerRecord | null) => void
|
selectServer: (server: McpServerRecord | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertByName(servers: McpServerRecord[], server: McpServerRecord) {
|
function isProjectScoped(server: Pick<McpServerRecord, 'scope'>) {
|
||||||
const index = servers.findIndex((item) => item.name === server.name)
|
return server.scope === 'local' || server.scope === 'project'
|
||||||
if (index === -1) return [...servers, server]
|
}
|
||||||
return servers.map((item, itemIndex) => (itemIndex === index ? server : item))
|
|
||||||
|
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) => ({
|
export const useMcpStore = create<McpStore>((set) => ({
|
||||||
@ -68,57 +100,69 @@ export const useMcpStore = create<McpStore>((set) => ({
|
|||||||
|
|
||||||
createServer: async (name, payload, cwd) => {
|
createServer: async (name, payload, cwd) => {
|
||||||
const response = await mcpApi.create(name, payload, cwd)
|
const response = await mcpApi.create(name, payload, cwd)
|
||||||
|
const created = attachProjectPath(response.server, cwd)
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
servers: [...state.servers, response.server],
|
servers: [...state.servers, created],
|
||||||
selectedServer: response.server,
|
selectedServer: created,
|
||||||
error: null,
|
error: null,
|
||||||
}))
|
}))
|
||||||
return response.server
|
return created
|
||||||
},
|
},
|
||||||
|
|
||||||
updateServer: async (name, payload, cwd) => {
|
updateServer: async (server, payload, cwd) => {
|
||||||
const response = await mcpApi.update(name, payload, cwd)
|
const response = await mcpApi.update(server.name, payload, cwd)
|
||||||
|
const updated = attachProjectPath(response.server, cwd ?? server.projectPath)
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
servers: upsertByName(
|
servers: replaceServer(state.servers, server, updated, cwd ?? server.projectPath),
|
||||||
state.servers.filter((server) => server.name !== name),
|
selectedServer: state.selectedServer && isSameServer(state.selectedServer, server) ? updated : state.selectedServer,
|
||||||
response.server,
|
|
||||||
),
|
|
||||||
selectedServer: response.server,
|
|
||||||
error: null,
|
error: null,
|
||||||
}))
|
}))
|
||||||
return response.server
|
return updated
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteServer: async (name, scope, cwd) => {
|
deleteServer: async (server, cwd) => {
|
||||||
await mcpApi.remove(name, scope, cwd)
|
await mcpApi.remove(server.name, server.scope, cwd)
|
||||||
set((state) => ({
|
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:
|
selectedServer:
|
||||||
state.selectedServer?.name === name && state.selectedServer?.scope === scope
|
state.selectedServer && isSameServer(state.selectedServer, server)
|
||||||
? null
|
? null
|
||||||
: state.selectedServer,
|
: state.selectedServer,
|
||||||
error: null,
|
error: null,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleServer: async (name, cwd) => {
|
toggleServer: async (server, cwd) => {
|
||||||
const response = await mcpApi.toggle(name, cwd)
|
const response = await mcpApi.toggle(server.name, cwd)
|
||||||
|
const updated = attachProjectPath(response.server, cwd ?? server.projectPath)
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
servers: upsertByName(state.servers, response.server),
|
servers: replaceServer(state.servers, server, updated, cwd ?? server.projectPath),
|
||||||
selectedServer: state.selectedServer?.name === name ? response.server : state.selectedServer,
|
selectedServer: state.selectedServer && isSameServer(state.selectedServer, server) ? updated : state.selectedServer,
|
||||||
error: null,
|
error: null,
|
||||||
}))
|
}))
|
||||||
return response.server
|
return updated
|
||||||
},
|
},
|
||||||
|
|
||||||
reconnectServer: async (name, cwd) => {
|
reconnectServer: async (server, cwd) => {
|
||||||
const response = await mcpApi.reconnect(name, cwd)
|
const response = await mcpApi.reconnect(server.name, cwd)
|
||||||
|
const updated = attachProjectPath(response.server, cwd ?? server.projectPath)
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
servers: upsertByName(state.servers, response.server),
|
servers: replaceServer(state.servers, server, updated, cwd ?? server.projectPath),
|
||||||
selectedServer: state.selectedServer?.name === name ? response.server : state.selectedServer,
|
selectedServer: state.selectedServer && isSameServer(state.selectedServer, server) ? updated : state.selectedServer,
|
||||||
error: null,
|
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 }),
|
selectServer: (server) => set({ selectedServer: server }),
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export type McpServerRecord = {
|
|||||||
scope: string
|
scope: string
|
||||||
transport: string
|
transport: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
status: 'connected' | 'needs-auth' | 'failed' | 'disabled'
|
status: 'connected' | 'needs-auth' | 'failed' | 'disabled' | 'checking'
|
||||||
statusLabel: string
|
statusLabel: string
|
||||||
statusDetail?: string
|
statusDetail?: string
|
||||||
configLocation: string
|
configLocation: string
|
||||||
|
|||||||
@ -3,12 +3,15 @@ import * as fs from 'fs/promises'
|
|||||||
import * as os from 'os'
|
import * as os from 'os'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import * as mcpClient from '../../services/mcp/client.js'
|
import * as mcpClient from '../../services/mcp/client.js'
|
||||||
|
import * as mcpConfig from '../../services/mcp/config.js'
|
||||||
import { handleMcpApi } from '../api/mcp.js'
|
import { handleMcpApi } from '../api/mcp.js'
|
||||||
|
|
||||||
let tmpDir: string
|
let tmpDir: string
|
||||||
let projectRoot: string
|
let projectRoot: string
|
||||||
let originalConfigDir: string | undefined
|
let originalConfigDir: string | undefined
|
||||||
let connectSpy: ReturnType<typeof spyOn> | undefined
|
let connectSpy: ReturnType<typeof spyOn> | undefined
|
||||||
|
let getClaudeCodeMcpConfigsSpy: ReturnType<typeof spyOn> | undefined
|
||||||
|
let reconnectSpy: ReturnType<typeof spyOn> | undefined
|
||||||
|
|
||||||
async function setup() {
|
async function setup() {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-mcp-test-'))
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-mcp-test-'))
|
||||||
@ -65,6 +68,10 @@ describe('MCP API', () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
connectSpy?.mockRestore()
|
connectSpy?.mockRestore()
|
||||||
connectSpy = undefined
|
connectSpy = undefined
|
||||||
|
getClaudeCodeMcpConfigsSpy?.mockRestore()
|
||||||
|
getClaudeCodeMcpConfigsSpy = undefined
|
||||||
|
reconnectSpy?.mockRestore()
|
||||||
|
reconnectSpy = undefined
|
||||||
await teardown()
|
await teardown()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -88,6 +95,7 @@ describe('MCP API', () => {
|
|||||||
const createdBody = await createRes.json()
|
const createdBody = await createRes.json()
|
||||||
expect(createdBody.server.name).toBe('chrome-devtools')
|
expect(createdBody.server.name).toBe('chrome-devtools')
|
||||||
expect(createdBody.server.transport).toBe('stdio')
|
expect(createdBody.server.transport).toBe('stdio')
|
||||||
|
expect(createdBody.server.status).toBe('checking')
|
||||||
|
|
||||||
const list = makeRequest('GET', `/api/mcp?cwd=${encodeURIComponent(projectRoot)}`)
|
const list = makeRequest('GET', `/api/mcp?cwd=${encodeURIComponent(projectRoot)}`)
|
||||||
const listRes = await handleMcpApi(list.req, list.url, list.segments)
|
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).toHaveLength(1)
|
||||||
expect(listBody.servers[0].name).toBe('chrome-devtools')
|
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(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 () => {
|
it('updates, toggles, and deletes MCP servers', async () => {
|
||||||
@ -153,4 +185,49 @@ describe('MCP API', () => {
|
|||||||
const listBody = await listRes.json()
|
const listBody = await listRes.json()
|
||||||
expect(listBody.servers.some((server: { name: string }) => server.name === 'context7')).toBe(false)
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
clearServerCache,
|
clearServerCache,
|
||||||
connectToServer,
|
connectToServer,
|
||||||
getMcpServerConnectionBatchSize,
|
|
||||||
reconnectMcpServerImpl,
|
reconnectMcpServerImpl,
|
||||||
} from '../../services/mcp/client.js'
|
} from '../../services/mcp/client.js'
|
||||||
import {
|
import {
|
||||||
@ -55,7 +54,7 @@ type McpServerDto = {
|
|||||||
scope: string
|
scope: string
|
||||||
transport: string
|
transport: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
status: 'connected' | 'needs-auth' | 'failed' | 'disabled'
|
status: 'connected' | 'needs-auth' | 'failed' | 'disabled' | 'checking'
|
||||||
statusLabel: string
|
statusLabel: string
|
||||||
statusDetail?: string
|
statusDetail?: string
|
||||||
configLocation: string
|
configLocation: string
|
||||||
@ -153,11 +152,30 @@ function getStatusLabel(status: McpServerDto['status']): string {
|
|||||||
return 'Unavailable'
|
return 'Unavailable'
|
||||||
case 'disabled':
|
case 'disabled':
|
||||||
return 'Disabled'
|
return 'Disabled'
|
||||||
|
case 'checking':
|
||||||
|
return 'Checking'
|
||||||
default:
|
default:
|
||||||
return status
|
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(
|
async function inspectServerStatus(
|
||||||
name: string,
|
name: string,
|
||||||
config: ScopedMcpServerConfig,
|
config: ScopedMcpServerConfig,
|
||||||
@ -197,12 +215,12 @@ async function inspectServerStatus(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function serializeServer(
|
function buildServerDto(
|
||||||
name: string,
|
name: string,
|
||||||
config: ScopedMcpServerConfig,
|
config: ScopedMcpServerConfig,
|
||||||
): Promise<McpServerDto> {
|
status: Pick<McpServerDto, 'status' | 'statusDetail' | 'statusLabel'>,
|
||||||
|
): McpServerDto {
|
||||||
const enabled = !isMcpServerDisabled(name)
|
const enabled = !isMcpServerDisabled(name)
|
||||||
const status = await inspectServerStatus(name, config, enabled)
|
|
||||||
const transport = config.type ?? 'stdio'
|
const transport = config.type ?? 'stdio'
|
||||||
const canEdit = EDITABLE_SCOPES.has(config.scope) && (transport === 'stdio' || transport === 'http' || transport === 'sse')
|
const canEdit = EDITABLE_SCOPES.has(config.scope) && (transport === 'stdio' || transport === 'http' || transport === 'sse')
|
||||||
|
|
||||||
@ -210,7 +228,7 @@ async function serializeServer(
|
|||||||
name,
|
name,
|
||||||
scope: config.scope,
|
scope: config.scope,
|
||||||
transport,
|
transport,
|
||||||
enabled,
|
enabled: !isMcpServerDisabled(name),
|
||||||
status: status.status,
|
status: status.status,
|
||||||
statusLabel: status.statusLabel,
|
statusLabel: status.statusLabel,
|
||||||
statusDetail: status.statusDetail,
|
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 {
|
function buildServerConfig(config: unknown): McpServerConfig {
|
||||||
if (!config || typeof config !== 'object') {
|
if (!config || typeof config !== 'object') {
|
||||||
throw ApiError.badRequest('Missing or invalid "config" in request body')
|
throw ApiError.badRequest('Missing or invalid "config" in request body')
|
||||||
@ -309,19 +355,20 @@ async function listServers(): Promise<Response> {
|
|||||||
const visibleServers = Object.entries(servers)
|
const visibleServers = Object.entries(servers)
|
||||||
.filter(([name, config]) => isVisibleServer(name, config))
|
.filter(([name, config]) => isVisibleServer(name, config))
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
.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())
|
async function getServerStatus(name: string): Promise<Response> {
|
||||||
const items: McpServerDto[] = []
|
const existing = await resolveServerForRuntimeAction(name)
|
||||||
|
if (!existing) {
|
||||||
for (let index = 0; index < visibleServers.length; index += concurrency) {
|
throw ApiError.notFound(`MCP server not found: ${name}`)
|
||||||
const chunk = visibleServers.slice(index, index + concurrency)
|
|
||||||
const chunkItems = await Promise.all(
|
|
||||||
chunk.map(([name, config]) => serializeServer(name, config)),
|
|
||||||
)
|
|
||||||
items.push(...chunkItems)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({ servers: items })
|
return Response.json({
|
||||||
|
server: await serializeServerWithLiveStatus(name, existing),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createServer(body: Record<string, unknown>): Promise<Response> {
|
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`)
|
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> {
|
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`)
|
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> {
|
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> {
|
async function toggleServer(name: string): Promise<Response> {
|
||||||
const existing = getMcpConfigByName(name)
|
const existing = await resolveServerForRuntimeAction(name)
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw ApiError.notFound(`MCP server not found: ${name}`)
|
throw ApiError.notFound(`MCP server not found: ${name}`)
|
||||||
}
|
}
|
||||||
@ -419,14 +466,14 @@ async function toggleServer(name: string): Promise<Response> {
|
|||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
await clearServerCache(name, existing).catch(() => {})
|
await clearServerCache(name, existing).catch(() => {})
|
||||||
const updated = await serializeServer(name, existing)
|
const updated = serializeServerSnapshot(name, existing)
|
||||||
return Response.json({ server: updated })
|
return Response.json({ server: updated })
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await reconnectMcpServerImpl(name, existing)
|
const result = await reconnectMcpServerImpl(name, existing)
|
||||||
await clearServerCache(name, existing).catch(() => {})
|
await clearServerCache(name, existing).catch(() => {})
|
||||||
|
|
||||||
const updated = await serializeServer(name, existing)
|
const updated = await serializeServerWithLiveStatus(name, existing)
|
||||||
const statusDetail =
|
const statusDetail =
|
||||||
result.client.type === 'failed' && 'error' in result.client ? result.client.error : undefined
|
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> {
|
async function reconnectServer(name: string): Promise<Response> {
|
||||||
const existing = getMcpConfigByName(name)
|
const existing = await resolveServerForRuntimeAction(name)
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw ApiError.notFound(`MCP server not found: ${name}`)
|
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)
|
const result = await reconnectMcpServerImpl(name, existing)
|
||||||
await clearServerCache(name, existing).catch(() => {})
|
await clearServerCache(name, existing).catch(() => {})
|
||||||
|
|
||||||
const server = await serializeServer(name, existing)
|
const server = await serializeServerWithLiveStatus(name, existing)
|
||||||
const statusDetail =
|
const statusDetail =
|
||||||
result.client.type === 'failed' && 'error' in result.client ? result.client.error : undefined
|
result.client.type === 'failed' && 'error' in result.client ? result.client.error : undefined
|
||||||
|
|
||||||
@ -479,6 +526,10 @@ export async function handleMcpApi(
|
|||||||
return listServers()
|
return listServers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && serverName && action === 'status') {
|
||||||
|
return getServerStatus(serverName)
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && !serverName) {
|
if (req.method === 'POST' && !serverName) {
|
||||||
return createServer(body ?? {})
|
return createServer(body ?? {})
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user