/** * End-to-end tests for dsh-mcp-client. Exercises the REAL MCP protocol against: * 1. A self-written fixture server over stdio (controlled edge cases) * 2. @modelcontextprotocol/server-everything (official integration test server) * 3. @modelcontextprotocol/server-filesystem (real filesystem operations) * 4. An in-process StreamableHTTPServerTransport server over Streamable HTTP * * No API key needed — all servers are local/keyless. */ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import { z } from 'zod' import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import { CallId } from '@deepseek-ai/dsh-llm' import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts' import { publicToolName } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' import type { Config } from '@deepseek-ai/dsh-mcp-client' const testToolSignal = new AbortController().signal const fixtureServerPath = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) // Resolve package-local .bin for pnpm-hoisted MCP server binaries. const packageDir = fileURLToPath(new URL('..', import.meta.url)) const localBin = join(packageDir, 'node_modules', '.bin') // ---- Helpers ---- async function mountRegistry(): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) return ctx } /** Apply the MCP client plugin and wait for tools to be registered. */ async function applyAndWait(ctx: Context, config: Config, timeoutMs = 20_000): Promise { // Annotated bindings (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() const timer = setTimeout( () => { gate.reject(new Error(`applyAndWait timed out after ${timeoutMs}ms — no tools/change event`)) }, timeoutMs, ) ctx.on('tools/change', () => { clearTimeout(timer); gate.resolve() }) apply(ctx, config) await gate.promise } function sleep(ms: number): Promise { const gate: PromiseWithResolvers = Promise.withResolvers() setTimeout(gate.resolve, ms) return gate.promise } /** Narrow a result content block to its text, failing the test on any other shape. */ function textOf(block: unknown): string { if (block && typeof block === 'object' && 'text' in block && typeof block.text === 'string') { return block.text } throw new Error(`expected a text content block, got ${JSON.stringify(block)}`) } let callSeq = 0 function nextCallId(): CallId { return CallId(`e2e-${++callSeq}`) } // ---- Fixture server tests ---- describe('fixture server — controlled scenarios', () => { let ctx: Context const fixtureConfig: Config = { transport: 'stdio', serverName: 'fixture', command: process.execPath, args: [fixtureServerPath], env: {}, cwd: packageDir, toolCallTimeoutMs: 15_000, } beforeAll(async () => { ctx = await mountRegistry() await applyAndWait(ctx, fixtureConfig) }, 30_000) afterAll(async () => { if (ctx) await ctx.fiber.dispose() await sleep(200) }) it('discovers all fixture tools under the server namespace', () => { const schemas = ctx.tools.schemas() const names = schemas.map(s => s.name) expect(names).toContain('mcp__fixture__add') expect(names).toContain('mcp__fixture__greet') expect(names).toContain('mcp__fixture__fail') expect(names).toContain('mcp__fixture__image') // Raw names are not registered. expect(names).not.toContain('add') }) it('normalizes the dotted tool name with a deterministic hash suffix', () => { const publicName = publicToolName('fixture', 'admin.reset') expect(publicName).toMatch(/^mcp__fixture__admin_reset_[0-9a-f]{12}$/) expect(ctx.tools.get(publicName)).toBeDefined() }) it('executes the dotted tool via its normalized public name', async () => { const result = await ctx.tools.execute({ signal: testToolSignal, callId: nextCallId(), name: publicToolName('fixture', 'admin.reset'), arguments: {}, }) expect(result.isError).toBe(false) expect(result.content[0]).toEqual({ type: 'text', text: 'reset done' }) }) it('executes add(2, 3) → "5"', async () => { const result = await ctx.tools.execute({ signal: testToolSignal, callId: nextCallId(), name: 'mcp__fixture__add', arguments: { a: 2, b: 3 }, }) expect(result.isError).toBe(false) expect(result.content[0]).toEqual({ type: 'text', text: '5' }) }) it('executes greet("World") → "Hello, World!"', async () => { const result = await ctx.tools.execute({ signal: testToolSignal, callId: nextCallId(), name: 'mcp__fixture__greet', arguments: { name: 'World' }, }) expect(result.isError).toBe(false) expect(result.content[0]).toEqual({ type: 'text', text: 'Hello, World!' }) }) it('executes fail() → isError result', async () => { const result = await ctx.tools.execute({ signal: testToolSignal, callId: nextCallId(), name: 'mcp__fixture__fail', arguments: {}, }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ type: 'text' }) }) it('executes image() → image placeholder', async () => { const result = await ctx.tools.execute({ signal: testToolSignal, callId: nextCallId(), name: 'mcp__fixture__image', arguments: {}, }) expect(result.isError).toBe(false) const text = textOf(result.content[0]) expect(text).toContain('Here is an image:') expect(text).toContain('[image: image/png, content discarded]') expect(text).toContain('End of image.') }) }) describe('fixture server — duplicate serverName', () => { it('rejects a second instance with the same serverName on one root', async () => { const ctx = await mountRegistry() const config: Config = { transport: 'stdio', serverName: 'dup', command: process.execPath, args: [fixtureServerPath], env: {}, cwd: packageDir, toolCallTimeoutMs: 15_000, } await applyAndWait(ctx, config) expect(() => { apply(ctx, config) }).toThrow(/serverName "dup" is already in use/) await ctx.fiber.dispose() await sleep(200) }, 30_000) }) describe('fixture server — disposal', () => { it('disposes cleanly without error', async () => { const ctx = await mountRegistry() await applyAndWait(ctx, { transport: 'stdio', serverName: 'fixture', command: process.execPath, args: [fixtureServerPath], env: {}, cwd: packageDir, toolCallTimeoutMs: 15_000, }) // Tools are registered before dispose. expect(ctx.tools.get('mcp__fixture__add')).toBeDefined() expect(ctx.tools.schemas().length).toBeGreaterThanOrEqual(4) // Dispose should complete without throwing. await ctx.fiber.dispose() await sleep(200) }, 30_000) }) // ---- @modelcontextprotocol/server-everything ---- describe('server-everything — official test server', () => { let ctx: Context const config: Config = { transport: 'stdio', serverName: 'everything', command: join(localBin, 'mcp-server-everything'), args: ['stdio'], env: {}, cwd: '', toolCallTimeoutMs: 30_000, } beforeAll(async () => { ctx = await mountRegistry() await applyAndWait(ctx, config) }, 60_000) afterAll(async () => { if (ctx) await ctx.fiber.dispose() await sleep(500) }) it('discovers tools from server-everything', () => { const schemas = ctx.tools.schemas() const names = schemas.map(s => s.name) expect(names).toContain('mcp__everything__echo') expect(names).toContain('mcp__everything__get-sum') expect(names).toContain('mcp__everything__get-tiny-image') expect(names.length).toBeGreaterThanOrEqual(8) }) it('executes echo({ message: "hello" }) → "Echo: hello"', async () => { const result = await ctx.tools.execute({ signal: testToolSignal, callId: nextCallId(), name: 'mcp__everything__echo', arguments: { message: 'hello' }, }) expect(result.isError).toBe(false) expect(textOf(result.content[0])).toBe('Echo: hello') }) it('executes get-sum({ a: 3, b: 7 }) → contains "10"', async () => { const result = await ctx.tools.execute({ signal: testToolSignal, callId: nextCallId(), name: 'mcp__everything__get-sum', arguments: { a: 3, b: 7 }, }) expect(result.isError).toBe(false) expect(textOf(result.content[0])).toContain('10') }) it('executes get-tiny-image → image placeholder', async () => { const result = await ctx.tools.execute({ signal: testToolSignal, callId: nextCallId(), name: 'mcp__everything__get-tiny-image', arguments: {}, }) expect(result.isError).toBe(false) expect(textOf(result.content[0])).toContain('[image: image/png, content discarded]') }) }) // ---- @modelcontextprotocol/server-filesystem ---- describe('server-filesystem — real filesystem operations', () => { let ctx: Context let tempDir: string beforeAll(async () => { tempDir = await mkdtemp(join(tmpdir(), 'mcp-fs-e2e-')) ctx = await mountRegistry() const config: Config = { transport: 'stdio', serverName: 'filesystem', command: join(localBin, 'mcp-server-filesystem'), args: [tempDir], env: {}, cwd: '', toolCallTimeoutMs: 30_000, } await applyAndWait(ctx, config) }, 60_000) afterAll(async () => { if (ctx) await ctx.fiber.dispose() await sleep(500) await rm(tempDir, { recursive: true, force: true }) }) it('discovers filesystem tools', () => { const schemas = ctx.tools.schemas() const names = schemas.map(s => s.name) expect(names).toContain('mcp__filesystem__read_file') expect(names).toContain('mcp__filesystem__write_file') expect(names).toContain('mcp__filesystem__list_directory') }) it('write_file + read_file round-trip', async () => { const filePath = join(tempDir, 'test.txt') const content = 'Hello from MCP e2e test!' const writeResult = await ctx.tools.execute({ signal: testToolSignal, callId: nextCallId(), name: 'mcp__filesystem__write_file', arguments: { path: filePath, content }, }) expect(writeResult.isError).toBe(false) // Assert the filesystem effect independently of the tool result. const onDisk = await readFile(filePath, 'utf8') expect(onDisk).toBe(content) const readResult = await ctx.tools.execute({ signal: testToolSignal, callId: nextCallId(), name: 'mcp__filesystem__read_file', arguments: { path: filePath }, }) expect(readResult.isError).toBe(false) expect(textOf(readResult.content[0])).toContain(content) }) it('list_directory shows written file', async () => { await writeFile(join(tempDir, 'listed.txt'), 'listed') const result = await ctx.tools.execute({ signal: testToolSignal, callId: nextCallId(), name: 'mcp__filesystem__list_directory', arguments: { path: tempDir }, }) expect(result.isError).toBe(false) expect(textOf(result.content[0])).toContain('listed.txt') }) }) // ---- Streamable HTTP transport ---- describe('streamable-http — in-process MCP server', () => { let ctx: Context let httpServer: Server let baseUrl: string /** Authorization header values observed by the HTTP server, in arrival order. */ const seenAuth: Array = [] /** * Stateless Streamable HTTP endpoint: a fresh McpServer + server transport * per request (the SDK's documented stateless pattern — no session id, no * SSE stream to keep). The tool set mirrors a minimal fixture server. */ async function handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise { seenAuth.push(req.headers.authorization) const server = new McpServer( { name: 'http-fixture', version: '1.0.0' }, { capabilities: { tools: {} } }, ) server.registerTool('ping', { description: 'Replies pong.', inputSchema: {}, }, async () => ({ content: [{ type: 'text', text: 'pong' }], })) server.registerTool('shout', { description: 'Upper-cases a message.', inputSchema: { message: z.string().describe('Message to upper-case') }, }, async args => ({ content: [{ type: 'text', text: args.message.toUpperCase() }], })) // Stateless mode: sessionIdGenerator ABSENT (the runtime treats absent and // explicit-undefined identically; exactOptionalPropertyTypes forbids the // SDK-documented explicit `sessionIdGenerator: undefined` spelling). const transport = new StreamableHTTPServerTransport({}) res.on('close', () => { void transport.close(); void server.close() }) // Same exactOptionalPropertyTypes mismatch the client transport factory // documents (src/transport.ts): the SDK types optional callbacks without // `| undefined`. The SDK constructed the object; the cast is safe. await server.connect(transport as Transport) await transport.handleRequest(req, res) } beforeAll(async () => { httpServer = createServer((req, res) => { handleMcpRequest(req, res).catch((error: unknown) => { res.writeHead(500).end(String(error)) }) }) const listening: PromiseWithResolvers = Promise.withResolvers() httpServer.listen(0, '127.0.0.1', listening.resolve) await listening.promise const address = httpServer.address() if (address === null || typeof address === 'string') throw new Error(`expected a TCP AddressInfo, got ${String(address)}`) baseUrl = `http://127.0.0.1:${address.port}/mcp` ctx = await mountRegistry() const config: Config = { transport: 'streamable-http', serverName: 'web', url: baseUrl, headers: { Authorization: 'Bearer e2e-test-token' }, toolCallTimeoutMs: 15_000, } await applyAndWait(ctx, config) }, 30_000) afterAll(async () => { if (ctx) await ctx.fiber.dispose() await sleep(200) const closed: PromiseWithResolvers = Promise.withResolvers() httpServer.close(() => { closed.resolve() }) await closed.promise }) it('discovers tools under the server namespace over HTTP', () => { const names = ctx.tools.schemas().map(s => s.name) expect(names).toContain('mcp__web__ping') expect(names).toContain('mcp__web__shout') }) it('executes ping() → "pong" over HTTP', async () => { const result = await ctx.tools.execute({ signal: testToolSignal, callId: nextCallId(), name: 'mcp__web__ping', arguments: {}, }) expect(result.isError).toBe(false) expect(result.content[0]).toEqual({ type: 'text', text: 'pong' }) }) it('executes shout({ message }) with args over HTTP', async () => { const result = await ctx.tools.execute({ signal: testToolSignal, callId: nextCallId(), name: 'mcp__web__shout', arguments: { message: 'quiet' }, }) expect(result.isError).toBe(false) expect(result.content[0]).toEqual({ type: 'text', text: 'QUIET' }) }) it('sends configured headers on every HTTP request', () => { expect(seenAuth.length).toBeGreaterThan(0) for (const auth of seenAuth) expect(auth).toBe('Bearer e2e-test-token') }) })