mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
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>
231 lines
7.0 KiB
TypeScript
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,
|
|
}
|
|
}
|