| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292 |
- /**
- * 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<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 {
- 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<Context> {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRegistry)
- 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
- }
- 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<void>
- 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<void>
- // 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<void>
- 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()
- })
- })
|