1c3fbd21ae
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.
66 lines
2.4 KiB
TypeScript
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)
|
|
})
|
|
})
|