Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
522 lines
22 KiB
TypeScript
522 lines
22 KiB
TypeScript
/**
|
|
* Tests for the mcp-client connection supervisor: crash-driven reconnection
|
|
* with bounded backoff, generation-safe tool re-registration, the failure
|
|
* cap, the stability-window budget reset, and disposal stopping reconnection.
|
|
* Isolated file so vi.mock of the MCP SDK doesn't pollute other test suites.
|
|
*/
|
|
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
|
import type { Config } from '@deepseek-ai/dsh-mcp-client'
|
|
|
|
// ---- Mock MCP SDK ----
|
|
|
|
// vi.mock factories are hoisted above every import/const, so the mock fns and
|
|
// class must be created inside vi.hoisted to exist when the factories run.
|
|
const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient, instances } = vi.hoisted(() => {
|
|
const mockConnect = vi.fn<() => Promise<void>>()
|
|
const mockClose = vi.fn<() => Promise<void>>()
|
|
const mockListTools = vi.fn<(_params?: Record<string, unknown>) => Promise<unknown>>()
|
|
const mockCallTool = vi.fn<(
|
|
_params?: Record<string, unknown>, _compatibilitySchema?: unknown, _options?: unknown,
|
|
) => Promise<unknown>>()
|
|
const mockSetNotificationHandler = vi.fn()
|
|
const mockRequest = vi.fn(async (
|
|
request: { method: string; params?: Record<string, unknown> },
|
|
_schema: unknown,
|
|
options?: unknown,
|
|
): Promise<unknown> => {
|
|
if (request.method === 'tools/list') return await mockListTools(request.params)
|
|
if (request.method === 'tools/call') return await mockCallTool(request.params, undefined, options)
|
|
throw new Error(`unexpected MCP request: ${request.method}`)
|
|
})
|
|
class MockClient {
|
|
onclose: (() => void) | undefined
|
|
connect = mockConnect
|
|
close = mockClose
|
|
request = mockRequest
|
|
setNotificationHandler = mockSetNotificationHandler
|
|
constructor() { instances.push(this) }
|
|
}
|
|
const instances: MockClient[] = []
|
|
return { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient, instances }
|
|
})
|
|
|
|
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
|
Client: MockClient,
|
|
}))
|
|
|
|
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
|
StdioClientTransport: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
|
|
StreamableHTTPClientTransport: vi.fn(),
|
|
}))
|
|
|
|
// vi.mock is hoisted above static imports, so the modules under test see the
|
|
// mocked SDK even through a static import.
|
|
import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
|
|
import { RECONNECT_DEFAULTS, resolveReconnectPolicy, startConnection } from '@deepseek-ai/dsh-mcp-client/src/connection.ts'
|
|
|
|
// ---- Helpers ----
|
|
|
|
const testToolSignal = new AbortController().signal
|
|
|
|
async function mountRegistry(): Promise<Context> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRuntime)
|
|
return ctx
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
// Annotated binding (not withResolvers<void>()): the tests lint layer runs
|
|
// no-invalid-void-type with default options, which rejects the explicit
|
|
// type argument in call position but accepts the inferred form.
|
|
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
|
setTimeout(gate.resolve, ms)
|
|
return gate.promise
|
|
}
|
|
|
|
/** Capture the supervisor's logger lines by level on one context. */
|
|
function captureLogs(ctx: Context): { warns: string[]; errors: string[]; infos: string[] } {
|
|
const warns: string[] = []
|
|
const errors: string[] = []
|
|
const infos: string[] = []
|
|
ctx.logger.warn = ((message: unknown) => { warns.push(String(message)) }) as typeof ctx.logger.warn
|
|
ctx.logger.error = ((message: unknown) => { errors.push(String(message)) }) as typeof ctx.logger.error
|
|
ctx.logger.info = ((message: unknown) => { infos.push(String(message)) }) as typeof ctx.logger.info
|
|
return { warns, errors, infos }
|
|
}
|
|
|
|
function stdioConfig(reconnect?: Config['reconnect']): Config {
|
|
return {
|
|
transport: 'stdio',
|
|
serverName: 'srv',
|
|
command: 'echo',
|
|
args: [],
|
|
env: {},
|
|
cwd: '',
|
|
toolCallTimeoutMs: 60_000,
|
|
failOnStartupError: false,
|
|
...reconnect === undefined ? {} : { reconnect },
|
|
}
|
|
}
|
|
|
|
/** The tool list the mock server advertises after a successful (re)connect. */
|
|
function listing(...names: string[]): { tools: { name: string; inputSchema: { type: string } }[]; nextCursor: undefined } {
|
|
return {
|
|
tools: names.map(name => ({ name, inputSchema: { type: 'object' } })),
|
|
nextCursor: undefined,
|
|
}
|
|
}
|
|
|
|
let callSeq = 0
|
|
function nextCallId(): CallId {
|
|
return CallId(`reconnect-${++callSeq}`)
|
|
}
|
|
|
|
// ---- Tests ----
|
|
|
|
describe('reconnect supervisor', () => {
|
|
let ctx: Context
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks()
|
|
instances.length = 0
|
|
mockConnect.mockResolvedValue(undefined)
|
|
mockClose.mockImplementation(function (this: { onclose?: () => void }) {
|
|
this.onclose?.()
|
|
return Promise.resolve()
|
|
})
|
|
mockListTools.mockResolvedValue(listing('remote'))
|
|
mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] })
|
|
ctx = await mountRegistry()
|
|
})
|
|
|
|
it('reconnects after a transport close, re-syncs tools through the new generation, and serves calls', async () => {
|
|
const { warns, infos } = captureLogs(ctx)
|
|
await apply(ctx, stdioConfig({ initialDelayMs: 5, maxDelayMs: 40, maxAttempts: 5 }))
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
expect(instances).toHaveLength(1)
|
|
|
|
// The recovered server advertises a different list: the swap must neither
|
|
// duplicate nor leak the pre-crash generation.
|
|
mockListTools.mockResolvedValue(listing('revived'))
|
|
instances[0]!.onclose?.()
|
|
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__revived')).toBeDefined() })
|
|
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
|
expect(instances).toHaveLength(2)
|
|
expect(mockConnect).toHaveBeenCalledTimes(2)
|
|
|
|
// Post-recovery calls execute through the re-registered definition.
|
|
const result = await ctx.tools.execute({
|
|
signal: testToolSignal,
|
|
callId: nextCallId(), name: 'mcp__srv__revived', arguments: {},
|
|
})
|
|
expect(result.isError).toBe(false)
|
|
|
|
// User-visible state: reconnecting and recovered are distinct lines.
|
|
expect(warns.some(line => line.includes('reconnecting in 5ms (attempt 1/5)'))).toBe(true)
|
|
expect(infos.some(line => line.includes('reconnected and re-synced tools'))).toBe(true)
|
|
|
|
// A late close signal from the replaced generation is ignored.
|
|
instances[0]!.onclose?.()
|
|
await sleep(30)
|
|
expect(instances).toHaveLength(2)
|
|
})
|
|
|
|
it('stops at the failure cap, unregisters the tools, and reports final failure', async () => {
|
|
const { warns, errors } = captureLogs(ctx)
|
|
await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 2 }))
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
|
|
mockConnect.mockRejectedValue(new Error('server gone'))
|
|
// A failing close on the failed attempt's cleanup must not break the loop.
|
|
mockClose.mockImplementation(function (this: { onclose?: () => void }) {
|
|
this.onclose?.()
|
|
return Promise.reject(new Error('already closed'))
|
|
})
|
|
instances[0]!.onclose?.()
|
|
|
|
await vi.waitFor(() => {
|
|
expect(errors.some(line => line.includes('giving up after 2 consecutive failed reconnect attempts'))).toBe(true)
|
|
})
|
|
// Stale tools do not leak past final failure.
|
|
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
|
// Initial connect + exactly maxAttempts reconnect attempts.
|
|
expect(mockConnect).toHaveBeenCalledTimes(3)
|
|
expect(warns.some(line => line.includes('connection attempt failed: Error: server gone'))).toBe(true)
|
|
expect(warns.some(line => line.includes('connection failed; retrying in 4ms (attempt 2/2)'))).toBe(true)
|
|
await sleep(30)
|
|
expect(mockConnect).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('gives up behind an in-flight re-sync and removes the generation it publishes', async () => {
|
|
const { errors } = captureLogs(ctx)
|
|
await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 1 }))
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
|
|
const gate: PromiseWithResolvers<unknown> = Promise.withResolvers()
|
|
mockListTools.mockImplementation(() => gate.promise)
|
|
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
|
|
const resync = handler()
|
|
await vi.waitFor(() => { expect(mockListTools).toHaveBeenCalledTimes(2) })
|
|
|
|
mockConnect.mockRejectedValue(new Error('server gone'))
|
|
instances[0]!.onclose?.()
|
|
await vi.waitFor(() => {
|
|
expect(errors.some(line => line.includes('giving up after 1 consecutive failed reconnect attempts'))).toBe(true)
|
|
})
|
|
|
|
gate.resolve(listing('late'))
|
|
await resync
|
|
await vi.waitFor(() => {
|
|
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
|
expect(ctx.tools.get('mcp__srv__late')).toBeUndefined()
|
|
})
|
|
expect(mockConnect).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('does not start a replacement until a failed generation reports that it closed', async () => {
|
|
const { warns } = captureLogs(ctx)
|
|
mockConnect.mockRejectedValueOnce(new Error('initialize failed'))
|
|
// Model the SDK's fire-and-forget close after initialize fails: the
|
|
// harness's second close call returns, but the child has not exited yet.
|
|
mockClose.mockResolvedValue(undefined)
|
|
|
|
const applying = apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 2 }))
|
|
await vi.waitFor(() => { expect(mockClose).toHaveBeenCalled() })
|
|
await sleep(30)
|
|
expect(instances).toHaveLength(1)
|
|
|
|
instances[0]!.onclose?.()
|
|
await applying
|
|
await vi.waitFor(() => { expect(instances).toHaveLength(2) })
|
|
expect(warns.some(line => line.includes('connection failed; retrying in 2ms (attempt 1/2)'))).toBe(true)
|
|
})
|
|
|
|
it('stops reconnecting when a failed generation never reports that it closed', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const { errors } = captureLogs(ctx)
|
|
mockConnect.mockRejectedValue(new Error('initialize failed'))
|
|
mockClose.mockResolvedValue(undefined)
|
|
|
|
const applying = apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 2 }))
|
|
await vi.advanceTimersByTimeAsync(5_000)
|
|
await applying
|
|
|
|
expect(instances).toHaveLength(1)
|
|
expect(errors.some(line => line.includes('reconnect stopped to avoid overlapping server processes'))).toBe(true)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('suppresses retry reporting when disposal owns a pending connect rejection', async () => {
|
|
const { warns } = captureLogs(ctx)
|
|
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
|
mockConnect.mockImplementation(() => gate.promise)
|
|
const handle = startConnection(ctx, stdioConfig(), resolveReconnectPolicy(undefined, 'reconnect'))
|
|
await vi.waitFor(() => { expect(instances).toHaveLength(1) })
|
|
|
|
const disposing = handle.dispose()
|
|
gate.reject(new Error('disposed connect'))
|
|
await disposing
|
|
await handle.ready
|
|
|
|
expect(warns.some(line => line.includes('connection attempt failed'))).toBe(false)
|
|
expect(instances).toHaveLength(1)
|
|
})
|
|
|
|
it('bounds disposal while a resolving generation never reports that it closed', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const { errors } = captureLogs(ctx)
|
|
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
|
mockConnect.mockImplementation(() => gate.promise)
|
|
mockClose.mockResolvedValue(undefined)
|
|
const handle = startConnection(ctx, stdioConfig(), resolveReconnectPolicy(undefined, 'reconnect'))
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
|
|
const disposing = handle.dispose()
|
|
await vi.advanceTimersByTimeAsync(5_000)
|
|
gate.resolve()
|
|
await disposing
|
|
|
|
expect(mockListTools).not.toHaveBeenCalled()
|
|
expect(errors.some(line => line.includes('server shutdown may be incomplete'))).toBe(true)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('dispose during the backoff wait cancels the pending reconnect', async () => {
|
|
await apply(ctx, stdioConfig({ initialDelayMs: 60_000, maxDelayMs: 60_000, maxAttempts: 5 }))
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
|
|
instances[0]!.onclose?.()
|
|
// Now waiting out a 60s backoff; disposal must return promptly anyway.
|
|
await ctx.fiber.dispose()
|
|
await sleep(30)
|
|
expect(mockConnect).toHaveBeenCalledTimes(1)
|
|
expect(instances).toHaveLength(1)
|
|
})
|
|
|
|
it('a transport close after dispose schedules nothing', async () => {
|
|
const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig())
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
|
|
await fiber.dispose()
|
|
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
|
|
|
// The disposer's client.close() fires onclose in the real SDK.
|
|
instances[0]!.onclose?.()
|
|
await sleep(30)
|
|
expect(instances).toHaveLength(1)
|
|
expect(mockConnect).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('reconnect disabled keeps the registered tools and reports manual recovery', async () => {
|
|
const { errors } = captureLogs(ctx)
|
|
await apply(ctx, stdioConfig({ enabled: false }))
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
|
|
instances[0]!.onclose?.()
|
|
await sleep(30)
|
|
expect(mockConnect).toHaveBeenCalledTimes(1)
|
|
// Pre-reconnect contract: the generation stays registered until disposal.
|
|
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
|
expect(errors.some(line => line.includes('connection lost and reconnect is disabled'))).toBe(true)
|
|
})
|
|
it('reconnect disabled after a failed initial connect reports no registered tools', async () => {
|
|
const { errors } = captureLogs(ctx)
|
|
mockConnect.mockRejectedValue(new Error('refused'))
|
|
await apply(ctx, stdioConfig({ enabled: false }))
|
|
await sleep(30)
|
|
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
|
expect(errors.some(line => line.includes('connection failed and reconnect is disabled'))).toBe(true)
|
|
expect(errors.some(line => line.includes('no tools were registered'))).toBe(true)
|
|
})
|
|
|
|
it('an uptime past the stability window resets the attempt budget', async () => {
|
|
const { errors } = captureLogs(ctx)
|
|
await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 30, maxAttempts: 1 }))
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
|
|
instances[0]!.onclose?.()
|
|
await vi.waitFor(() => { expect(instances).toHaveLength(2) })
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
|
|
// Outlive the stability window (= maxDelayMs), then crash again: the
|
|
// budget restarts at attempt 1 instead of exceeding maxAttempts.
|
|
await sleep(40)
|
|
instances[1]!.onclose?.()
|
|
await vi.waitFor(() => { expect(instances).toHaveLength(3) })
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
expect(errors).toHaveLength(0)
|
|
})
|
|
|
|
it('a crash loop with briefly successful connects still exhausts the cap', async () => {
|
|
const { errors } = captureLogs(ctx)
|
|
await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 10_000, maxAttempts: 1 }))
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
|
|
// Crash, recover (attempt 1 of 1), crash again well inside the stability
|
|
// window: the successful connect must not launder the budget.
|
|
instances[0]!.onclose?.()
|
|
await vi.waitFor(() => { expect(instances).toHaveLength(2) })
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
instances[1]!.onclose?.()
|
|
|
|
await vi.waitFor(() => {
|
|
expect(errors.some(line => line.includes('giving up after 1 consecutive failed reconnect attempts'))).toBe(true)
|
|
})
|
|
expect(instances).toHaveLength(2)
|
|
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
|
})
|
|
|
|
it('a connect rejection racing its own transport close schedules exactly one retry per attempt', async () => {
|
|
const { errors } = captureLogs(ctx)
|
|
await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 3 }))
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
|
|
// Each reconnect attempt sees the stdio transport die (onclose) AND its
|
|
// connect() reject — the real SDK emits both for a spawn failure.
|
|
mockConnect.mockImplementation(async () => {
|
|
instances.at(-1)!.onclose?.()
|
|
throw new Error('spawn failed')
|
|
})
|
|
instances[0]!.onclose?.()
|
|
|
|
await vi.waitFor(() => {
|
|
expect(errors.some(line => line.includes('giving up after 3 consecutive failed reconnect attempts'))).toBe(true)
|
|
})
|
|
// Initial generation + exactly one generation per budgeted attempt: a
|
|
// double-scheduled retry would create more.
|
|
expect(instances).toHaveLength(4)
|
|
expect(errors.filter(line => line.includes('giving up')).length).toBe(1)
|
|
})
|
|
|
|
it('a transport that closes during a resolving connect registers nothing from the dead generation', async () => {
|
|
await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 2 }))
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
expect(mockListTools).toHaveBeenCalledTimes(1)
|
|
|
|
mockConnect.mockImplementation(async () => {
|
|
instances.at(-1)!.onclose?.()
|
|
})
|
|
instances[0]!.onclose?.()
|
|
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() })
|
|
// The dead generations never reached tool discovery.
|
|
expect(mockListTools).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('dispose during an in-flight initial sync quiesces without leaking tools', async () => {
|
|
const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 5 }))
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
|
|
// Block the reconnect attempt's tool discovery until after dispose starts.
|
|
const gate: PromiseWithResolvers<unknown> = Promise.withResolvers()
|
|
mockListTools.mockImplementation(() => gate.promise)
|
|
instances[0]!.onclose?.()
|
|
await vi.waitFor(() => { expect(mockListTools).toHaveBeenCalledTimes(2) })
|
|
|
|
const disposing = fiber.dispose()
|
|
await sleep(10)
|
|
gate.resolve(listing('late'))
|
|
await disposing
|
|
|
|
// The late sync's swap ran, then disposal unregistered its result: no
|
|
// generation survives the plugin.
|
|
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
|
expect(ctx.tools.get('mcp__srv__late')).toBeUndefined()
|
|
})
|
|
|
|
it('a re-sync failing because dispose closed the transport stays silent', async () => {
|
|
const { errors } = captureLogs(ctx)
|
|
const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig())
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
|
|
const gate: PromiseWithResolvers<unknown> = Promise.withResolvers()
|
|
mockListTools.mockImplementation(() => gate.promise)
|
|
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
|
|
const resync = handler()
|
|
await vi.waitFor(() => { expect(mockListTools).toHaveBeenCalledTimes(2) })
|
|
|
|
const disposing = fiber.dispose()
|
|
await sleep(10)
|
|
gate.reject(new Error('Connection closed'))
|
|
await disposing
|
|
await resync
|
|
|
|
expect(errors.some(line => line.includes('tool re-sync failed'))).toBe(false)
|
|
})
|
|
|
|
it('a stale notification handler from a replaced generation is ignored', async () => {
|
|
await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 5 }))
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
|
|
instances[0]!.onclose?.()
|
|
await vi.waitFor(() => { expect(instances).toHaveLength(2) })
|
|
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
|
const listCalls = mockListTools.mock.calls.length
|
|
|
|
const staleHandler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
|
|
await staleHandler()
|
|
expect(mockListTools).toHaveBeenCalledTimes(listCalls)
|
|
})
|
|
})
|
|
|
|
// ---- Policy resolution ----
|
|
|
|
describe('resolveReconnectPolicy', () => {
|
|
const path = 'mcp-client(srv): reconnect'
|
|
|
|
it('resolves omission to the defaults, frozen', () => {
|
|
const policy = resolveReconnectPolicy(undefined, path)
|
|
expect(policy).toEqual(RECONNECT_DEFAULTS)
|
|
expect(Object.isFrozen(policy)).toBe(true)
|
|
})
|
|
|
|
it('keeps explicit values', () => {
|
|
expect(resolveReconnectPolicy(
|
|
{ enabled: false, initialDelayMs: 1, maxDelayMs: 2, maxAttempts: 7 },
|
|
path,
|
|
)).toEqual({ enabled: false, initialDelayMs: 1, maxDelayMs: 2, maxAttempts: 7 })
|
|
})
|
|
|
|
it('rejects unknown keys', () => {
|
|
expect(() => resolveReconnectPolicy({ jitterRatio: 0.5 } as never, path))
|
|
.toThrow(/reconnect\.jitterRatio is not a reconnect option/)
|
|
})
|
|
|
|
it('rejects out-of-range delays', () => {
|
|
expect(() => resolveReconnectPolicy({ initialDelayMs: 0 }, path)).toThrow(/initialDelayMs must be a positive finite number/)
|
|
expect(() => resolveReconnectPolicy({ initialDelayMs: Number.POSITIVE_INFINITY }, path)).toThrow(/initialDelayMs/)
|
|
expect(() => resolveReconnectPolicy({ maxDelayMs: -1 }, path)).toThrow(/maxDelayMs must be a positive finite number/)
|
|
})
|
|
|
|
it('rejects an initial delay above the ceiling', () => {
|
|
expect(() => resolveReconnectPolicy({ initialDelayMs: 100, maxDelayMs: 5 }, path))
|
|
.toThrow(/initialDelayMs must be less than or equal to maxDelayMs/)
|
|
})
|
|
|
|
it('rejects non-positive-integer attempt caps', () => {
|
|
expect(() => resolveReconnectPolicy({ maxAttempts: 0 }, path)).toThrow(/maxAttempts must be a positive integer/)
|
|
expect(() => resolveReconnectPolicy({ maxAttempts: 1.5 }, path)).toThrow(/maxAttempts must be a positive integer/)
|
|
})
|
|
|
|
it('apply fails loud at load on a misconfigured reconnect', async () => {
|
|
const ctx = await mountRegistry()
|
|
await expect(apply(ctx, stdioConfig({ initialDelayMs: 100, maxDelayMs: 5 })))
|
|
.rejects.toThrow(/initialDelayMs must be less than or equal to maxDelayMs/)
|
|
})
|
|
})
|