diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts index 54a0f287df..c2ebf579c0 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts @@ -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() } }, []) diff --git a/apps/desktop/src/app/skills/mcp-tab.tsx b/apps/desktop/src/app/skills/mcp-tab.tsx index 015dbe49be..36dfce46fa 100644 --- a/apps/desktop/src/app/skills/mcp-tab.tsx +++ b/apps/desktop/src/app/skills/mcp-tab.tsx @@ -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) => 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() - -// 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 => - JSON.stringify([server.url, server.command, server.args, server.env, server.headers, server.transport, server.auth]) - -const probeKey = (name: string, server: Record | undefined, profileKey: string): string => - `${profileKey}::${name}::${serverFingerprint(server ?? {})}` - type Probe = McpTestResult | 'probing' // Per-server cost/usage overlay inputs: `tokens` is the approximate per-call diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index e9c8575b8d..9d2f4439c0 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -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).', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 313972eccb..3e427f1e56 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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).', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index cff785fb9a..301365c73f 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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)。', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 9d75817366..1c0556b708 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index cde02a5e9a..f140697477 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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)。', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index b9df61a6cc..59840d709b 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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)。', diff --git a/apps/desktop/src/lib/mcp-probe-cache.test.ts b/apps/desktop/src/lib/mcp-probe-cache.test.ts new file mode 100644 index 0000000000..c39ae445bc --- /dev/null +++ b/apps/desktop/src/lib/mcp-probe-cache.test.ts @@ -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 => ({ 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) + }) +}) diff --git a/apps/desktop/src/lib/mcp-probe-cache.ts b/apps/desktop/src/lib/mcp-probe-cache.ts new file mode 100644 index 0000000000..c29c4888c6 --- /dev/null +++ b/apps/desktop/src/lib/mcp-probe-cache.ts @@ -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() + +// 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 => + JSON.stringify([server.url, server.command, server.args, server.env, server.headers, server.transport, server.auth]) + +export const probeKey = (name: string, server: Record | 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' +} diff --git a/apps/desktop/src/store/mcp-health.test.ts b/apps/desktop/src/store/mcp-health.test.ts new file mode 100644 index 0000000000..d753fbfc02 --- /dev/null +++ b/apps/desktop/src/store/mcp-health.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/store/mcp-health.ts b/apps/desktop/src/store/mcp-health.ts new file mode 100644 index 0000000000..3542ad3f96 --- /dev/null +++ b/apps/desktop/src/store/mcp-health.ts @@ -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() +const notifiedThisSession = new Set() + +let started = false +let timer: ReturnType | 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 = 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): boolean => + typeof server.url === 'string' && server.enabled !== false + +async function sweep(): Promise { + const epoch = sweepEpoch + const profileKey = normalizeProfileKey($activeGatewayProfile.get()) + + let config: Record + + 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>) : {} + + 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 +}