feat(desktop): background MCP health checks with re-auth nudges
MCP server problems (expired OAuth tokens especially) were only
discovered when the user visited the MCP page and a probe ran. Now a
renderer-side background checker (store/mcp-health.ts) sweeps the
active profile's enabled HTTP/SSE MCP servers on gateway connect and
every 30 minutes, and fires an in-app notification with a "Sign in"
action ("<name> MCP needs re-authentication") that navigates to the
MCP page with ?server=<name> so useDeepLinkHighlight focuses the
server and its Authenticate button. Navigation only — OAuth flows are
never auto-launched.
stdio servers are deliberately excluded: probing a stdio server SPAWNS
a local process, so a background timer must never touch them. Only
url-shaped servers (where OAuth expiry lives) are swept, sequentially.
The tab's probeCache/serverFingerprint/probeKey/NEEDS_AUTH_RE moved to
a shared lib/mcp-probe-cache.ts (behavior identical) so the page and
the checker share one probe cache and its 5-minute TTL — neither
surface re-probes what the other just learned.
Notifications fire only on a TRANSITION into needs-auth/error (pure
state machine, unit-tested), hard-capped at one per server per app
session, keyed per profile. Profile switches drop pending timers and
re-arm for the new profile; sweeps never run while the gateway is
disconnected. No new config knobs. i18n keys added across
en/zh/zh-hant/ja/ar + types.
This commit is contained in:
@@ -4,6 +4,7 @@ import { closeActiveTab } from '@/app/chat/close-tab'
|
||||
import { openSession } from '@/app/open-session'
|
||||
import { storedSessionIdForNotification } from '@/lib/session-ids'
|
||||
import { requestMcpInstallFromDeepLink } from '@/store/mcp-deeplink-install'
|
||||
import { startMcpHealthChecker, stopMcpHealthChecker } from '@/store/mcp-health'
|
||||
import { respondToApprovalAction } from '@/store/native-notifications'
|
||||
import { openFolderAsProject } from '@/store/projects'
|
||||
import {
|
||||
@@ -60,11 +61,15 @@ export function useDesktopIntegrations({
|
||||
// process's "open updates" menu request.
|
||||
useEffect(() => {
|
||||
startUpdatePoller()
|
||||
// Background MCP health: HTTP/SSE servers only (never spawns stdio),
|
||||
// notifies on transitions into needs-auth/error with a Sign in action.
|
||||
startMcpHealthChecker()
|
||||
const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow())
|
||||
|
||||
return () => {
|
||||
unsubscribe?.()
|
||||
stopUpdatePoller()
|
||||
stopMcpHealthChecker()
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import { compactNumber } from '@/lib/format'
|
||||
import { brandFor, brandGlyphStyle } from '@/lib/mcp-brands'
|
||||
import { estimateServerTokens, serverUsageCount } from '@/lib/mcp-cost'
|
||||
import { completeMcpDesktopOAuth } from '@/lib/mcp-dashboard-oauth'
|
||||
import { NEEDS_AUTH_RE, PROBE_TTL_MS, probeCache, probeKey, serverFingerprint } from '@/lib/mcp-probe-cache'
|
||||
import { countEnabledTools, isToolEnabled, toggleToolInServer } from '@/lib/mcp-tool-filter'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -104,28 +105,10 @@ function getServers(config: HermesConfigRecord | null): McpServers {
|
||||
// agent's MCP loader read.
|
||||
const serverEnabled = (server: Record<string, unknown>) => server.enabled !== false
|
||||
|
||||
const NEEDS_AUTH_RE = /\b(401|unauthorized|forbidden|invalid[_ ]?token|authentication|oauth)\b/i
|
||||
|
||||
// Shared cache for the Nous-approved catalog — feeds both description enrichment
|
||||
// and the Catalog install view; invalidated after an install.
|
||||
const MCP_CATALOG_KEY = ['mcp-catalog'] as const
|
||||
|
||||
// Probe results outlive the component: each probe is a REAL connect/disconnect
|
||||
// (stdio servers get spawned!), so re-entering the page must not re-probe the
|
||||
// fleet. Manual refresh / auth / toggle-on bypass the cache.
|
||||
const PROBE_TTL_MS = 5 * 60_000
|
||||
const probeCache = new Map<string, { at: number; result: McpTestResult }>()
|
||||
|
||||
// A probe is only valid for one (profile, exact-config) pair. Keying the cache
|
||||
// by a fingerprint of the connection-relevant fields — plus the active profile
|
||||
// — means a same-name edit (url/command/env change) or a same-named server in
|
||||
// another profile MISSES the cache instead of showing a stale probe.
|
||||
const serverFingerprint = (server: Record<string, unknown>): string =>
|
||||
JSON.stringify([server.url, server.command, server.args, server.env, server.headers, server.transport, server.auth])
|
||||
|
||||
const probeKey = (name: string, server: Record<string, unknown> | undefined, profileKey: string): string =>
|
||||
`${profileKey}::${name}::${serverFingerprint(server ?? {})}`
|
||||
|
||||
type Probe = McpTestResult | 'probing'
|
||||
|
||||
// Per-server cost/usage overlay inputs: `tokens` is the approximate per-call
|
||||
|
||||
@@ -115,6 +115,14 @@ export const ar = defineLocale({
|
||||
updateReadyMessage: count => `${count} تغيير جديد متاح.`,
|
||||
updateReadyMessageUnknown: 'يتوفر تحديث جديد.',
|
||||
seeWhatsNew: 'عرض الجديد',
|
||||
mcp: {
|
||||
needsAuthTitle: 'خادم MCP يحتاج إلى إعادة المصادقة',
|
||||
needsAuthMessage: name => `يحتاج ${name} MCP إلى إعادة المصادقة.`,
|
||||
errorTitle: 'تعذر الوصول إلى خادم MCP',
|
||||
errorMessage: name => `فشل فحص سلامة ${name} MCP.`,
|
||||
signIn: 'تسجيل الدخول',
|
||||
view: 'عرض'
|
||||
},
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'يتطلب ElevenLabs STT المفتاح ELEVENLABS_API_KEY.',
|
||||
elevenLabsRejectedKey: 'رفض ElevenLabs مفتاح API (401).',
|
||||
|
||||
@@ -130,6 +130,14 @@ export const en: Translations = {
|
||||
updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`,
|
||||
updateReadyMessageUnknown: 'A new update is available.',
|
||||
seeWhatsNew: "See what's new",
|
||||
mcp: {
|
||||
needsAuthTitle: 'MCP server needs re-authentication',
|
||||
needsAuthMessage: name => `${name} MCP needs re-authentication.`,
|
||||
errorTitle: 'MCP server unreachable',
|
||||
errorMessage: name => `${name} MCP failed its health check.`,
|
||||
signIn: 'Sign in',
|
||||
view: 'View'
|
||||
},
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'ElevenLabs STT needs ELEVENLABS_API_KEY.',
|
||||
elevenLabsRejectedKey: 'ElevenLabs rejected the API key (401).',
|
||||
|
||||
@@ -131,6 +131,14 @@ export const ja = defineLocale({
|
||||
updateReadyMessage: count => `${count} 件の新しい変更が利用可能です。`,
|
||||
updateReadyMessageUnknown: '新しい更新が利用可能です。',
|
||||
seeWhatsNew: '新機能を見る',
|
||||
mcp: {
|
||||
needsAuthTitle: 'MCP サーバーの再認証が必要です',
|
||||
needsAuthMessage: name => `${name} MCP の再認証が必要です。`,
|
||||
errorTitle: 'MCP サーバーに接続できません',
|
||||
errorMessage: name => `${name} MCP のヘルスチェックに失敗しました。`,
|
||||
signIn: 'サインイン',
|
||||
view: '表示'
|
||||
},
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'ElevenLabs STT には ELEVENLABS_API_KEY が必要です。',
|
||||
elevenLabsRejectedKey: 'ElevenLabs が API キーを拒否しました (401)。',
|
||||
|
||||
@@ -172,6 +172,14 @@ export interface Translations {
|
||||
updateReadyMessage: (count: number) => string
|
||||
updateReadyMessageUnknown: string
|
||||
seeWhatsNew: string
|
||||
mcp: {
|
||||
needsAuthTitle: string
|
||||
needsAuthMessage: (name: string) => string
|
||||
errorTitle: string
|
||||
errorMessage: (name: string) => string
|
||||
signIn: string
|
||||
view: string
|
||||
}
|
||||
errors: {
|
||||
elevenLabsNeedsKey: string
|
||||
elevenLabsRejectedKey: string
|
||||
|
||||
@@ -127,6 +127,14 @@ export const zhHant = defineLocale({
|
||||
updateReadyMessage: count => `有 ${count} 項新變更可用。`,
|
||||
updateReadyMessageUnknown: '有新更新可用。',
|
||||
seeWhatsNew: '查看新增內容',
|
||||
mcp: {
|
||||
needsAuthTitle: 'MCP 伺服器需要重新驗證',
|
||||
needsAuthMessage: name => `${name} MCP 需要重新驗證。`,
|
||||
errorTitle: 'MCP 伺服器無法連線',
|
||||
errorMessage: name => `${name} MCP 健康檢查失敗。`,
|
||||
signIn: '登入',
|
||||
view: '檢視'
|
||||
},
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
|
||||
elevenLabsRejectedKey: 'ElevenLabs 拒絕了該 API 金鑰 (401)。',
|
||||
|
||||
@@ -127,6 +127,14 @@ export const zh: Translations = {
|
||||
updateReadyMessage: count => `有 ${count} 项新更改可用。`,
|
||||
updateReadyMessageUnknown: '有新更新可用。',
|
||||
seeWhatsNew: '查看更新内容',
|
||||
mcp: {
|
||||
needsAuthTitle: 'MCP 服务器需要重新认证',
|
||||
needsAuthMessage: name => `${name} MCP 需要重新认证。`,
|
||||
errorTitle: 'MCP 服务器无法连接',
|
||||
errorMessage: name => `${name} MCP 健康检查失败。`,
|
||||
signIn: '登录',
|
||||
view: '查看'
|
||||
},
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
|
||||
elevenLabsRejectedKey: 'ElevenLabs 拒绝了该 API key (401)。',
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { McpTestResult } from '@/hermes'
|
||||
|
||||
import { classifyProbe, freshProbe, NEEDS_AUTH_RE, PROBE_TTL_MS, probeCache, probeKey } from './mcp-probe-cache'
|
||||
|
||||
const result = (over: Partial<McpTestResult> = {}): McpTestResult => ({ ok: true, tools: [], ...over })
|
||||
|
||||
describe('classifyProbe', () => {
|
||||
it('classifies a successful probe as ok', () => {
|
||||
expect(classifyProbe(result())).toBe('ok')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'HTTP 401 Unauthorized',
|
||||
'invalid_token: The access token expired',
|
||||
'OAuth authorization required',
|
||||
'authentication failed'
|
||||
])('classifies "%s" as needs-auth', error => {
|
||||
expect(classifyProbe(result({ ok: false, error }))).toBe('needs-auth')
|
||||
})
|
||||
|
||||
it('classifies other failures as error', () => {
|
||||
expect(classifyProbe(result({ ok: false, error: 'ECONNREFUSED 127.0.0.1:3845' }))).toBe('error')
|
||||
})
|
||||
|
||||
it('classifies a failure without an error string as error', () => {
|
||||
expect(classifyProbe(result({ ok: false }))).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('probeKey', () => {
|
||||
it('scopes by profile, name, and connection-relevant config', () => {
|
||||
const server = { url: 'https://api.githubcopilot.com/mcp/' }
|
||||
expect(probeKey('github', server, 'default')).not.toBe(probeKey('github', server, 'work'))
|
||||
expect(probeKey('github', server, 'default')).not.toBe(probeKey('gh2', server, 'default'))
|
||||
expect(probeKey('github', server, 'default')).not.toBe(
|
||||
probeKey('github', { url: 'https://other.example/mcp' }, 'default')
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores non-connection fields so cosmetic edits still hit the cache', () => {
|
||||
const server = { url: 'https://api.example/mcp' }
|
||||
expect(probeKey('s', server, 'default')).toBe(probeKey('s', { ...server, description: 'hi' }, 'default'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('freshProbe', () => {
|
||||
it('returns a cached result inside the TTL and null after it', () => {
|
||||
const key = probeKey('ttl-test', { url: 'https://x' }, 'default')
|
||||
const cached = result()
|
||||
probeCache.set(key, { at: 1_000, result: cached })
|
||||
|
||||
expect(freshProbe(key, 1_000 + PROBE_TTL_MS - 1)).toBe(cached)
|
||||
expect(freshProbe(key, 1_000 + PROBE_TTL_MS)).toBeNull()
|
||||
expect(freshProbe('missing', 0)).toBeNull()
|
||||
probeCache.delete(key)
|
||||
})
|
||||
})
|
||||
|
||||
describe('NEEDS_AUTH_RE', () => {
|
||||
it('does not match unrelated failure text', () => {
|
||||
expect(NEEDS_AUTH_RE.test('connection timed out after 60000ms')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { McpTestResult } from '@/hermes'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared MCP probe cache. Extracted from mcp-tab.tsx so the MCP page and the
|
||||
// background health checker (store/mcp-health.ts) share ONE cache: a probe is
|
||||
// a REAL connect/disconnect (stdio servers get spawned!), so neither surface
|
||||
// may re-probe what the other just learned.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const NEEDS_AUTH_RE = /\b(401|unauthorized|forbidden|invalid[_ ]?token|authentication|oauth)\b/i
|
||||
|
||||
// Probe results outlive any component: each probe is a real connect/disconnect,
|
||||
// so re-entering the MCP page (or a background sweep) must not re-probe the
|
||||
// fleet. Manual refresh / auth / toggle-on bypass the cache.
|
||||
export const PROBE_TTL_MS = 5 * 60_000
|
||||
|
||||
export const probeCache = new Map<string, { at: number; result: McpTestResult }>()
|
||||
|
||||
// A probe is only valid for one (profile, exact-config) pair. Keying the cache
|
||||
// by a fingerprint of the connection-relevant fields — plus the active profile
|
||||
// — means a same-name edit (url/command/env change) or a same-named server in
|
||||
// another profile MISSES the cache instead of showing a stale probe.
|
||||
export const serverFingerprint = (server: Record<string, unknown>): string =>
|
||||
JSON.stringify([server.url, server.command, server.args, server.env, server.headers, server.transport, server.auth])
|
||||
|
||||
export const probeKey = (name: string, server: Record<string, unknown> | undefined, profileKey: string): string =>
|
||||
`${profileKey}::${name}::${serverFingerprint(server ?? {})}`
|
||||
|
||||
/** Read a still-fresh cached probe result, or null (miss / expired). */
|
||||
export function freshProbe(key: string, now = Date.now()): McpTestResult | null {
|
||||
const cached = probeCache.get(key)
|
||||
|
||||
return cached && now - cached.at < PROBE_TTL_MS ? cached.result : null
|
||||
}
|
||||
|
||||
/** Classify a finished probe the way the MCP page's status dot does. */
|
||||
export function classifyProbe(result: McpTestResult): 'error' | 'needs-auth' | 'ok' {
|
||||
if (result.ok) {
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
return NEEDS_AUTH_RE.test(result.error ?? '') ? 'needs-auth' : 'error'
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The store wires itself to gateway/profile atoms and the REST layer at import
|
||||
// time paths; mock the seams (same shape as updates.test.ts) so this test only
|
||||
// exercises the pure transition state machine.
|
||||
vi.mock('@/hermes', () => ({
|
||||
getHermesConfigRecord: vi.fn(),
|
||||
testMcpServer: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
translateNow: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notify: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store/profile', () => ({
|
||||
$activeGatewayProfile: { get: () => 'default', listen: () => () => {} },
|
||||
normalizeProfileKey: (name: string | null | undefined) => (name ?? '').trim() || 'default'
|
||||
}))
|
||||
|
||||
vi.mock('@/store/session', () => ({
|
||||
$gatewayState: { get: () => 'closed', subscribe: () => () => {} }
|
||||
}))
|
||||
|
||||
const { shouldNotifyOnTransition } = await import('./mcp-health')
|
||||
|
||||
type Status = 'error' | 'needs-auth' | 'ok'
|
||||
|
||||
describe('shouldNotifyOnTransition', () => {
|
||||
// The full previous × next decision table: notify only on a TRANSITION into
|
||||
// a bad state. Rechecks of an already-bad server stay quiet; ok never nudges.
|
||||
it.each<[previous: Status | null, next: Status, notify: boolean]>([
|
||||
// First observation of the session (previous unknown).
|
||||
[null, 'ok', false],
|
||||
[null, 'needs-auth', true],
|
||||
[null, 'error', true],
|
||||
// Healthy server stays healthy / breaks.
|
||||
['ok', 'ok', false],
|
||||
['ok', 'needs-auth', true],
|
||||
['ok', 'error', true],
|
||||
// Already-broken server: rechecks must NOT re-notify…
|
||||
['needs-auth', 'needs-auth', false],
|
||||
['error', 'error', false],
|
||||
// …but flipping from one bad state to the other is a new transition.
|
||||
['needs-auth', 'error', true],
|
||||
['error', 'needs-auth', true],
|
||||
// Recovery is silent.
|
||||
['needs-auth', 'ok', false],
|
||||
['error', 'ok', false]
|
||||
])('previous=%s next=%s → notify=%s', (previous, next, expected) => {
|
||||
expect(shouldNotifyOnTransition(previous, next)).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Background MCP health checker. On gateway connect — and every 30 minutes
|
||||
* after — probe the ACTIVE profile's enabled HTTP/SSE MCP servers and nudge
|
||||
* the user when one transitions into needs-auth (expired OAuth token) or
|
||||
* error, with a one-click path to the MCP page's Authenticate button.
|
||||
*
|
||||
* Scope is deliberate: stdio servers are NEVER probed here. Probing a stdio
|
||||
* server SPAWNS a local process, so a background timer would silently launch
|
||||
* user-configured commands every half hour. Only url-shaped servers (HTTP/SSE
|
||||
* — where OAuth expiry actually lives) are swept, sequentially, and through
|
||||
* the same probe cache the MCP page uses so neither surface re-probes what
|
||||
* the other just learned.
|
||||
*/
|
||||
|
||||
import { getHermesConfigRecord, type McpTestResult, testMcpServer } from '@/hermes'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { classifyProbe, freshProbe, probeCache, probeKey } from '@/lib/mcp-probe-cache'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
// A constant, not a config knob: the sweep is cheap (a handful of sequential
|
||||
// HTTP probes at most) and the notification is transition-gated below, so
|
||||
// there is nothing for a user to meaningfully tune.
|
||||
const CHECK_INTERVAL_MS = 30 * 60_000
|
||||
|
||||
export type McpHealthStatus = 'error' | 'needs-auth' | 'ok'
|
||||
|
||||
/**
|
||||
* The notify decision, as a pure state machine: nudge only on a TRANSITION
|
||||
* into a bad state — never on every recheck of an already-known-bad server,
|
||||
* and never for ok. An unknown previous state (first sweep of the session)
|
||||
* counts as a transition: an expired token discovered at launch is exactly
|
||||
* the case this exists for.
|
||||
*/
|
||||
export function shouldNotifyOnTransition(previous: McpHealthStatus | null, next: McpHealthStatus): boolean {
|
||||
return (next === 'error' || next === 'needs-auth') && previous !== next
|
||||
}
|
||||
|
||||
// Last-known status per (profile, server) — the transition memory — and the
|
||||
// per-app-session notification cap. Both keyed by profile so one profile's
|
||||
// broken server can't mute or trigger another's (AGENTS.md scope-in-key).
|
||||
const lastStatus = new Map<string, McpHealthStatus>()
|
||||
const notifiedThisSession = new Set<string>()
|
||||
|
||||
let started = false
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
// Bumped on profile switch; in-flight sweeps compare and bail so a slow
|
||||
// profile-A probe can't record (or notify) into profile B's state.
|
||||
let sweepEpoch = 0
|
||||
// Sweeps are chained, never concurrent — sequential probes, no parallel bursts.
|
||||
let sweepChain: Promise<void> = Promise.resolve()
|
||||
let offGatewayState: (() => void) | null = null
|
||||
let offProfile: (() => void) | null = null
|
||||
|
||||
// Navigation only — never auto-launch an OAuth flow from the background. The
|
||||
// server query param routes through useDeepLinkHighlight on the MCP tab, which
|
||||
// scrolls to and focuses the server so its ServerConfig pane (with the
|
||||
// Authenticate button) is one click away.
|
||||
function openMcpServerPage(name: string): void {
|
||||
window.location.hash = `#/skills?tab=mcp&server=${encodeURIComponent(name)}`
|
||||
}
|
||||
|
||||
function recordResult(profileKey: string, name: string, status: McpHealthStatus): void {
|
||||
const key = `${profileKey}::${name}`
|
||||
const previous = lastStatus.get(key) ?? null
|
||||
lastStatus.set(key, status)
|
||||
|
||||
if (!shouldNotifyOnTransition(previous, status) || notifiedThisSession.has(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
notifiedThisSession.add(key)
|
||||
|
||||
const needsAuth = status === 'needs-auth'
|
||||
|
||||
notify({
|
||||
action: {
|
||||
label: translateNow(needsAuth ? 'notifications.mcp.signIn' : 'notifications.mcp.view'),
|
||||
onClick: () => openMcpServerPage(name)
|
||||
},
|
||||
id: `mcp-health-${key}`,
|
||||
kind: 'warning',
|
||||
message: translateNow(needsAuth ? 'notifications.mcp.needsAuthMessage' : 'notifications.mcp.errorMessage', name),
|
||||
title: translateNow(needsAuth ? 'notifications.mcp.needsAuthTitle' : 'notifications.mcp.errorTitle')
|
||||
})
|
||||
}
|
||||
|
||||
const isUrlServer = (server: Record<string, unknown>): boolean =>
|
||||
typeof server.url === 'string' && server.enabled !== false
|
||||
|
||||
async function sweep(): Promise<void> {
|
||||
const epoch = sweepEpoch
|
||||
const profileKey = normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
let config: Record<string, unknown>
|
||||
|
||||
try {
|
||||
config = await getHermesConfigRecord()
|
||||
} catch {
|
||||
// Backend unreachable / mid-restart — the next interval tick retries.
|
||||
return
|
||||
}
|
||||
|
||||
if (epoch !== sweepEpoch) {
|
||||
return
|
||||
}
|
||||
|
||||
const raw = config.mcp_servers
|
||||
const servers = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, Record<string, unknown>>) : {}
|
||||
|
||||
for (const [name, server] of Object.entries(servers)) {
|
||||
if (!isUrlServer(server)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// A profile switch or gateway drop mid-sweep stops the remaining probes.
|
||||
if (epoch !== sweepEpoch || $gatewayState.get() !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
const key = probeKey(name, server, profileKey)
|
||||
let result = freshProbe(key)
|
||||
|
||||
if (!result) {
|
||||
try {
|
||||
result = await testMcpServer(name)
|
||||
} catch (err) {
|
||||
result = { ok: false, error: err instanceof Error ? err.message : String(err), tools: [] } as McpTestResult
|
||||
}
|
||||
|
||||
if (epoch !== sweepEpoch) {
|
||||
return
|
||||
}
|
||||
|
||||
probeCache.set(key, { at: Date.now(), result })
|
||||
}
|
||||
|
||||
recordResult(profileKey, name, classifyProbe(result))
|
||||
}
|
||||
}
|
||||
|
||||
function queueSweep(): void {
|
||||
sweepChain = sweepChain.then(sweep, sweep)
|
||||
}
|
||||
|
||||
function arm(): void {
|
||||
if (timer !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
queueSweep()
|
||||
timer = setInterval(queueSweep, CHECK_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function disarm(): void {
|
||||
if (timer !== null) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire the background checker to gateway/profile state. Idempotent. */
|
||||
export function startMcpHealthChecker(): void {
|
||||
if (started || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
started = true
|
||||
|
||||
// Never check while the gateway is disconnected: the timer only exists
|
||||
// while the active socket is open, and rearms on reconnect.
|
||||
offGatewayState = $gatewayState.subscribe((state: string) => {
|
||||
if (state === 'open') {
|
||||
arm()
|
||||
} else {
|
||||
disarm()
|
||||
}
|
||||
})
|
||||
|
||||
// Profile switch: invalidate in-flight sweeps, drop the timer, and re-arm
|
||||
// for the new profile (fresh immediate sweep + fresh interval) if its
|
||||
// gateway is connected. lastStatus/notifiedThisSession are profile-keyed,
|
||||
// so no wipe is needed — profile A's transition memory stays intact for
|
||||
// when the user switches back.
|
||||
offProfile = $activeGatewayProfile.listen(() => {
|
||||
sweepEpoch += 1
|
||||
disarm()
|
||||
|
||||
if ($gatewayState.get() === 'open') {
|
||||
arm()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function stopMcpHealthChecker(): void {
|
||||
disarm()
|
||||
sweepEpoch += 1
|
||||
offGatewayState?.()
|
||||
offGatewayState = null
|
||||
offProfile?.()
|
||||
offProfile = null
|
||||
started = false
|
||||
}
|
||||
Reference in New Issue
Block a user