/** * Tests for the mcp-client plugin's `apply` lifecycle entry point. * 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 'cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' 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 } = vi.hoisted(() => { const mockConnect = vi.fn<() => Promise>() const mockClose = vi.fn<() => Promise>() const mockListTools = vi.fn<(_params?: Record) => Promise>() const mockCallTool = vi.fn<( _params?: Record, _compatibilitySchema?: unknown, _options?: unknown, ) => Promise>() const mockSetNotificationHandler = vi.fn() const mockRequest = vi.fn(async ( request: { method: string; params?: Record }, _schema: unknown, options?: unknown, ): Promise => { 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 { connect = mockConnect close = mockClose listTools = mockListTools callTool = mockCallTool request = mockRequest setNotificationHandler = mockSetNotificationHandler } return { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient } }) 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 module under test sees the // mocked SDK even through a static import. import { apply, name, inject, Config as ConfigSchema } from '@deepseek-ai/dsh-mcp-client/src/index.ts' // ---- Helpers ---- async function mountRegistry(): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) return ctx } function sleep(ms: number): Promise { // Annotated binding (not withResolvers()): 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 = Promise.withResolvers() setTimeout(gate.resolve, ms) return gate.promise } const stdioConfig: Config = { transport: 'stdio', serverName: 'srv', command: 'echo', args: [], env: {}, cwd: '', toolCallTimeoutMs: 60_000, } // ---- Tests ---- describe('mcp-client plugin module exports', () => { it('exports name, inject, and Config', () => { expect(name).toBe('mcp-client') expect(inject).toEqual(['tools']) expect(ConfigSchema).toBeDefined() }) it('Config schema rejects a missing serverName', () => { expect(() => ConfigSchema({ transport: 'stdio', command: 'echo', } as never)).toThrow() }) it('Config schema rejects an invalid serverName', () => { // schemastery unions wrap branch errors in a generic "expected ... but got" // message, so assert the throw, not the inner pattern text. expect(() => ConfigSchema({ transport: 'stdio', serverName: 'bad name!', command: 'echo', } as never)).toThrow() expect(() => ConfigSchema({ transport: 'stdio', serverName: 'x'.repeat(33), command: 'echo', } as never)).toThrow() }) it('Config schema accepts a valid serverName', () => { const resolved = ConfigSchema({ transport: 'stdio', serverName: 'github-prod_1', command: 'echo', } as never) expect(resolved.serverName).toBe('github-prod_1') }) }) describe('apply (plugin lifecycle)', () => { let ctx: Context beforeEach(async () => { vi.clearAllMocks() mockConnect.mockResolvedValue(undefined) mockClose.mockResolvedValue(undefined) mockListTools.mockResolvedValue({ tools: [{ name: 'remote', description: 'A remote tool', inputSchema: { type: 'object' } }], nextCursor: undefined, }) mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }) ctx = await mountRegistry() }) it('connects, syncs tools under the namespace, and registers a notification handler', async () => { apply(ctx, stdioConfig) await sleep(50) expect(mockConnect).toHaveBeenCalled() expect(mockListTools).toHaveBeenCalled() expect(mockSetNotificationHandler).toHaveBeenCalled() expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() expect(ctx.tools.get('remote')).toBeUndefined() }) it('rejects a duplicate serverName at load and leaves the first instance intact', async () => { apply(ctx, stdioConfig) await sleep(50) expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() expect(() => { apply(ctx, stdioConfig) }).toThrow(/serverName "srv" is already in use/) // First instance unaffected. expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) it('releases the serverName reservation on dispose', async () => { const first = new Context() await first.plugin(SystemPrompt) await first.plugin(ToolRegistry) apply(first, stdioConfig) await sleep(50) await first.fiber.dispose() await sleep(50) // Same root would conflict; a fresh app root reuses the name freely, // and the disposed instance no longer holds the reservation on its root. const second = new Context() await second.plugin(SystemPrompt) await second.plugin(ToolRegistry) expect(() => { apply(second, stdioConfig) }).not.toThrow() }) it('scopes serverName reservations per app root', async () => { const other = await mountRegistry() apply(ctx, stdioConfig) // Same serverName on a DIFFERENT root is fine. expect(() => { apply(other, stdioConfig) }).not.toThrow() await sleep(50) expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() expect(other.tools.get('mcp__srv__remote')).toBeDefined() }) it('logs error and registers no tools when connect fails; dispose is a no-op', async () => { mockConnect.mockRejectedValue(new Error('connection refused')) apply(ctx, stdioConfig) await sleep(50) expect(mockListTools).not.toHaveBeenCalled() expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() // Disposal exercises the empty fallback accessor: nothing to unregister, // close still attempted, no throw. await ctx.fiber.dispose() await sleep(50) expect(mockClose).toHaveBeenCalled() }) it('re-syncs tools on ToolListChanged notification', async () => { apply(ctx, stdioConfig) await sleep(50) expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() mockListTools.mockResolvedValue({ tools: [{ name: 'updated', inputSchema: { type: 'object' } }], nextCursor: undefined, }) const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise await handler() expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() expect(ctx.tools.get('mcp__srv__updated')).toBeDefined() }) it('keeps the previous generation when a re-sync fails', async () => { apply(ctx, stdioConfig) await sleep(50) expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() mockListTools.mockRejectedValue(new Error('flaky server')) const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise // Must not reject (contained), and must keep the last good generation. await handler() expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) it('effect disposer unregisters the CURRENT generation and closes client', async () => { // Load through ctx.plugin so ONLY the plugin's fiber is disposed — the // registry must survive to observe the unregistration. const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig) await sleep(50) // Advance to a second generation first. mockListTools.mockResolvedValue({ tools: [{ name: 'updated', inputSchema: { type: 'object' } }], nextCursor: undefined, }) const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise await handler() expect(ctx.tools.get('mcp__srv__updated')).toBeDefined() await fiber.dispose() await sleep(50) expect(mockClose).toHaveBeenCalled() // The live (second) generation was unregistered, not just the first. expect(ctx.tools.get('mcp__srv__updated')).toBeUndefined() }) it('effect disposer handles client.close failure gracefully', async () => { mockClose.mockRejectedValue(new Error('already closed')) apply(ctx, stdioConfig) await sleep(50) // Should not throw when dispose is triggered. await ctx.fiber.dispose() await sleep(50) expect(mockClose).toHaveBeenCalled() }) it('uses streamable-http config path', async () => { const httpConfig: Config = { transport: 'streamable-http', serverName: 'web', url: 'http://localhost:3000/mcp', headers: { Authorization: 'Bearer x' }, toolCallTimeoutMs: 30_000, } apply(ctx, httpConfig) await sleep(50) expect(mockConnect).toHaveBeenCalled() expect(ctx.tools.get('mcp__web__remote')).toBeDefined() }) })