apply.spec.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. /**
  2. * Tests for the mcp-client plugin's `apply` lifecycle entry point.
  3. * Isolated file so vi.mock of the MCP SDK doesn't pollute other test suites.
  4. */
  5. import { describe, expect, it, vi, beforeEach } from 'vitest'
  6. import { Context } from 'cordis'
  7. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  8. import ToolRegistry from '@deepseek-ai/dsh-tools'
  9. import type { Config } from '@deepseek-ai/dsh-mcp-client'
  10. // ---- Mock MCP SDK ----
  11. // vi.mock factories are hoisted above every import/const, so the mock fns and
  12. // class must be created inside vi.hoisted to exist when the factories run.
  13. const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient } = vi.hoisted(() => {
  14. const mockConnect = vi.fn<() => Promise<void>>()
  15. const mockClose = vi.fn<() => Promise<void>>()
  16. const mockListTools = vi.fn<(_params?: Record<string, unknown>) => Promise<unknown>>()
  17. const mockCallTool = vi.fn<(
  18. _params?: Record<string, unknown>, _compatibilitySchema?: unknown, _options?: unknown,
  19. ) => Promise<unknown>>()
  20. const mockSetNotificationHandler = vi.fn()
  21. const mockRequest = vi.fn(async (
  22. request: { method: string; params?: Record<string, unknown> },
  23. _schema: unknown,
  24. options?: unknown,
  25. ): Promise<unknown> => {
  26. if (request.method === 'tools/list') return await mockListTools(request.params)
  27. if (request.method === 'tools/call') return await mockCallTool(request.params, undefined, options)
  28. throw new Error(`unexpected MCP request: ${request.method}`)
  29. })
  30. class MockClient {
  31. connect = mockConnect
  32. close = mockClose
  33. listTools = mockListTools
  34. callTool = mockCallTool
  35. request = mockRequest
  36. setNotificationHandler = mockSetNotificationHandler
  37. }
  38. return { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient }
  39. })
  40. vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
  41. Client: MockClient,
  42. }))
  43. vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
  44. StdioClientTransport: vi.fn(),
  45. }))
  46. vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
  47. StreamableHTTPClientTransport: vi.fn(),
  48. }))
  49. // vi.mock is hoisted above static imports, so the module under test sees the
  50. // mocked SDK even through a static import.
  51. import { apply, name, inject, Config as ConfigSchema } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
  52. // ---- Helpers ----
  53. async function mountRegistry(): Promise<Context> {
  54. const ctx = new Context()
  55. await ctx.plugin(SystemPrompt)
  56. await ctx.plugin(ToolRegistry)
  57. return ctx
  58. }
  59. function sleep(ms: number): Promise<void> {
  60. // Annotated binding (not withResolvers<void>()): the tests lint layer runs
  61. // no-invalid-void-type with default options, which rejects the explicit
  62. // type argument in call position but accepts the inferred form.
  63. const gate: PromiseWithResolvers<void> = Promise.withResolvers()
  64. setTimeout(gate.resolve, ms)
  65. return gate.promise
  66. }
  67. const stdioConfig: Config = {
  68. transport: 'stdio',
  69. serverName: 'srv',
  70. command: 'echo',
  71. args: [],
  72. env: {},
  73. cwd: '',
  74. toolCallTimeoutMs: 60_000,
  75. }
  76. // ---- Tests ----
  77. describe('mcp-client plugin module exports', () => {
  78. it('exports name, inject, and Config', () => {
  79. expect(name).toBe('mcp-client')
  80. expect(inject).toEqual(['tools'])
  81. expect(ConfigSchema).toBeDefined()
  82. })
  83. it('Config schema rejects a missing serverName', () => {
  84. expect(() => ConfigSchema({
  85. transport: 'stdio',
  86. command: 'echo',
  87. } as never)).toThrow()
  88. })
  89. it('Config schema rejects an invalid serverName', () => {
  90. // schemastery unions wrap branch errors in a generic "expected ... but got"
  91. // message, so assert the throw, not the inner pattern text.
  92. expect(() => ConfigSchema({
  93. transport: 'stdio',
  94. serverName: 'bad name!',
  95. command: 'echo',
  96. } as never)).toThrow()
  97. expect(() => ConfigSchema({
  98. transport: 'stdio',
  99. serverName: 'x'.repeat(33),
  100. command: 'echo',
  101. } as never)).toThrow()
  102. })
  103. it('Config schema accepts a valid serverName', () => {
  104. const resolved = ConfigSchema({
  105. transport: 'stdio',
  106. serverName: 'github-prod_1',
  107. command: 'echo',
  108. } as never)
  109. expect(resolved.serverName).toBe('github-prod_1')
  110. })
  111. })
  112. describe('apply (plugin lifecycle)', () => {
  113. let ctx: Context
  114. beforeEach(async () => {
  115. vi.clearAllMocks()
  116. mockConnect.mockResolvedValue(undefined)
  117. mockClose.mockResolvedValue(undefined)
  118. mockListTools.mockResolvedValue({
  119. tools: [{ name: 'remote', description: 'A remote tool', inputSchema: { type: 'object' } }],
  120. nextCursor: undefined,
  121. })
  122. mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] })
  123. ctx = await mountRegistry()
  124. })
  125. it('connects, syncs tools under the namespace, and registers a notification handler', async () => {
  126. apply(ctx, stdioConfig)
  127. await sleep(50)
  128. expect(mockConnect).toHaveBeenCalled()
  129. expect(mockListTools).toHaveBeenCalled()
  130. expect(mockSetNotificationHandler).toHaveBeenCalled()
  131. expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
  132. expect(ctx.tools.get('remote')).toBeUndefined()
  133. })
  134. it('rejects a duplicate serverName at load and leaves the first instance intact', async () => {
  135. apply(ctx, stdioConfig)
  136. await sleep(50)
  137. expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
  138. expect(() => { apply(ctx, stdioConfig) }).toThrow(/serverName "srv" is already in use/)
  139. // First instance unaffected.
  140. expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
  141. })
  142. it('releases the serverName reservation on dispose', async () => {
  143. const first = new Context()
  144. await first.plugin(SystemPrompt)
  145. await first.plugin(ToolRegistry)
  146. apply(first, stdioConfig)
  147. await sleep(50)
  148. await first.fiber.dispose()
  149. await sleep(50)
  150. // Same root would conflict; a fresh app root reuses the name freely,
  151. // and the disposed instance no longer holds the reservation on its root.
  152. const second = new Context()
  153. await second.plugin(SystemPrompt)
  154. await second.plugin(ToolRegistry)
  155. expect(() => { apply(second, stdioConfig) }).not.toThrow()
  156. })
  157. it('scopes serverName reservations per app root', async () => {
  158. const other = await mountRegistry()
  159. apply(ctx, stdioConfig)
  160. // Same serverName on a DIFFERENT root is fine.
  161. expect(() => { apply(other, stdioConfig) }).not.toThrow()
  162. await sleep(50)
  163. expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
  164. expect(other.tools.get('mcp__srv__remote')).toBeDefined()
  165. })
  166. it('logs error and registers no tools when connect fails; dispose is a no-op', async () => {
  167. mockConnect.mockRejectedValue(new Error('connection refused'))
  168. apply(ctx, stdioConfig)
  169. await sleep(50)
  170. expect(mockListTools).not.toHaveBeenCalled()
  171. expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
  172. // Disposal exercises the empty fallback accessor: nothing to unregister,
  173. // close still attempted, no throw.
  174. await ctx.fiber.dispose()
  175. await sleep(50)
  176. expect(mockClose).toHaveBeenCalled()
  177. })
  178. it('re-syncs tools on ToolListChanged notification', async () => {
  179. apply(ctx, stdioConfig)
  180. await sleep(50)
  181. expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
  182. mockListTools.mockResolvedValue({
  183. tools: [{ name: 'updated', inputSchema: { type: 'object' } }],
  184. nextCursor: undefined,
  185. })
  186. const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
  187. await handler()
  188. expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
  189. expect(ctx.tools.get('mcp__srv__updated')).toBeDefined()
  190. })
  191. it('keeps the previous generation when a re-sync fails', async () => {
  192. apply(ctx, stdioConfig)
  193. await sleep(50)
  194. expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
  195. mockListTools.mockRejectedValue(new Error('flaky server'))
  196. const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
  197. // Must not reject (contained), and must keep the last good generation.
  198. await handler()
  199. expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
  200. })
  201. it('effect disposer unregisters the CURRENT generation and closes client', async () => {
  202. // Load through ctx.plugin so ONLY the plugin's fiber is disposed — the
  203. // registry must survive to observe the unregistration.
  204. const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig)
  205. await sleep(50)
  206. // Advance to a second generation first.
  207. mockListTools.mockResolvedValue({
  208. tools: [{ name: 'updated', inputSchema: { type: 'object' } }],
  209. nextCursor: undefined,
  210. })
  211. const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
  212. await handler()
  213. expect(ctx.tools.get('mcp__srv__updated')).toBeDefined()
  214. await fiber.dispose()
  215. await sleep(50)
  216. expect(mockClose).toHaveBeenCalled()
  217. // The live (second) generation was unregistered, not just the first.
  218. expect(ctx.tools.get('mcp__srv__updated')).toBeUndefined()
  219. })
  220. it('effect disposer handles client.close failure gracefully', async () => {
  221. mockClose.mockRejectedValue(new Error('already closed'))
  222. apply(ctx, stdioConfig)
  223. await sleep(50)
  224. // Should not throw when dispose is triggered.
  225. await ctx.fiber.dispose()
  226. await sleep(50)
  227. expect(mockClose).toHaveBeenCalled()
  228. })
  229. it('uses streamable-http config path', async () => {
  230. const httpConfig: Config = {
  231. transport: 'streamable-http',
  232. serverName: 'web',
  233. url: 'http://localhost:3000/mcp',
  234. headers: { Authorization: 'Bearer x' },
  235. toolCallTimeoutMs: 30_000,
  236. }
  237. apply(ctx, httpConfig)
  238. await sleep(50)
  239. expect(mockConnect).toHaveBeenCalled()
  240. expect(ctx.tools.get('mcp__web__remote')).toBeDefined()
  241. })
  242. })