mateclaw/mateclaw-ui/src/composables/useMcpServers.ts
matevip 64b8e167d5 fix(mcp): refresh agent cache on MCP connection change + non-blocking connect (#289)
Closes #289 — after an MCP server (re)connects, chat queries kept replying
"from memory" instead of calling MCP tools.

Root cause: agents snapshot their tool set at build time and are cached in
AgentService.agentInstances, but MCP server lifecycle changes never
invalidated that cache (unlike model-config / tool-guard changes which do).
A stale, tool-less agent graph survived until process restart.

Changes:
- Add McpServerChangedEvent; McpServerService publishes it on connect /
  disconnect / reconnect / delete / (re)connect-failure / batch refresh /
  startup init. AgentService listens and calls refreshAllAgents(), so the
  next turn rebuilds against the live MCP tool set. Also closes the boot
  race where the web server accepts requests before the @Order(200) MCP
  init runner finishes.
- Make create/update/toggle connect asynchronously on a dedicated pool
  ("mcp-connect") so a slow/unreachable server can no longer freeze the
  admin request; status returns immediately as "connecting".
- UI: render the new "connecting" status (pulsing amber dot), show a
  friendly "connecting in background" toast, and poll until the status
  settles (window widened to ~40s to outlast the default connect timeout).
- UI: MCP config modal no longer closes on outside/backdrop click — only
  the × and Cancel buttons close it, so an accidental click can't discard
  unsaved config.

Verified E2E: ckjia-shopping (参考价) MCP server connected at runtime with
no backend restart; the cached 通用助手 agent immediately enabled and called
ckjia_shopping_recommend, returning real product cards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:14:08 +08:00

231 lines
7.0 KiB
TypeScript

import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { mcpApi } from '@/api/index'
import type { McpServer, McpServerForm, McpTestResult } from '@/views/mcp/types'
import { mcpCatalog, type McpCatalogEntry } from '@/views/mcp/catalog'
/**
* Single source of truth for the MCP connections page. Owns the installed
* server list, search/pagination state, and CRUD helpers — the page and
* components stay thin presentational layers.
*/
export function useMcpServers() {
const { t } = useI18n()
const installed = ref<McpServer[]>([])
const isLoading = ref(false)
const isRefreshing = ref(false)
const testingId = ref<number | null>(null)
const testResult = ref<McpTestResult | null>(null)
const search = ref('')
const pageSize = ref(12)
const installedPage = ref(1)
const catalogPage = ref(1)
// Reset both pages whenever the search input changes — prevents pointing
// at a page index that no longer exists after filtering shrinks the list.
watch(search, () => {
installedPage.value = 1
catalogPage.value = 1
})
const lowerSearch = computed(() => search.value.trim().toLowerCase())
const filteredInstalled = computed<McpServer[]>(() => {
const q = lowerSearch.value
if (!q) return installed.value
return installed.value.filter(s =>
s.name.toLowerCase().includes(q) ||
(s.description ?? '').toLowerCase().includes(q),
)
})
// Don't recommend a catalog entry whose key collides with an existing
// installed server name — once added it belongs in the Added section.
const installedNames = computed(() => new Set(installed.value.map(s => s.name)))
const filteredCatalog = computed<McpCatalogEntry[]>(() => {
const q = lowerSearch.value
return mcpCatalog.filter(c => {
if (installedNames.value.has(c.key)) return false
if (!q) return true
return (
c.name.toLowerCase().includes(q) ||
c.description.toLowerCase().includes(q) ||
c.key.toLowerCase().includes(q)
)
})
})
function paginate<T>(list: T[], page: number, size: number): T[] {
const start = (page - 1) * size
return list.slice(start, start + size)
}
// Connecting to an MCP server now runs asynchronously on the backend: the
// create/toggle/update call returns immediately with status "connecting".
// Poll a few times so the card updates to connected/error without the user
// hitting "refresh" manually. Stops early once nothing is still connecting.
// Poll long enough to outlast a slow/failing connect: the SSE/HTTP connect
// can take up to connectTimeout + readTimeout (default 30s+30s) before it
// resolves to error, so a short window would leave the card stuck on
// "connecting" until a manual refresh. ~40s with a gentle 2s cadence covers
// the common case; it also early-exits as soon as nothing is connecting.
let pollTimer: ReturnType<typeof setTimeout> | null = null
function pollConnectingStatus(rounds = 20, intervalMs = 2000) {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
let n = 0
const tick = async () => {
n += 1
await reload()
const stillConnecting = installed.value.some(s => s.lastStatus === 'connecting')
if (stillConnecting && n < rounds) {
pollTimer = setTimeout(tick, intervalMs)
} else {
pollTimer = null
}
}
pollTimer = setTimeout(tick, intervalMs)
}
const pagedInstalled = computed(() =>
paginate(filteredInstalled.value, installedPage.value, pageSize.value),
)
const pagedCatalog = computed(() =>
paginate(filteredCatalog.value, catalogPage.value, pageSize.value),
)
let loadGeneration = 0
async function reload() {
const gen = ++loadGeneration
isLoading.value = true
try {
const res: any = await mcpApi.list()
if (gen === loadGeneration) {
installed.value = (res?.data ?? []) as McpServer[]
}
} catch {
if (gen === loadGeneration) mcToast.error(t('mcp.messages.loadFailed'))
} finally {
if (gen === loadGeneration) isLoading.value = false
}
}
async function refreshAll() {
isRefreshing.value = true
try {
await mcpApi.refresh()
mcToast.success(t('mcp.messages.refreshSuccess'))
await reload()
} catch (e: any) {
mcToast.error(e?.message || t('mcp.messages.saveFailed'))
} finally {
isRefreshing.value = false
}
}
async function saveServer(form: McpServerForm, editing: McpServer | null): Promise<boolean> {
try {
if (editing) {
await mcpApi.update(editing.id, form)
mcToast.success(t('mcp.messages.updateSuccess'))
} else {
await mcpApi.create(form)
mcToast.success(t('mcp.messages.createSuccess'))
}
await reload()
// Enabled servers connect in the background — tell the user and poll for
// the result instead of leaving the card stuck on "connecting".
if (form.enabled) {
mcToast.info(t('mcp.messages.connecting'))
pollConnectingStatus()
}
return true
} catch (e: any) {
mcToast.error(e?.message || t('mcp.messages.saveFailed'))
return false
}
}
async function removeServer(server: McpServer): Promise<boolean> {
try {
await mcpApi.delete(server.id)
mcToast.success(t('mcp.messages.deleteSuccess'))
await reload()
return true
} catch (e: any) {
mcToast.error(e?.message || t('mcp.messages.saveFailed'))
return false
}
}
async function toggleServer(server: McpServer) {
const enabling = !server.enabled
try {
await mcpApi.toggle(server.id, enabling)
mcToast.success(t('mcp.messages.toggleSuccess'))
await reload()
// Enabling connects in the background — poll until it settles.
if (enabling) {
mcToast.info(t('mcp.messages.connecting'))
pollConnectingStatus()
}
} catch (e: any) {
mcToast.error(e?.message || t('mcp.messages.saveFailed'))
}
}
async function testServer(server: McpServer) {
testingId.value = server.id
testResult.value = null
try {
const res: any = await mcpApi.test(server.id)
testResult.value = res.data as McpTestResult
} catch (e: any) {
testResult.value = {
success: false,
message: e?.message || 'Unknown error',
toolCount: 0,
latencyMs: 0,
discoveredTools: [],
}
} finally {
testingId.value = null
// Auto-dismiss toast after 4s so it doesn't linger on screen.
setTimeout(() => {
testResult.value = null
}, 4000)
}
}
return {
// state
installed,
isLoading,
isRefreshing,
testingId,
testResult,
search,
pageSize,
installedPage,
catalogPage,
// derived
filteredInstalled,
filteredCatalog,
pagedInstalled,
pagedCatalog,
// actions
reload,
refreshAll,
saveServer,
removeServer,
toggleServer,
testServer,
}
}