protocol.spec.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. import { z } from 'zod'
  2. /** Real SDK negotiation, subscription, and cancellation through the connection supervisor. */
  3. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import { InMemoryTransport, type Transport } from '@modelcontextprotocol/client'
  6. import { McpServer } from '@modelcontextprotocol/server'
  7. import { serveStdio } from '@modelcontextprotocol/server/stdio'
  8. import { ToolCallId } from '@deepseek-ai/dsh-llm'
  9. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  10. import ToolRuntime from '@deepseek-ai/dsh-tools'
  11. import McpResources from '@deepseek-ai/dsh-mcp-resources'
  12. import { startConnection, resolveReconnectPolicy } from '../src/connection.ts'
  13. import type { Config } from '../src/index.ts'
  14. const { mockTransport } = vi.hoisted(() => ({ mockTransport: vi.fn<() => Transport>() }))
  15. vi.mock('../src/transport.ts', () => ({ createTransport: mockTransport }))
  16. const config: Config = {
  17. transport: 'stdio', serverName: 'fixture', command: 'fixture', args: [], env: {}, cwd: '',
  18. toolCallTimeoutMs: 60_000, failOnStartupError: true,
  19. }
  20. async function connect(server: McpServer, options?: { resources: true }): Promise<Context> {
  21. const ctx = new Context()
  22. await ctx.plugin(SystemPrompt)
  23. await ctx.plugin(ToolRuntime)
  24. const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
  25. const serving = serveStdio(() => server, { transport: serverTransport })
  26. mockTransport.mockReturnValue(clientTransport)
  27. const connection = startConnection(ctx, config, resolveReconnectPolicy({ enabled: false }, 'fixture'))
  28. onTestFinished(async () => {
  29. await connection.dispose()
  30. await serving.close()
  31. await ctx.fiber.dispose()
  32. })
  33. expect(await connection.ready).toEqual({})
  34. if (options?.resources) {
  35. await ctx.plugin(McpResources)
  36. ctx.mcpResources.register('fixture', connection.resources)
  37. }
  38. return ctx
  39. }
  40. describe('modern MCP connections', () => {
  41. it('keeps a resource-only server connected without requesting tools', async () => {
  42. const server = new McpServer({ name: 'resources', version: '1' })
  43. server.registerResource('memo', 'memo://readme', {}, async () => ({
  44. contents: [{ uri: 'memo://readme', text: 'memo' }],
  45. }))
  46. const ctx = await connect(server)
  47. expect(ctx.tools.schemas()).toEqual([])
  48. })
  49. it('keeps shared resource tools for a configured server without resource capability', async () => {
  50. const server = new McpServer({ name: 'tools-only', version: '1' })
  51. server.registerTool('ping', { inputSchema: z.object({}) }, async () => ({
  52. content: [{ type: 'text', text: 'pong' }],
  53. }))
  54. const ctx = await connect(server, { resources: true })
  55. const names = ctx.tools.schemas().map(tool => tool.name)
  56. expect(names.toSorted()).toEqual([
  57. 'list_mcp_resource_templates', 'list_mcp_resources', 'mcp__fixture__ping', 'read_mcp_resource',
  58. ])
  59. for (const [name, expected] of [
  60. ['list_mcp_resources', { resources: [] }],
  61. ['list_mcp_resource_templates', { resourceTemplates: [] }],
  62. ] as const) {
  63. const result = await ctx.tools.execute({
  64. name, arguments: { server: 'fixture' },
  65. callId: ToolCallId(name), signal: new AbortController().signal,
  66. })
  67. expect(result).toMatchObject({ isError: false, value: expected })
  68. }
  69. const read = await ctx.tools.execute({
  70. name: 'read_mcp_resource', arguments: { server: 'fixture', uri: 'memo://readme' },
  71. callId: ToolCallId('unsupported-resource-read'), signal: new AbortController().signal,
  72. })
  73. expect(read.isError).toBe(true)
  74. if (read.isError) expect(read.error.message).toContain('Method not found')
  75. expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(names)
  76. const ping = await ctx.tools.execute({
  77. name: 'mcp__fixture__ping', arguments: {},
  78. callId: ToolCallId('ping-after-resource-errors'), signal: new AbortController().signal,
  79. })
  80. expect(ping).toMatchObject({ isError: false, value: { content: [{ type: 'text', text: 'pong' }] } })
  81. })
  82. it('reads resources and preserves explicit list and template cursors through the SDK', async () => {
  83. const server = new McpServer({ name: 'resources', version: '1' })
  84. server.registerResource('memo', 'memo://readme', {}, async () => ({
  85. contents: [{ uri: 'memo://readme', text: 'memo' }],
  86. }))
  87. const seen: (string | undefined)[] = []
  88. server.server.setRequestHandler('resources/list', async (request) => {
  89. const cursor = request.params?.cursor
  90. seen.push(cursor)
  91. return { resources: [{ name: 'memo', uri: 'memo://readme' }] }
  92. })
  93. server.server.setRequestHandler('resources/templates/list', async (request) => {
  94. seen.push(request.params?.cursor)
  95. return { resourceTemplates: [] }
  96. })
  97. const ctx = await connect(server, { resources: true })
  98. for (const name of ['list_mcp_resources', 'list_mcp_resource_templates']) {
  99. for (const cursor of [undefined, 'opaque-page']) {
  100. const result = await ctx.tools.execute({
  101. name, arguments: { server: 'fixture', ...cursor === undefined ? {} : { cursor } },
  102. callId: ToolCallId(name), signal: new AbortController().signal,
  103. })
  104. expect(result.isError).toBe(false)
  105. }
  106. }
  107. expect(seen).toEqual([undefined, 'opaque-page', undefined, 'opaque-page'])
  108. const read = await ctx.tools.execute({
  109. name: 'read_mcp_resource', arguments: { server: 'fixture', uri: 'memo://readme' },
  110. callId: ToolCallId('read-resource'), signal: new AbortController().signal,
  111. })
  112. expect(read).toMatchObject({ isError: false, value: { contents: [{ uri: 'memo://readme', text: 'memo' }] } })
  113. })
  114. it('updates tools through the SDK modern list-change subscription', async () => {
  115. const server = new McpServer({ name: 'tools', version: '1' })
  116. server.registerTool('first', { inputSchema: z.object({}) }, async () => ({ content: [] }))
  117. const ctx = await connect(server)
  118. expect(ctx.tools.get('mcp__fixture__first')).toBeDefined()
  119. server.registerTool('second', { inputSchema: z.object({}) }, async () => ({ content: [] }))
  120. await vi.waitFor(() => { expect(ctx.tools.get('mcp__fixture__second')).toBeDefined() })
  121. })
  122. it('delivers caller cancellation to an executing modern tool', async () => {
  123. const entered: PromiseWithResolvers<void> = Promise.withResolvers()
  124. const cancelled: PromiseWithResolvers<void> = Promise.withResolvers()
  125. const server = new McpServer({ name: 'cancel', version: '1' })
  126. server.registerTool('wait', { inputSchema: z.object({}) }, async (_args, context) => {
  127. const signal = context.mcpReq.signal
  128. signal.addEventListener('abort', () => { cancelled.resolve() }, { once: true })
  129. entered.resolve()
  130. await cancelled.promise
  131. return { content: [] }
  132. })
  133. const ctx = await connect(server)
  134. const controller = new AbortController()
  135. const result = ctx.tools.execute({
  136. callId: ToolCallId('cancel'), name: 'mcp__fixture__wait', arguments: {}, signal: controller.signal,
  137. })
  138. await entered.promise
  139. controller.abort(new Error('caller stopped'))
  140. expect((await result).isError).toBe(true)
  141. await cancelled.promise
  142. })
  143. })