| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451 |
- /**
- * 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<Context> {
- 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<void> {
- // Annotated bindings (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()
- 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<void> {
- const gate: PromiseWithResolvers<void> = 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<string | undefined> = []
- /**
- * 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<void> {
- 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<void> = 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<void> = 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')
- })
- })
|