Files
hermes-agent/apps/desktop/src/lib/mcp-probe-cache.test.ts
T
Teknium 1c3fbd21ae 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.
2026-08-16 02:36:54 -07:00

66 lines
2.4 KiB
TypeScript

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)
})
})