mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +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 { render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { McpSettings } from '../pages/McpSettings'
|
||||
@ -38,6 +38,7 @@ describe('McpSettings', () => {
|
||||
})
|
||||
useMcpStore.setState({
|
||||
servers: [],
|
||||
selectedServer: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
fetchServers: vi.fn(),
|
||||
@ -46,6 +47,8 @@ describe('McpSettings', () => {
|
||||
deleteServer: vi.fn(),
|
||||
toggleServer: vi.fn(),
|
||||
reconnectServer: vi.fn(),
|
||||
refreshServerStatus: vi.fn(),
|
||||
selectServer: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
@ -109,4 +112,116 @@ describe('McpSettings', () => {
|
||||
expect(screen.getByText('plugin:telegram:telegram')).toBeInTheDocument()
|
||||
expect(screen.getByText('global-user')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts background status refresh after the fast list render', async () => {
|
||||
const server = {
|
||||
name: 'deepwiki',
|
||||
scope: 'user',
|
||||
transport: 'http',
|
||||
enabled: true,
|
||||
status: 'checking' as const,
|
||||
statusLabel: 'Checking',
|
||||
configLocation: '/tmp/config',
|
||||
summary: 'https://example.com/mcp',
|
||||
canEdit: true,
|
||||
canRemove: true,
|
||||
canReconnect: true,
|
||||
canToggle: true,
|
||||
config: { type: 'http' as const, url: 'https://example.com/mcp', headers: {} },
|
||||
}
|
||||
const refreshServerStatus = vi.fn().mockResolvedValue({
|
||||
...server,
|
||||
status: 'connected' as const,
|
||||
statusLabel: 'Connected',
|
||||
})
|
||||
|
||||
useMcpStore.setState({
|
||||
servers: [server],
|
||||
refreshServerStatus,
|
||||
})
|
||||
|
||||
render(<McpSettings />)
|
||||
|
||||
expect(screen.getByText('Checking')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshServerStatus).toHaveBeenCalledWith(server, '/workspace/project')
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the delete confirmation modal from the edit view and deletes with the active cwd', async () => {
|
||||
const deleteServer = vi.fn().mockResolvedValue(undefined)
|
||||
const server = {
|
||||
name: 'global-user',
|
||||
scope: 'user',
|
||||
transport: 'http',
|
||||
enabled: true,
|
||||
status: 'connected',
|
||||
statusLabel: 'Connected',
|
||||
configLocation: '/tmp/config',
|
||||
summary: 'https://example.com/mcp',
|
||||
canEdit: true,
|
||||
canRemove: true,
|
||||
canReconnect: true,
|
||||
canToggle: true,
|
||||
config: { type: 'http', url: 'https://example.com/mcp', headers: {} },
|
||||
} as const
|
||||
|
||||
useMcpStore.setState({
|
||||
servers: [server],
|
||||
deleteServer,
|
||||
})
|
||||
|
||||
render(<McpSettings />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open global-user' }))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /uninstall/i }))
|
||||
})
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Delete MCP server')).toBeInTheDocument()
|
||||
expect(screen.getByText('Delete MCP server "global-user"? This action cannot be undone.')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
})
|
||||
|
||||
expect(deleteServer).toHaveBeenCalledWith(server, '/workspace/project')
|
||||
})
|
||||
|
||||
it('uses the active cwd when toggling a server', async () => {
|
||||
const toggleServer = vi.fn().mockResolvedValue(undefined)
|
||||
const server = {
|
||||
name: 'global-user',
|
||||
scope: 'user',
|
||||
transport: 'http',
|
||||
enabled: true,
|
||||
status: 'connected',
|
||||
statusLabel: 'Connected',
|
||||
configLocation: '/tmp/config',
|
||||
summary: 'https://example.com/mcp',
|
||||
canEdit: true,
|
||||
canRemove: true,
|
||||
canReconnect: true,
|
||||
canToggle: true,
|
||||
config: { type: 'http', url: 'https://example.com/mcp', headers: {} },
|
||||
} as const
|
||||
|
||||
useMcpStore.setState({
|
||||
servers: [server],
|
||||
toggleServer,
|
||||
})
|
||||
|
||||
render(<McpSettings />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
})
|
||||
|
||||
expect(toggleServer).toHaveBeenCalledWith(server, '/workspace/project')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 } : {})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button } from '../components/shared/Button'
|
||||
import { Input } from '../components/shared/Input'
|
||||
import { Modal } from '../components/shared/Modal'
|
||||
@ -63,6 +63,7 @@ const MCP_GROUP_ORDER: McpGroupKey[] = [
|
||||
|
||||
const STATUS_TONE: Record<McpServerRecord['status'], string> = {
|
||||
connected: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
|
||||
checking: 'bg-slate-500/10 text-slate-600 border-slate-500/20',
|
||||
'needs-auth': 'bg-amber-500/10 text-amber-600 border-amber-500/20',
|
||||
failed: 'bg-rose-500/10 text-rose-600 border-rose-500/20',
|
||||
disabled: 'bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] border-[var(--color-border)]',
|
||||
@ -228,6 +229,14 @@ function scopeLabel(server: McpServerRecord, t: ReturnType<typeof useTranslation
|
||||
return t(`settings.mcp.scope.${group}`)
|
||||
}
|
||||
|
||||
function getServerIdentityKey(server: Pick<McpServerRecord, 'name' | 'scope' | 'projectPath'>) {
|
||||
if (server.scope === 'local' || server.scope === 'project') {
|
||||
return `${server.scope}:${server.projectPath ?? ''}:${server.name}`
|
||||
}
|
||||
|
||||
return `${server.scope}:${server.name}`
|
||||
}
|
||||
|
||||
function ToggleSwitch({
|
||||
checked,
|
||||
disabled,
|
||||
@ -382,7 +391,7 @@ function ServerRow({
|
||||
}
|
||||
|
||||
export function McpSettings() {
|
||||
const { servers, selectedServer, isLoading, error, fetchServers, createServer, updateServer, deleteServer, toggleServer, reconnectServer, selectServer } = useMcpStore()
|
||||
const { servers, selectedServer, isLoading, error, fetchServers, createServer, updateServer, deleteServer, toggleServer, reconnectServer, refreshServerStatus, selectServer } = useMcpStore()
|
||||
const addToast = useUIStore((s) => s.addToast)
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||
@ -393,9 +402,11 @@ export function McpSettings() {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [busyServerName, setBusyServerName] = useState<string | null>(null)
|
||||
const [pendingDeleteServer, setPendingDeleteServer] = useState<McpServerRecord | null>(null)
|
||||
const refreshInFlightRef = useRef(new Set<string>())
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeSessionId)
|
||||
const currentWorkDir = activeSession?.workDir || undefined
|
||||
const resolveOperationCwd = (server?: McpServerRecord) => server?.projectPath ?? currentWorkDir
|
||||
|
||||
useEffect(() => {
|
||||
void fetchServers(undefined, currentWorkDir)
|
||||
@ -441,10 +452,55 @@ export function McpSettings() {
|
||||
}
|
||||
}, [selectedServer])
|
||||
|
||||
useEffect(() => {
|
||||
const pendingServers = servers.filter((server) => (
|
||||
server.enabled &&
|
||||
server.status === 'checking' &&
|
||||
!refreshInFlightRef.current.has(getServerIdentityKey(server))
|
||||
))
|
||||
|
||||
if (pendingServers.length === 0) return
|
||||
|
||||
let cancelled = false
|
||||
const queue = [...pendingServers]
|
||||
const workerCount = Math.min(2, queue.length)
|
||||
|
||||
const runWorker = async () => {
|
||||
while (!cancelled) {
|
||||
const server = queue.shift()
|
||||
if (!server) return
|
||||
|
||||
const key = getServerIdentityKey(server)
|
||||
refreshInFlightRef.current.add(key)
|
||||
try {
|
||||
const updated = await refreshServerStatus(server, resolveOperationCwd(server))
|
||||
if (cancelled) return
|
||||
|
||||
setView((current) => {
|
||||
if (current.type !== 'details' && current.type !== 'edit') return current
|
||||
if (getServerIdentityKey(current.server) !== key) return current
|
||||
return { ...current, server: updated }
|
||||
})
|
||||
} catch {
|
||||
// Keep passive checks silent. Explicit reconnect remains the action that
|
||||
// surfaces failures to the user.
|
||||
} finally {
|
||||
refreshInFlightRef.current.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Promise.all(Array.from({ length: workerCount }, () => runWorker()))
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [servers, refreshServerStatus, currentWorkDir])
|
||||
|
||||
const handleToggle = async (server: McpServerRecord) => {
|
||||
setBusyServerName(server.name)
|
||||
try {
|
||||
const updated = await toggleServer(server.name, undefined)
|
||||
const updated = await toggleServer(server, resolveOperationCwd(server))
|
||||
addToast({
|
||||
type: 'success',
|
||||
message: updated.enabled ? t('settings.mcp.toast.enabled', { name: server.name }) : t('settings.mcp.toast.disabled', { name: server.name }),
|
||||
@ -462,7 +518,7 @@ export function McpSettings() {
|
||||
const handleReconnect = async (server: McpServerRecord) => {
|
||||
setBusyServerName(server.name)
|
||||
try {
|
||||
const updated = await reconnectServer(server.name, undefined)
|
||||
const updated = await reconnectServer(server, resolveOperationCwd(server))
|
||||
addToast({
|
||||
type: updated.status === 'connected' ? 'success' : 'warning',
|
||||
message: updated.status === 'connected'
|
||||
@ -490,7 +546,7 @@ export function McpSettings() {
|
||||
if (!server) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteServer(server.name, server.scope, undefined)
|
||||
await deleteServer(server, resolveOperationCwd(server))
|
||||
addToast({
|
||||
type: 'success',
|
||||
message: t('settings.mcp.toast.deleted', { name: server.name }),
|
||||
@ -508,14 +564,39 @@ export function McpSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const deleteModal = (
|
||||
<Modal
|
||||
open={pendingDeleteServer !== null}
|
||||
onClose={() => {
|
||||
if (isDeleting) return
|
||||
setPendingDeleteServer(null)
|
||||
}}
|
||||
title={t('settings.mcp.form.deleteTitle')}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setPendingDeleteServer(null)} disabled={isDeleting}>
|
||||
{t('settings.mcp.form.cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={confirmDelete} loading={isDeleting}>
|
||||
{t('settings.mcp.form.confirmDelete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{pendingDeleteServer ? t('settings.mcp.form.deleteConfirmBody', { name: pendingDeleteServer.name }) : ''}
|
||||
</p>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!isDraftValid(draft)) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const payload = buildPayload(draft)
|
||||
const saved = view.type === 'edit'
|
||||
? await updateServer(view.server.name, payload, undefined)
|
||||
: await createServer(draft.name.trim(), payload, undefined)
|
||||
? await updateServer(view.server, payload, resolveOperationCwd(view.server))
|
||||
: await createServer(draft.name.trim(), payload, currentWorkDir)
|
||||
|
||||
addToast({
|
||||
type: 'success',
|
||||
@ -573,44 +654,47 @@ export function McpSettings() {
|
||||
if (view.type === 'details') {
|
||||
const server = view.server
|
||||
return (
|
||||
<div className="max-w-5xl min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView({ type: 'list' })}
|
||||
className="mb-5 inline-flex items-center gap-2 text-sm text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
|
||||
{t('settings.mcp.form.back')}
|
||||
</button>
|
||||
<>
|
||||
<div className="max-w-5xl min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView({ type: 'list' })}
|
||||
className="mb-5 inline-flex items-center gap-2 text-sm text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
|
||||
{t('settings.mcp.form.back')}
|
||||
</button>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h2 className="text-[2.2rem] font-semibold tracking-[-0.03em] text-[var(--color-text-primary)]">{server.name}</h2>
|
||||
<p className="mt-3 text-base text-[var(--color-text-secondary)]">{server.summary}</p>
|
||||
<div className="flex items-start justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h2 className="text-[2.2rem] font-semibold tracking-[-0.03em] text-[var(--color-text-primary)]">{server.name}</h2>
|
||||
<p className="mt-3 text-base text-[var(--color-text-secondary)]">{server.summary}</p>
|
||||
</div>
|
||||
{server.canReconnect && (
|
||||
<Button variant="secondary" onClick={() => handleReconnect(server)} loading={busyServerName === server.name}>
|
||||
<span className="material-symbols-outlined text-[16px]">sync</span>
|
||||
{t('settings.mcp.form.reconnect')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{server.canReconnect && (
|
||||
<Button variant="secondary" onClick={() => handleReconnect(server)} loading={busyServerName === server.name}>
|
||||
<span className="material-symbols-outlined text-[16px]">sync</span>
|
||||
{t('settings.mcp.form.reconnect')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<InfoPair label={t('settings.mcp.form.transport')} value={transportLabel(server.transport, t)} />
|
||||
<InfoPair label={t('settings.mcp.form.scope')} value={scopeLabel(server, t)} />
|
||||
<InfoPair label={t('settings.mcp.form.status')} value={server.statusLabel} />
|
||||
<InfoPair label={t('settings.mcp.form.location')} value={server.configLocation} />
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">{t('settings.mcp.form.rawConfig')}</div>
|
||||
<pre className="overflow-x-auto rounded-[var(--radius-lg)] bg-[var(--color-surface-hover)] p-4 text-xs text-[var(--color-text-secondary)]">
|
||||
{JSON.stringify(server.config, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<InfoPair label={t('settings.mcp.form.transport')} value={transportLabel(server.transport, t)} />
|
||||
<InfoPair label={t('settings.mcp.form.scope')} value={scopeLabel(server, t)} />
|
||||
<InfoPair label={t('settings.mcp.form.status')} value={server.statusLabel} />
|
||||
<InfoPair label={t('settings.mcp.form.location')} value={server.configLocation} />
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">{t('settings.mcp.form.rawConfig')}</div>
|
||||
<pre className="overflow-x-auto rounded-[var(--radius-lg)] bg-[var(--color-surface-hover)] p-4 text-xs text-[var(--color-text-secondary)]">
|
||||
{JSON.stringify(server.config, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{deleteModal}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -621,48 +705,49 @@ export function McpSettings() {
|
||||
const isBusy = isSaving || isDeleting
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView({ type: 'list' })}
|
||||
className="mb-5 inline-flex items-center gap-2 text-sm text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
|
||||
{t('settings.mcp.form.back')}
|
||||
</button>
|
||||
<>
|
||||
<div className="max-w-5xl min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView({ type: 'list' })}
|
||||
className="mb-5 inline-flex items-center gap-2 text-sm text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
|
||||
{t('settings.mcp.form.back')}
|
||||
</button>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h2 className="text-[2.2rem] font-semibold tracking-[-0.03em] text-[var(--color-text-primary)]">
|
||||
{editing ? t('settings.mcp.form.editTitle', { name: targetServer!.name }) : t('settings.mcp.form.createTitle')}
|
||||
</h2>
|
||||
<p className="mt-3 text-base text-[var(--color-text-secondary)]">
|
||||
{editing ? t('settings.mcp.form.editHint') : t('settings.mcp.form.createHint')}
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h2 className="text-[2.2rem] font-semibold tracking-[-0.03em] text-[var(--color-text-primary)]">
|
||||
{editing ? t('settings.mcp.form.editTitle', { name: targetServer!.name }) : t('settings.mcp.form.createTitle')}
|
||||
</h2>
|
||||
<p className="mt-3 text-base text-[var(--color-text-secondary)]">
|
||||
{editing ? t('settings.mcp.form.editHint') : t('settings.mcp.form.createHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{editing && targetServer?.canReconnect && (
|
||||
<Button variant="secondary" onClick={() => handleReconnect(targetServer)} loading={busyServerName === targetServer.name}>
|
||||
<span className="material-symbols-outlined text-[16px]">sync</span>
|
||||
{t('settings.mcp.form.reconnect')}
|
||||
</Button>
|
||||
)}
|
||||
{editing && targetServer?.canRemove && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-[var(--color-error)] hover:text-[var(--color-error)] hover:bg-[var(--color-error)]/8"
|
||||
onClick={() => handleDelete(targetServer)}
|
||||
loading={isDeleting}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||
{t('settings.mcp.form.uninstall')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{editing && targetServer?.canReconnect && (
|
||||
<Button variant="secondary" onClick={() => handleReconnect(targetServer)} loading={busyServerName === targetServer.name}>
|
||||
<span className="material-symbols-outlined text-[16px]">sync</span>
|
||||
{t('settings.mcp.form.reconnect')}
|
||||
</Button>
|
||||
)}
|
||||
{editing && targetServer?.canRemove && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-[var(--color-error)] hover:text-[var(--color-error)] hover:bg-[var(--color-error)]/8"
|
||||
onClick={() => handleDelete(targetServer)}
|
||||
loading={isDeleting}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||
{t('settings.mcp.form.uninstall')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
|
||||
<Input
|
||||
label={t('settings.mcp.form.name')}
|
||||
@ -802,7 +887,9 @@ export function McpSettings() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{deleteModal}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -882,29 +969,7 @@ export function McpSettings() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={pendingDeleteServer !== null}
|
||||
onClose={() => {
|
||||
if (isDeleting) return
|
||||
setPendingDeleteServer(null)
|
||||
}}
|
||||
title={t('settings.mcp.form.deleteTitle')}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setPendingDeleteServer(null)} disabled={isDeleting}>
|
||||
{t('settings.mcp.form.cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={confirmDelete} loading={isDeleting}>
|
||||
{t('settings.mcp.form.confirmDelete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{pendingDeleteServer ? t('settings.mcp.form.deleteConfirmBody', { name: pendingDeleteServer.name }) : ''}
|
||||
</p>
|
||||
</Modal>
|
||||
{deleteModal}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -9,17 +9,49 @@ type McpStore = {
|
||||
error: string | null
|
||||
fetchServers: (projectPaths?: string[], fallbackCwd?: string) => Promise<void>
|
||||
createServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
|
||||
updateServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
|
||||
deleteServer: (name: string, scope: string, cwd?: string) => Promise<void>
|
||||
toggleServer: (name: string, cwd?: string) => Promise<McpServerRecord>
|
||||
reconnectServer: (name: string, cwd?: string) => Promise<McpServerRecord>
|
||||
updateServer: (server: McpServerRecord, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
|
||||
deleteServer: (server: McpServerRecord, cwd?: string) => Promise<void>
|
||||
toggleServer: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
|
||||
reconnectServer: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
|
||||
refreshServerStatus: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
|
||||
selectServer: (server: McpServerRecord | null) => void
|
||||
}
|
||||
|
||||
function upsertByName(servers: McpServerRecord[], server: McpServerRecord) {
|
||||
const index = servers.findIndex((item) => item.name === server.name)
|
||||
if (index === -1) return [...servers, server]
|
||||
return servers.map((item, itemIndex) => (itemIndex === index ? server : item))
|
||||
function isProjectScoped(server: Pick<McpServerRecord, 'scope'>) {
|
||||
return server.scope === 'local' || server.scope === 'project'
|
||||
}
|
||||
|
||||
function attachProjectPath(server: McpServerRecord, cwd?: string) {
|
||||
if (!isProjectScoped(server)) {
|
||||
return {
|
||||
...server,
|
||||
projectPath: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...server,
|
||||
projectPath: cwd ?? server.projectPath,
|
||||
}
|
||||
}
|
||||
|
||||
function isSameServer(a: Pick<McpServerRecord, 'name' | 'scope' | 'projectPath'>, b: Pick<McpServerRecord, 'name' | 'scope' | 'projectPath'>) {
|
||||
if (a.name !== b.name || a.scope !== b.scope) return false
|
||||
if (!isProjectScoped(a) && !isProjectScoped(b)) return true
|
||||
return (a.projectPath ?? '') === (b.projectPath ?? '')
|
||||
}
|
||||
|
||||
function replaceServer(
|
||||
servers: McpServerRecord[],
|
||||
previous: Pick<McpServerRecord, 'name' | 'scope' | 'projectPath'>,
|
||||
next: McpServerRecord,
|
||||
cwd?: string,
|
||||
) {
|
||||
const normalizedNext = attachProjectPath(next, cwd)
|
||||
const index = servers.findIndex((item) => isSameServer(item, previous))
|
||||
if (index === -1) return [...servers, normalizedNext]
|
||||
|
||||
return servers.map((item, itemIndex) => (itemIndex === index ? normalizedNext : item))
|
||||
}
|
||||
|
||||
export const useMcpStore = create<McpStore>((set) => ({
|
||||
@ -68,57 +100,69 @@ export const useMcpStore = create<McpStore>((set) => ({
|
||||
|
||||
createServer: async (name, payload, cwd) => {
|
||||
const response = await mcpApi.create(name, payload, cwd)
|
||||
const created = attachProjectPath(response.server, cwd)
|
||||
set((state) => ({
|
||||
servers: [...state.servers, response.server],
|
||||
selectedServer: response.server,
|
||||
servers: [...state.servers, created],
|
||||
selectedServer: created,
|
||||
error: null,
|
||||
}))
|
||||
return response.server
|
||||
return created
|
||||
},
|
||||
|
||||
updateServer: async (name, payload, cwd) => {
|
||||
const response = await mcpApi.update(name, payload, cwd)
|
||||
updateServer: async (server, payload, cwd) => {
|
||||
const response = await mcpApi.update(server.name, payload, cwd)
|
||||
const updated = attachProjectPath(response.server, cwd ?? server.projectPath)
|
||||
set((state) => ({
|
||||
servers: upsertByName(
|
||||
state.servers.filter((server) => server.name !== name),
|
||||
response.server,
|
||||
),
|
||||
selectedServer: response.server,
|
||||
servers: replaceServer(state.servers, server, updated, cwd ?? server.projectPath),
|
||||
selectedServer: state.selectedServer && isSameServer(state.selectedServer, server) ? updated : state.selectedServer,
|
||||
error: null,
|
||||
}))
|
||||
return response.server
|
||||
return updated
|
||||
},
|
||||
|
||||
deleteServer: async (name, scope, cwd) => {
|
||||
await mcpApi.remove(name, scope, cwd)
|
||||
deleteServer: async (server, cwd) => {
|
||||
await mcpApi.remove(server.name, server.scope, cwd)
|
||||
set((state) => ({
|
||||
servers: state.servers.filter((server) => !(server.name === name && server.scope === scope && (server.projectPath ?? '') === (cwd ?? ''))),
|
||||
servers: state.servers.filter((item) => !isSameServer(item, server)),
|
||||
selectedServer:
|
||||
state.selectedServer?.name === name && state.selectedServer?.scope === scope
|
||||
state.selectedServer && isSameServer(state.selectedServer, server)
|
||||
? null
|
||||
: state.selectedServer,
|
||||
error: null,
|
||||
}))
|
||||
},
|
||||
|
||||
toggleServer: async (name, cwd) => {
|
||||
const response = await mcpApi.toggle(name, cwd)
|
||||
toggleServer: async (server, cwd) => {
|
||||
const response = await mcpApi.toggle(server.name, cwd)
|
||||
const updated = attachProjectPath(response.server, cwd ?? server.projectPath)
|
||||
set((state) => ({
|
||||
servers: upsertByName(state.servers, response.server),
|
||||
selectedServer: state.selectedServer?.name === name ? response.server : state.selectedServer,
|
||||
servers: replaceServer(state.servers, server, updated, cwd ?? server.projectPath),
|
||||
selectedServer: state.selectedServer && isSameServer(state.selectedServer, server) ? updated : state.selectedServer,
|
||||
error: null,
|
||||
}))
|
||||
return response.server
|
||||
return updated
|
||||
},
|
||||
|
||||
reconnectServer: async (name, cwd) => {
|
||||
const response = await mcpApi.reconnect(name, cwd)
|
||||
reconnectServer: async (server, cwd) => {
|
||||
const response = await mcpApi.reconnect(server.name, cwd)
|
||||
const updated = attachProjectPath(response.server, cwd ?? server.projectPath)
|
||||
set((state) => ({
|
||||
servers: upsertByName(state.servers, response.server),
|
||||
selectedServer: state.selectedServer?.name === name ? response.server : state.selectedServer,
|
||||
servers: replaceServer(state.servers, server, updated, cwd ?? server.projectPath),
|
||||
selectedServer: state.selectedServer && isSameServer(state.selectedServer, server) ? updated : state.selectedServer,
|
||||
error: null,
|
||||
}))
|
||||
return response.server
|
||||
return updated
|
||||
},
|
||||
|
||||
refreshServerStatus: async (server, cwd) => {
|
||||
const response = await mcpApi.status(server.name, cwd)
|
||||
const updated = attachProjectPath(response.server, cwd ?? server.projectPath)
|
||||
set((state) => ({
|
||||
servers: replaceServer(state.servers, server, updated, cwd ?? server.projectPath),
|
||||
selectedServer: state.selectedServer && isSameServer(state.selectedServer, server) ? updated : state.selectedServer,
|
||||
error: null,
|
||||
}))
|
||||
return updated
|
||||
},
|
||||
|
||||
selectServer: (server) => set({ selectedServer: server }),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -3,12 +3,15 @@ import * as fs from 'fs/promises'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import * as mcpClient from '../../services/mcp/client.js'
|
||||
import * as mcpConfig from '../../services/mcp/config.js'
|
||||
import { handleMcpApi } from '../api/mcp.js'
|
||||
|
||||
let tmpDir: string
|
||||
let projectRoot: string
|
||||
let originalConfigDir: string | undefined
|
||||
let connectSpy: ReturnType<typeof spyOn> | undefined
|
||||
let getClaudeCodeMcpConfigsSpy: ReturnType<typeof spyOn> | undefined
|
||||
let reconnectSpy: ReturnType<typeof spyOn> | undefined
|
||||
|
||||
async function setup() {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-mcp-test-'))
|
||||
@ -65,6 +68,10 @@ describe('MCP API', () => {
|
||||
afterEach(async () => {
|
||||
connectSpy?.mockRestore()
|
||||
connectSpy = undefined
|
||||
getClaudeCodeMcpConfigsSpy?.mockRestore()
|
||||
getClaudeCodeMcpConfigsSpy = undefined
|
||||
reconnectSpy?.mockRestore()
|
||||
reconnectSpy = undefined
|
||||
await teardown()
|
||||
})
|
||||
|
||||
@ -88,6 +95,7 @@ describe('MCP API', () => {
|
||||
const createdBody = await createRes.json()
|
||||
expect(createdBody.server.name).toBe('chrome-devtools')
|
||||
expect(createdBody.server.transport).toBe('stdio')
|
||||
expect(createdBody.server.status).toBe('checking')
|
||||
|
||||
const list = makeRequest('GET', `/api/mcp?cwd=${encodeURIComponent(projectRoot)}`)
|
||||
const listRes = await handleMcpApi(list.req, list.url, list.segments)
|
||||
@ -96,8 +104,32 @@ describe('MCP API', () => {
|
||||
|
||||
expect(listBody.servers).toHaveLength(1)
|
||||
expect(listBody.servers[0].name).toBe('chrome-devtools')
|
||||
expect(listBody.servers[0].status).toBe('connected')
|
||||
expect(listBody.servers[0].status).toBe('checking')
|
||||
expect(listBody.servers[0].config.command).toBe('npx')
|
||||
expect(connectSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks a single server status on demand', async () => {
|
||||
const create = makeRequest('POST', '/api/mcp', {
|
||||
cwd: projectRoot,
|
||||
name: 'deepwiki',
|
||||
scope: 'user',
|
||||
config: {
|
||||
type: 'http',
|
||||
url: 'https://mcp.example.com/mcp',
|
||||
headers: {},
|
||||
},
|
||||
})
|
||||
await handleMcpApi(create.req, create.url, create.segments)
|
||||
|
||||
const status = makeRequest('GET', `/api/mcp/deepwiki/status?cwd=${encodeURIComponent(projectRoot)}`)
|
||||
const statusRes = await handleMcpApi(status.req, status.url, status.segments)
|
||||
|
||||
expect(statusRes.status).toBe(200)
|
||||
const body = await statusRes.json()
|
||||
expect(body.server.name).toBe('deepwiki')
|
||||
expect(body.server.status).toBe('connected')
|
||||
expect(connectSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates, toggles, and deletes MCP servers', async () => {
|
||||
@ -153,4 +185,49 @@ describe('MCP API', () => {
|
||||
const listBody = await listRes.json()
|
||||
expect(listBody.servers.some((server: { name: string }) => server.name === 'context7')).toBe(false)
|
||||
})
|
||||
|
||||
it('reconnects plugin-scoped MCP servers exposed via the merged server list', async () => {
|
||||
const pluginServerName = 'plugin:telegram:telegram'
|
||||
const pluginServerConfig = {
|
||||
scope: 'dynamic',
|
||||
type: 'stdio',
|
||||
command: 'bun',
|
||||
args: ['run', 'start'],
|
||||
env: {
|
||||
CLAUDE_PLUGIN_ROOT: '/tmp/telegram-plugin',
|
||||
},
|
||||
pluginSource: 'telegram@claude-plugins-official',
|
||||
} as const
|
||||
|
||||
getClaudeCodeMcpConfigsSpy = spyOn(mcpConfig, 'getClaudeCodeMcpConfigs').mockResolvedValue({
|
||||
servers: {
|
||||
[pluginServerName]: pluginServerConfig,
|
||||
},
|
||||
errors: [],
|
||||
})
|
||||
|
||||
reconnectSpy = spyOn(mcpClient, 'reconnectMcpServerImpl').mockResolvedValue({
|
||||
name: pluginServerName,
|
||||
client: {
|
||||
name: pluginServerName,
|
||||
type: 'connected',
|
||||
client: {} as never,
|
||||
capabilities: {},
|
||||
config: pluginServerConfig,
|
||||
cleanup: mock(async () => {}),
|
||||
},
|
||||
})
|
||||
|
||||
const reconnect = makeRequest('POST', `/api/mcp/${encodeURIComponent(pluginServerName)}/reconnect`, {
|
||||
cwd: projectRoot,
|
||||
})
|
||||
const reconnectRes = await handleMcpApi(reconnect.req, reconnect.url, reconnect.segments)
|
||||
|
||||
expect(reconnectRes.status).toBe(200)
|
||||
expect(reconnectSpy).toHaveBeenCalledWith(pluginServerName, pluginServerConfig)
|
||||
|
||||
const body = await reconnectRes.json()
|
||||
expect(body.server.name).toBe(pluginServerName)
|
||||
expect(body.server.scope).toBe('dynamic')
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,7 +5,6 @@ import {
|
||||
import {
|
||||
clearServerCache,
|
||||
connectToServer,
|
||||
getMcpServerConnectionBatchSize,
|
||||
reconnectMcpServerImpl,
|
||||
} from '../../services/mcp/client.js'
|
||||
import {
|
||||
@ -55,7 +54,7 @@ type McpServerDto = {
|
||||
scope: string
|
||||
transport: string
|
||||
enabled: boolean
|
||||
status: 'connected' | 'needs-auth' | 'failed' | 'disabled'
|
||||
status: 'connected' | 'needs-auth' | 'failed' | 'disabled' | 'checking'
|
||||
statusLabel: string
|
||||
statusDetail?: string
|
||||
configLocation: string
|
||||
@ -153,11 +152,30 @@ function getStatusLabel(status: McpServerDto['status']): string {
|
||||
return 'Unavailable'
|
||||
case 'disabled':
|
||||
return 'Disabled'
|
||||
case 'checking':
|
||||
return 'Checking'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialStatus(
|
||||
enabled: boolean,
|
||||
): Pick<McpServerDto, 'status' | 'statusDetail' | 'statusLabel'> {
|
||||
if (!enabled) {
|
||||
return {
|
||||
status: 'disabled',
|
||||
statusLabel: getStatusLabel('disabled'),
|
||||
statusDetail: 'Server disabled for the current project',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'checking',
|
||||
statusLabel: getStatusLabel('checking'),
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectServerStatus(
|
||||
name: string,
|
||||
config: ScopedMcpServerConfig,
|
||||
@ -197,12 +215,12 @@ async function inspectServerStatus(
|
||||
}
|
||||
}
|
||||
|
||||
async function serializeServer(
|
||||
function buildServerDto(
|
||||
name: string,
|
||||
config: ScopedMcpServerConfig,
|
||||
): Promise<McpServerDto> {
|
||||
status: Pick<McpServerDto, 'status' | 'statusDetail' | 'statusLabel'>,
|
||||
): McpServerDto {
|
||||
const enabled = !isMcpServerDisabled(name)
|
||||
const status = await inspectServerStatus(name, config, enabled)
|
||||
const transport = config.type ?? 'stdio'
|
||||
const canEdit = EDITABLE_SCOPES.has(config.scope) && (transport === 'stdio' || transport === 'http' || transport === 'sse')
|
||||
|
||||
@ -210,7 +228,7 @@ async function serializeServer(
|
||||
name,
|
||||
scope: config.scope,
|
||||
transport,
|
||||
enabled,
|
||||
enabled: !isMcpServerDisabled(name),
|
||||
status: status.status,
|
||||
statusLabel: status.statusLabel,
|
||||
statusDetail: status.statusDetail,
|
||||
@ -224,6 +242,34 @@ async function serializeServer(
|
||||
}
|
||||
}
|
||||
|
||||
function serializeServerSnapshot(
|
||||
name: string,
|
||||
config: ScopedMcpServerConfig,
|
||||
): McpServerDto {
|
||||
return buildServerDto(name, config, getInitialStatus(!isMcpServerDisabled(name)))
|
||||
}
|
||||
|
||||
async function serializeServerWithLiveStatus(
|
||||
name: string,
|
||||
config: ScopedMcpServerConfig,
|
||||
): Promise<McpServerDto> {
|
||||
const enabled = !isMcpServerDisabled(name)
|
||||
const status = await inspectServerStatus(name, config, enabled)
|
||||
return buildServerDto(name, config, status)
|
||||
}
|
||||
|
||||
async function resolveServerForRuntimeAction(
|
||||
name: string,
|
||||
): Promise<ScopedMcpServerConfig | null> {
|
||||
const configured = getMcpConfigByName(name)
|
||||
if (configured) {
|
||||
return configured
|
||||
}
|
||||
|
||||
const { servers } = await getClaudeCodeMcpConfigs()
|
||||
return servers[name] ?? null
|
||||
}
|
||||
|
||||
function buildServerConfig(config: unknown): McpServerConfig {
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw ApiError.badRequest('Missing or invalid "config" in request body')
|
||||
@ -309,19 +355,20 @@ async function listServers(): Promise<Response> {
|
||||
const visibleServers = Object.entries(servers)
|
||||
.filter(([name, config]) => isVisibleServer(name, config))
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
return Response.json({
|
||||
servers: visibleServers.map(([name, config]) => serializeServerSnapshot(name, config)),
|
||||
})
|
||||
}
|
||||
|
||||
const concurrency = Math.max(1, getMcpServerConnectionBatchSize())
|
||||
const items: McpServerDto[] = []
|
||||
|
||||
for (let index = 0; index < visibleServers.length; index += concurrency) {
|
||||
const chunk = visibleServers.slice(index, index + concurrency)
|
||||
const chunkItems = await Promise.all(
|
||||
chunk.map(([name, config]) => serializeServer(name, config)),
|
||||
)
|
||||
items.push(...chunkItems)
|
||||
async function getServerStatus(name: string): Promise<Response> {
|
||||
const existing = await resolveServerForRuntimeAction(name)
|
||||
if (!existing) {
|
||||
throw ApiError.notFound(`MCP server not found: ${name}`)
|
||||
}
|
||||
|
||||
return Response.json({ servers: items })
|
||||
return Response.json({
|
||||
server: await serializeServerWithLiveStatus(name, existing),
|
||||
})
|
||||
}
|
||||
|
||||
async function createServer(body: Record<string, unknown>): Promise<Response> {
|
||||
@ -348,7 +395,7 @@ async function createServer(body: Record<string, unknown>): Promise<Response> {
|
||||
throw ApiError.internal(`Created MCP server "${name}" could not be reloaded`)
|
||||
}
|
||||
|
||||
return Response.json({ server: await serializeServer(name, created) }, { status: 201 })
|
||||
return Response.json({ server: serializeServerSnapshot(name, created) }, { status: 201 })
|
||||
}
|
||||
|
||||
async function updateServer(name: string, body: Record<string, unknown>): Promise<Response> {
|
||||
@ -391,7 +438,7 @@ async function updateServer(name: string, body: Record<string, unknown>): Promis
|
||||
throw ApiError.internal(`Updated MCP server "${name}" could not be reloaded`)
|
||||
}
|
||||
|
||||
return Response.json({ server: await serializeServer(name, updated) })
|
||||
return Response.json({ server: serializeServerSnapshot(name, updated) })
|
||||
}
|
||||
|
||||
async function deleteServer(name: string, url: URL): Promise<Response> {
|
||||
@ -409,7 +456,7 @@ async function deleteServer(name: string, url: URL): Promise<Response> {
|
||||
}
|
||||
|
||||
async function toggleServer(name: string): Promise<Response> {
|
||||
const existing = getMcpConfigByName(name)
|
||||
const existing = await resolveServerForRuntimeAction(name)
|
||||
if (!existing) {
|
||||
throw ApiError.notFound(`MCP server not found: ${name}`)
|
||||
}
|
||||
@ -419,14 +466,14 @@ async function toggleServer(name: string): Promise<Response> {
|
||||
|
||||
if (!enabled) {
|
||||
await clearServerCache(name, existing).catch(() => {})
|
||||
const updated = await serializeServer(name, existing)
|
||||
const updated = serializeServerSnapshot(name, existing)
|
||||
return Response.json({ server: updated })
|
||||
}
|
||||
|
||||
const result = await reconnectMcpServerImpl(name, existing)
|
||||
await clearServerCache(name, existing).catch(() => {})
|
||||
|
||||
const updated = await serializeServer(name, existing)
|
||||
const updated = await serializeServerWithLiveStatus(name, existing)
|
||||
const statusDetail =
|
||||
result.client.type === 'failed' && 'error' in result.client ? result.client.error : undefined
|
||||
|
||||
@ -439,7 +486,7 @@ async function toggleServer(name: string): Promise<Response> {
|
||||
}
|
||||
|
||||
async function reconnectServer(name: string): Promise<Response> {
|
||||
const existing = getMcpConfigByName(name)
|
||||
const existing = await resolveServerForRuntimeAction(name)
|
||||
if (!existing) {
|
||||
throw ApiError.notFound(`MCP server not found: ${name}`)
|
||||
}
|
||||
@ -447,7 +494,7 @@ async function reconnectServer(name: string): Promise<Response> {
|
||||
const result = await reconnectMcpServerImpl(name, existing)
|
||||
await clearServerCache(name, existing).catch(() => {})
|
||||
|
||||
const server = await serializeServer(name, existing)
|
||||
const server = await serializeServerWithLiveStatus(name, existing)
|
||||
const statusDetail =
|
||||
result.client.type === 'failed' && 'error' in result.client ? result.client.error : undefined
|
||||
|
||||
@ -479,6 +526,10 @@ export async function handleMcpApi(
|
||||
return listServers()
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && serverName && action === 'status') {
|
||||
return getServerStatus(serverName)
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && !serverName) {
|
||||
return createServer(body ?? {})
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user