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:
Teknium
2026-08-16 01:56:59 -07:00
parent 5d33efd990
commit 1c3fbd21ae
12 changed files with 422 additions and 18 deletions
@@ -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()
}
}, [])
+1 -18
View File
@@ -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
+8
View File
@@ -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).',
+8
View File
@@ -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).',
+8
View File
@@ -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)。',
+8
View File
@@ -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
+8
View File
@@ -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)。',
+8
View File
@@ -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)
})
})
+43
View File
@@ -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'
}
+56
View File
@@ -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)
})
})
+204
View File
@@ -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
}