mcp-client.e2e.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. /**
  2. * End-to-end tests for dsh-mcp-client. Exercises the REAL MCP protocol against:
  3. * 1. A self-written fixture server over stdio (controlled edge cases)
  4. * 2. @modelcontextprotocol/server-everything (official integration test server)
  5. * 3. @modelcontextprotocol/server-filesystem (real filesystem operations)
  6. * 4. An in-process StreamableHTTPServerTransport server over Streamable HTTP
  7. *
  8. * No API key needed — all servers are local/keyless.
  9. */
  10. import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
  11. import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'
  12. import { tmpdir } from 'node:os'
  13. import { join } from 'node:path'
  14. import { fileURLToPath } from 'node:url'
  15. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  16. import { Context } from 'cordis'
  17. import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
  18. import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
  19. import { z } from 'zod'
  20. import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
  21. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  22. import ToolRegistry from '@deepseek-ai/dsh-tools'
  23. import { CallId } from '@deepseek-ai/dsh-llm'
  24. import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
  25. import { publicToolName } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
  26. import type { Config } from '@deepseek-ai/dsh-mcp-client'
  27. const testToolSignal = new AbortController().signal
  28. const fixtureServerPath = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
  29. // Resolve package-local .bin for pnpm-hoisted MCP server binaries.
  30. const packageDir = fileURLToPath(new URL('..', import.meta.url))
  31. const localBin = join(packageDir, 'node_modules', '.bin')
  32. // ---- Helpers ----
  33. async function mountRegistry(): Promise<Context> {
  34. const ctx = new Context()
  35. await ctx.plugin(SystemPrompt)
  36. await ctx.plugin(ToolRegistry)
  37. return ctx
  38. }
  39. /** Apply the MCP client plugin and wait for tools to be registered. */
  40. async function applyAndWait(ctx: Context, config: Config, timeoutMs = 20_000): Promise<void> {
  41. // Annotated bindings (not withResolvers<void>()): the tests lint layer runs
  42. // no-invalid-void-type with default options, which rejects the explicit
  43. // type argument in call position but accepts the inferred form.
  44. const gate: PromiseWithResolvers<void> = Promise.withResolvers()
  45. const timer = setTimeout(
  46. () => { gate.reject(new Error(`applyAndWait timed out after ${timeoutMs}ms — no tools/change event`)) },
  47. timeoutMs,
  48. )
  49. ctx.on('tools/change', () => { clearTimeout(timer); gate.resolve() })
  50. apply(ctx, config)
  51. await gate.promise
  52. }
  53. function sleep(ms: number): Promise<void> {
  54. const gate: PromiseWithResolvers<void> = Promise.withResolvers()
  55. setTimeout(gate.resolve, ms)
  56. return gate.promise
  57. }
  58. /** Narrow a result content block to its text, failing the test on any other shape. */
  59. function textOf(block: unknown): string {
  60. if (block && typeof block === 'object' && 'text' in block && typeof block.text === 'string') {
  61. return block.text
  62. }
  63. throw new Error(`expected a text content block, got ${JSON.stringify(block)}`)
  64. }
  65. let callSeq = 0
  66. function nextCallId(): CallId {
  67. return CallId(`e2e-${++callSeq}`)
  68. }
  69. // ---- Fixture server tests ----
  70. describe('fixture server — controlled scenarios', () => {
  71. let ctx: Context
  72. const fixtureConfig: Config = {
  73. transport: 'stdio',
  74. serverName: 'fixture',
  75. command: process.execPath,
  76. args: [fixtureServerPath],
  77. env: {},
  78. cwd: packageDir,
  79. toolCallTimeoutMs: 15_000,
  80. }
  81. beforeAll(async () => {
  82. ctx = await mountRegistry()
  83. await applyAndWait(ctx, fixtureConfig)
  84. }, 30_000)
  85. afterAll(async () => {
  86. if (ctx) await ctx.fiber.dispose()
  87. await sleep(200)
  88. })
  89. it('discovers all fixture tools under the server namespace', () => {
  90. const schemas = ctx.tools.schemas()
  91. const names = schemas.map(s => s.name)
  92. expect(names).toContain('mcp__fixture__add')
  93. expect(names).toContain('mcp__fixture__greet')
  94. expect(names).toContain('mcp__fixture__fail')
  95. expect(names).toContain('mcp__fixture__image')
  96. // Raw names are not registered.
  97. expect(names).not.toContain('add')
  98. })
  99. it('normalizes the dotted tool name with a deterministic hash suffix', () => {
  100. const publicName = publicToolName('fixture', 'admin.reset')
  101. expect(publicName).toMatch(/^mcp__fixture__admin_reset_[0-9a-f]{12}$/)
  102. expect(ctx.tools.get(publicName)).toBeDefined()
  103. })
  104. it('executes the dotted tool via its normalized public name', async () => {
  105. const result = await ctx.tools.execute({
  106. signal: testToolSignal,
  107. callId: nextCallId(), name: publicToolName('fixture', 'admin.reset'), arguments: {},
  108. })
  109. expect(result.isError).toBe(false)
  110. expect(result.content[0]).toEqual({ type: 'text', text: 'reset done' })
  111. })
  112. it('executes add(2, 3) → "5"', async () => {
  113. const result = await ctx.tools.execute({
  114. signal: testToolSignal,
  115. callId: nextCallId(), name: 'mcp__fixture__add', arguments: { a: 2, b: 3 },
  116. })
  117. expect(result.isError).toBe(false)
  118. expect(result.content[0]).toEqual({ type: 'text', text: '5' })
  119. })
  120. it('executes greet("World") → "Hello, World!"', async () => {
  121. const result = await ctx.tools.execute({
  122. signal: testToolSignal,
  123. callId: nextCallId(), name: 'mcp__fixture__greet', arguments: { name: 'World' },
  124. })
  125. expect(result.isError).toBe(false)
  126. expect(result.content[0]).toEqual({ type: 'text', text: 'Hello, World!' })
  127. })
  128. it('executes fail() → isError result', async () => {
  129. const result = await ctx.tools.execute({
  130. signal: testToolSignal,
  131. callId: nextCallId(), name: 'mcp__fixture__fail', arguments: {},
  132. })
  133. expect(result.isError).toBe(true)
  134. expect(result.content[0]).toMatchObject({ type: 'text' })
  135. })
  136. it('executes image() → image placeholder', async () => {
  137. const result = await ctx.tools.execute({
  138. signal: testToolSignal,
  139. callId: nextCallId(), name: 'mcp__fixture__image', arguments: {},
  140. })
  141. expect(result.isError).toBe(false)
  142. const text = textOf(result.content[0])
  143. expect(text).toContain('Here is an image:')
  144. expect(text).toContain('[image: image/png, content discarded]')
  145. expect(text).toContain('End of image.')
  146. })
  147. })
  148. describe('fixture server — duplicate serverName', () => {
  149. it('rejects a second instance with the same serverName on one root', async () => {
  150. const ctx = await mountRegistry()
  151. const config: Config = {
  152. transport: 'stdio',
  153. serverName: 'dup',
  154. command: process.execPath,
  155. args: [fixtureServerPath],
  156. env: {},
  157. cwd: packageDir,
  158. toolCallTimeoutMs: 15_000,
  159. }
  160. await applyAndWait(ctx, config)
  161. expect(() => { apply(ctx, config) }).toThrow(/serverName "dup" is already in use/)
  162. await ctx.fiber.dispose()
  163. await sleep(200)
  164. }, 30_000)
  165. })
  166. describe('fixture server — disposal', () => {
  167. it('disposes cleanly without error', async () => {
  168. const ctx = await mountRegistry()
  169. await applyAndWait(ctx, {
  170. transport: 'stdio',
  171. serverName: 'fixture',
  172. command: process.execPath,
  173. args: [fixtureServerPath],
  174. env: {},
  175. cwd: packageDir,
  176. toolCallTimeoutMs: 15_000,
  177. })
  178. // Tools are registered before dispose.
  179. expect(ctx.tools.get('mcp__fixture__add')).toBeDefined()
  180. expect(ctx.tools.schemas().length).toBeGreaterThanOrEqual(4)
  181. // Dispose should complete without throwing.
  182. await ctx.fiber.dispose()
  183. await sleep(200)
  184. }, 30_000)
  185. })
  186. // ---- @modelcontextprotocol/server-everything ----
  187. describe('server-everything — official test server', () => {
  188. let ctx: Context
  189. const config: Config = {
  190. transport: 'stdio',
  191. serverName: 'everything',
  192. command: join(localBin, 'mcp-server-everything'),
  193. args: ['stdio'],
  194. env: {},
  195. cwd: '',
  196. toolCallTimeoutMs: 30_000,
  197. }
  198. beforeAll(async () => {
  199. ctx = await mountRegistry()
  200. await applyAndWait(ctx, config)
  201. }, 60_000)
  202. afterAll(async () => {
  203. if (ctx) await ctx.fiber.dispose()
  204. await sleep(500)
  205. })
  206. it('discovers tools from server-everything', () => {
  207. const schemas = ctx.tools.schemas()
  208. const names = schemas.map(s => s.name)
  209. expect(names).toContain('mcp__everything__echo')
  210. expect(names).toContain('mcp__everything__get-sum')
  211. expect(names).toContain('mcp__everything__get-tiny-image')
  212. expect(names.length).toBeGreaterThanOrEqual(8)
  213. })
  214. it('executes echo({ message: "hello" }) → "Echo: hello"', async () => {
  215. const result = await ctx.tools.execute({
  216. signal: testToolSignal,
  217. callId: nextCallId(), name: 'mcp__everything__echo', arguments: { message: 'hello' },
  218. })
  219. expect(result.isError).toBe(false)
  220. expect(textOf(result.content[0])).toBe('Echo: hello')
  221. })
  222. it('executes get-sum({ a: 3, b: 7 }) → contains "10"', async () => {
  223. const result = await ctx.tools.execute({
  224. signal: testToolSignal,
  225. callId: nextCallId(), name: 'mcp__everything__get-sum', arguments: { a: 3, b: 7 },
  226. })
  227. expect(result.isError).toBe(false)
  228. expect(textOf(result.content[0])).toContain('10')
  229. })
  230. it('executes get-tiny-image → image placeholder', async () => {
  231. const result = await ctx.tools.execute({
  232. signal: testToolSignal,
  233. callId: nextCallId(), name: 'mcp__everything__get-tiny-image', arguments: {},
  234. })
  235. expect(result.isError).toBe(false)
  236. expect(textOf(result.content[0])).toContain('[image: image/png, content discarded]')
  237. })
  238. })
  239. // ---- @modelcontextprotocol/server-filesystem ----
  240. describe('server-filesystem — real filesystem operations', () => {
  241. let ctx: Context
  242. let tempDir: string
  243. beforeAll(async () => {
  244. tempDir = await mkdtemp(join(tmpdir(), 'mcp-fs-e2e-'))
  245. ctx = await mountRegistry()
  246. const config: Config = {
  247. transport: 'stdio',
  248. serverName: 'filesystem',
  249. command: join(localBin, 'mcp-server-filesystem'),
  250. args: [tempDir],
  251. env: {},
  252. cwd: '',
  253. toolCallTimeoutMs: 30_000,
  254. }
  255. await applyAndWait(ctx, config)
  256. }, 60_000)
  257. afterAll(async () => {
  258. if (ctx) await ctx.fiber.dispose()
  259. await sleep(500)
  260. await rm(tempDir, { recursive: true, force: true })
  261. })
  262. it('discovers filesystem tools', () => {
  263. const schemas = ctx.tools.schemas()
  264. const names = schemas.map(s => s.name)
  265. expect(names).toContain('mcp__filesystem__read_file')
  266. expect(names).toContain('mcp__filesystem__write_file')
  267. expect(names).toContain('mcp__filesystem__list_directory')
  268. })
  269. it('write_file + read_file round-trip', async () => {
  270. const filePath = join(tempDir, 'test.txt')
  271. const content = 'Hello from MCP e2e test!'
  272. const writeResult = await ctx.tools.execute({
  273. signal: testToolSignal,
  274. callId: nextCallId(), name: 'mcp__filesystem__write_file', arguments: { path: filePath, content },
  275. })
  276. expect(writeResult.isError).toBe(false)
  277. // Assert the filesystem effect independently of the tool result.
  278. const onDisk = await readFile(filePath, 'utf8')
  279. expect(onDisk).toBe(content)
  280. const readResult = await ctx.tools.execute({
  281. signal: testToolSignal,
  282. callId: nextCallId(), name: 'mcp__filesystem__read_file', arguments: { path: filePath },
  283. })
  284. expect(readResult.isError).toBe(false)
  285. expect(textOf(readResult.content[0])).toContain(content)
  286. })
  287. it('list_directory shows written file', async () => {
  288. await writeFile(join(tempDir, 'listed.txt'), 'listed')
  289. const result = await ctx.tools.execute({
  290. signal: testToolSignal,
  291. callId: nextCallId(), name: 'mcp__filesystem__list_directory', arguments: { path: tempDir },
  292. })
  293. expect(result.isError).toBe(false)
  294. expect(textOf(result.content[0])).toContain('listed.txt')
  295. })
  296. })
  297. // ---- Streamable HTTP transport ----
  298. describe('streamable-http — in-process MCP server', () => {
  299. let ctx: Context
  300. let httpServer: Server
  301. let baseUrl: string
  302. /** Authorization header values observed by the HTTP server, in arrival order. */
  303. const seenAuth: Array<string | undefined> = []
  304. /**
  305. * Stateless Streamable HTTP endpoint: a fresh McpServer + server transport
  306. * per request (the SDK's documented stateless pattern — no session id, no
  307. * SSE stream to keep). The tool set mirrors a minimal fixture server.
  308. */
  309. async function handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
  310. seenAuth.push(req.headers.authorization)
  311. const server = new McpServer(
  312. { name: 'http-fixture', version: '1.0.0' },
  313. { capabilities: { tools: {} } },
  314. )
  315. server.registerTool('ping', {
  316. description: 'Replies pong.',
  317. inputSchema: {},
  318. }, async () => ({
  319. content: [{ type: 'text', text: 'pong' }],
  320. }))
  321. server.registerTool('shout', {
  322. description: 'Upper-cases a message.',
  323. inputSchema: { message: z.string().describe('Message to upper-case') },
  324. }, async args => ({
  325. content: [{ type: 'text', text: args.message.toUpperCase() }],
  326. }))
  327. // Stateless mode: sessionIdGenerator ABSENT (the runtime treats absent and
  328. // explicit-undefined identically; exactOptionalPropertyTypes forbids the
  329. // SDK-documented explicit `sessionIdGenerator: undefined` spelling).
  330. const transport = new StreamableHTTPServerTransport({})
  331. res.on('close', () => { void transport.close(); void server.close() })
  332. // Same exactOptionalPropertyTypes mismatch the client transport factory
  333. // documents (src/transport.ts): the SDK types optional callbacks without
  334. // `| undefined`. The SDK constructed the object; the cast is safe.
  335. await server.connect(transport as Transport)
  336. await transport.handleRequest(req, res)
  337. }
  338. beforeAll(async () => {
  339. httpServer = createServer((req, res) => {
  340. handleMcpRequest(req, res).catch((error: unknown) => {
  341. res.writeHead(500).end(String(error))
  342. })
  343. })
  344. const listening: PromiseWithResolvers<void> = Promise.withResolvers()
  345. httpServer.listen(0, '127.0.0.1', listening.resolve)
  346. await listening.promise
  347. const address = httpServer.address()
  348. if (address === null || typeof address === 'string') throw new Error(`expected a TCP AddressInfo, got ${String(address)}`)
  349. baseUrl = `http://127.0.0.1:${address.port}/mcp`
  350. ctx = await mountRegistry()
  351. const config: Config = {
  352. transport: 'streamable-http',
  353. serverName: 'web',
  354. url: baseUrl,
  355. headers: { Authorization: 'Bearer e2e-test-token' },
  356. toolCallTimeoutMs: 15_000,
  357. }
  358. await applyAndWait(ctx, config)
  359. }, 30_000)
  360. afterAll(async () => {
  361. if (ctx) await ctx.fiber.dispose()
  362. await sleep(200)
  363. const closed: PromiseWithResolvers<void> = Promise.withResolvers()
  364. httpServer.close(() => { closed.resolve() })
  365. await closed.promise
  366. })
  367. it('discovers tools under the server namespace over HTTP', () => {
  368. const names = ctx.tools.schemas().map(s => s.name)
  369. expect(names).toContain('mcp__web__ping')
  370. expect(names).toContain('mcp__web__shout')
  371. })
  372. it('executes ping() → "pong" over HTTP', async () => {
  373. const result = await ctx.tools.execute({
  374. signal: testToolSignal,
  375. callId: nextCallId(), name: 'mcp__web__ping', arguments: {},
  376. })
  377. expect(result.isError).toBe(false)
  378. expect(result.content[0]).toEqual({ type: 'text', text: 'pong' })
  379. })
  380. it('executes shout({ message }) with args over HTTP', async () => {
  381. const result = await ctx.tools.execute({
  382. signal: testToolSignal,
  383. callId: nextCallId(), name: 'mcp__web__shout', arguments: { message: 'quiet' },
  384. })
  385. expect(result.isError).toBe(false)
  386. expect(result.content[0]).toEqual({ type: 'text', text: 'QUIET' })
  387. })
  388. it('sends configured headers on every HTTP request', () => {
  389. expect(seenAuth.length).toBeGreaterThan(0)
  390. for (const auth of seenAuth) expect(auth).toBe('Bearer e2e-test-token')
  391. })
  392. })