mcp-client.spec.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838
  1. import { describe, expect, it, vi, beforeEach } from 'vitest'
  2. import { Client } from '@modelcontextprotocol/sdk/client/index.js'
  3. import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
  4. import { Context } from 'cordis'
  5. import { CallId } from '@deepseek-ai/dsh-llm'
  6. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  7. import ToolRegistry, { type JsonValue } from '@deepseek-ai/dsh-tools'
  8. import { publicToolName, syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
  9. import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts'
  10. import type { Config } from '@deepseek-ai/dsh-mcp-client'
  11. const testToolSignal = new AbortController().signal
  12. // ---- Mock MCP Client ----
  13. interface MockTool {
  14. name: string
  15. description?: string
  16. inputSchema: Record<string, unknown>
  17. outputSchema?: Record<string, unknown>
  18. execution?: { taskSupport?: 'optional' | 'required' | 'forbidden' }
  19. }
  20. interface MockCallResult {
  21. content: JsonValue[]
  22. structuredContent?: JsonValue
  23. isError?: boolean
  24. }
  25. function createMockClient(tools: MockTool[], callResult: MockCallResult = { content: [{ type: 'text', text: 'ok' }] }) {
  26. const listTools = vi.fn(async (
  27. _params?: Record<string, unknown>,
  28. ): Promise<{ tools: MockTool[]; nextCursor: string | undefined }> => ({ tools, nextCursor: undefined }))
  29. const callTool = vi.fn(async (
  30. _params?: Record<string, unknown>,
  31. _compatibilitySchema?: unknown,
  32. _options?: unknown,
  33. ): Promise<Record<string, unknown>> => ({ ...callResult }))
  34. return {
  35. listTools,
  36. callTool,
  37. request: vi.fn(async (
  38. request: { method: string; params?: Record<string, unknown> },
  39. _schema: unknown,
  40. options?: unknown,
  41. ): Promise<unknown> => {
  42. if (request.method === 'tools/list') return listTools(request.params)
  43. if (request.method === 'tools/call') return callTool(request.params, undefined, options)
  44. throw new Error(`unexpected MCP request: ${request.method}`)
  45. }),
  46. setNotificationHandler: vi.fn(),
  47. connect: vi.fn().mockResolvedValue(undefined),
  48. close: vi.fn().mockResolvedValue(undefined),
  49. }
  50. }
  51. // ---- Test harness helper ----
  52. async function mountRegistry(): Promise<Context> {
  53. const ctx = new Context()
  54. await ctx.plugin(SystemPrompt)
  55. await ctx.plugin(ToolRegistry)
  56. return ctx
  57. }
  58. const defaultOpts: ToolBridgeOptions = {
  59. serverName: 'srv',
  60. toolCallTimeoutMs: 60_000,
  61. }
  62. // ---- Tests ----
  63. describe('publicToolName', () => {
  64. it('joins clean names verbatim', () => {
  65. expect(publicToolName('github', 'create_issue')).toBe('mcp__github__create_issue')
  66. expect(publicToolName('everything', 'get-sum')).toBe('mcp__everything__get-sum')
  67. })
  68. it('replaces invalid characters and appends an identity hash', () => {
  69. const name = publicToolName('srv', 'admin.reset')
  70. expect(name).toMatch(/^mcp__srv__admin_reset_[0-9a-f]{12}$/)
  71. expect(name.length).toBeLessThanOrEqual(64)
  72. })
  73. it('truncates over-long names and appends an identity hash', () => {
  74. const rawName = 'a'.repeat(80)
  75. const name = publicToolName('srv', rawName)
  76. expect(name).toHaveLength(64)
  77. expect(name).toMatch(/_[0-9a-f]{12}$/)
  78. expect(name.startsWith('mcp__srv__aaa')).toBe(true)
  79. })
  80. it('is deterministic and collision-free for distinct identities', () => {
  81. // Two raw names that normalize to the same base must not collapse.
  82. const a = publicToolName('srv', 'admin.reset')
  83. const b = publicToolName('srv', 'admin_reset')
  84. expect(a).toBe(publicToolName('srv', 'admin.reset'))
  85. expect(a).not.toBe(b)
  86. })
  87. })
  88. describe('syncTools', () => {
  89. let ctx: Context
  90. beforeEach(async () => {
  91. ctx = await mountRegistry()
  92. })
  93. it('registers tools under server-qualified public names', async () => {
  94. const client = createMockClient([
  95. { name: 'greet', description: 'Say hello', inputSchema: { type: 'object', properties: { name: { type: 'string' } } } },
  96. { name: 'add', description: 'Add numbers', inputSchema: { type: 'object', properties: {} } },
  97. ])
  98. const disposers = await syncTools(client as never, ctx, defaultOpts, new Map())
  99. expect(disposers.size).toBe(2)
  100. expect(ctx.tools.get('mcp__srv__greet')).toBeDefined()
  101. expect(ctx.tools.get('mcp__srv__add')).toBeDefined()
  102. // Raw names are NOT registered.
  103. expect(ctx.tools.get('greet')).toBeUndefined()
  104. expect(ctx.tools.get('add')).toBeUndefined()
  105. })
  106. it('lets two servers publish the same raw name side by side', async () => {
  107. const clientA = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }])
  108. const clientB = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }])
  109. await syncTools(clientA as never, ctx, { ...defaultOpts, serverName: 'github' }, new Map())
  110. await syncTools(clientB as never, ctx, { ...defaultOpts, serverName: 'web' }, new Map())
  111. expect(ctx.tools.get('mcp__github__search')).toBeDefined()
  112. expect(ctx.tools.get('mcp__web__search')).toBeDefined()
  113. })
  114. it('coexists with a native tool of the same raw name', async () => {
  115. ctx.tools.register({
  116. name: 'search',
  117. description: 'Native search',
  118. parameters: { type: 'object' },
  119. output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value as string }] },
  120. execute: async () => 'native',
  121. })
  122. const client = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }])
  123. await syncTools(client as never, ctx, defaultOpts, new Map())
  124. expect(ctx.tools.get('search')).toBeDefined()
  125. expect(ctx.tools.get('mcp__srv__search')).toBeDefined()
  126. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'search', arguments: {} })
  127. expect(result.content[0]).toEqual({ type: 'text', text: 'native' })
  128. })
  129. it('rejects a tool list where one raw name appears twice', async () => {
  130. const client = createMockClient([
  131. { name: 'dup', inputSchema: { type: 'object' } },
  132. { name: 'dup', inputSchema: { type: 'object' } },
  133. ])
  134. await expect(syncTools(client as never, ctx, defaultOpts, new Map()))
  135. .rejects.toThrow(/listed tool "dup" more than once/)
  136. // Nothing registered, previous generation untouched (it was empty).
  137. expect(ctx.tools.get('mcp__srv__dup')).toBeUndefined()
  138. })
  139. it('keeps the previous generation when the fetch phase fails', async () => {
  140. const client = createMockClient([{ name: 'stable', inputSchema: { type: 'object' } }])
  141. const first = await syncTools(client as never, ctx, defaultOpts, new Map())
  142. expect(ctx.tools.get('mcp__srv__stable')).toBeDefined()
  143. client.listTools.mockRejectedValue(new Error('network down'))
  144. await expect(syncTools(client as never, ctx, defaultOpts, first)).rejects.toThrow('network down')
  145. // The previous generation is still live.
  146. expect(ctx.tools.get('mcp__srv__stable')).toBeDefined()
  147. })
  148. it('rolls back the whole generation when a foreign tool squats on the namespace', async () => {
  149. // A foreign registration occupies one of this server's public names.
  150. ctx.tools.register({
  151. name: 'mcp__srv__taken',
  152. description: 'Squatter',
  153. parameters: { type: 'object' },
  154. output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value as string }] },
  155. execute: async () => 'squatter',
  156. })
  157. const client = createMockClient([
  158. { name: 'free', inputSchema: { type: 'object' } },
  159. { name: 'taken', inputSchema: { type: 'object' } },
  160. ])
  161. const disposers = await syncTools(client as never, ctx, defaultOpts, new Map())
  162. // All-or-nothing: the non-conflicting tool is rolled back too.
  163. expect(disposers.size).toBe(0)
  164. expect(ctx.tools.get('mcp__srv__free')).toBeUndefined()
  165. // The squatter is untouched.
  166. expect(ctx.tools.get('mcp__srv__taken')).toBeDefined()
  167. })
  168. it('unregisters previous tools before re-syncing', async () => {
  169. const client = createMockClient([
  170. { name: 'old_tool', inputSchema: { type: 'object' } },
  171. ])
  172. const firstDisposers = await syncTools(client as never, ctx, defaultOpts, new Map())
  173. expect(ctx.tools.get('mcp__srv__old_tool')).toBeDefined()
  174. // Second sync with different tools should remove old_tool.
  175. client.listTools.mockResolvedValue({ tools: [{ name: 'new_tool', inputSchema: { type: 'object' } }], nextCursor: undefined })
  176. const secondDisposers = await syncTools(client as never, ctx, defaultOpts, firstDisposers)
  177. expect(ctx.tools.get('mcp__srv__old_tool')).toBeUndefined()
  178. expect(ctx.tools.get('mcp__srv__new_tool')).toBeDefined()
  179. expect(secondDisposers.size).toBe(1)
  180. })
  181. it('drains paginated listTools responses', async () => {
  182. const client = createMockClient([])
  183. client.listTools
  184. .mockResolvedValueOnce({ tools: [{ name: 'page1', inputSchema: { type: 'object' } }], nextCursor: 'cursor1' })
  185. .mockResolvedValueOnce({ tools: [{ name: 'page2', inputSchema: { type: 'object' } }], nextCursor: undefined })
  186. const disposers = await syncTools(client as never, ctx, defaultOpts, new Map())
  187. expect(disposers.size).toBe(2)
  188. expect(ctx.tools.get('mcp__srv__page1')).toBeDefined()
  189. expect(ctx.tools.get('mcp__srv__page2')).toBeDefined()
  190. })
  191. it('owns output validation independently of the SDK per-page cache', async () => {
  192. const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
  193. serverTransport.onmessage = (message) => {
  194. if (!('id' in message) || !('method' in message)) return
  195. const params = 'params' in message ? message.params : undefined
  196. let result: Record<string, unknown>
  197. if (message.method === 'initialize') {
  198. const protocolVersion = params && 'protocolVersion' in params
  199. ? params.protocolVersion
  200. : '2025-11-25'
  201. result = {
  202. protocolVersion,
  203. capabilities: { tools: {} },
  204. serverInfo: { name: 'raw-test', version: '1' },
  205. }
  206. } else if (message.method === 'tools/list') {
  207. const cursor = params && 'cursor' in params ? params.cursor : undefined
  208. result = cursor === undefined
  209. ? {
  210. tools: [{
  211. name: 'supported',
  212. inputSchema: { type: 'object' },
  213. outputSchema: {
  214. type: 'object',
  215. additionalProperties: false,
  216. properties: { answer: { type: 'integer' } },
  217. required: ['answer'],
  218. },
  219. }],
  220. nextCursor: 'page-2',
  221. }
  222. : {
  223. tools: [{
  224. name: 'future-schema',
  225. inputSchema: { type: 'object' },
  226. outputSchema: { type: 'object', patternProperties: { '^x-': { type: 'string' } } },
  227. }],
  228. }
  229. } else if (message.method === 'tools/call') {
  230. const name = params && 'name' in params ? params.name : undefined
  231. result = name === 'supported'
  232. ? { content: [{ type: 'text', text: 'missing structured content' }] }
  233. : { content: [42, null], structuredContent: ['kept', { nested: true }] }
  234. } else {
  235. result = {}
  236. }
  237. void serverTransport.send({ jsonrpc: '2.0', id: message.id, result })
  238. }
  239. await serverTransport.start()
  240. const client = new Client({ name: 'cache-independent-test', version: '1' })
  241. await client.connect(clientTransport)
  242. try {
  243. await syncTools(client, ctx, defaultOpts, new Map())
  244. const missing = await ctx.tools.execute({
  245. signal: testToolSignal,
  246. callId: CallId('missing'), name: 'mcp__srv__supported', arguments: {},
  247. })
  248. expect(missing.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } })
  249. expect(missing.error?.message).toContain('structuredContent')
  250. const fallback = await ctx.tools.execute({
  251. signal: testToolSignal,
  252. callId: CallId('fallback'), name: 'mcp__srv__future-schema', arguments: {},
  253. })
  254. if (fallback.isError) throw new Error('unsupported schema must use the bridge fallback')
  255. expect(fallback.value).toEqual({
  256. content: [42, null],
  257. structuredContent: ['kept', { nested: true }],
  258. })
  259. } finally {
  260. await client.close()
  261. }
  262. })
  263. })
  264. describe('tool execution', () => {
  265. let ctx: Context
  266. beforeEach(async () => {
  267. ctx = await mountRegistry()
  268. })
  269. it('calls MCP callTool with the RAW name and returns text content', async () => {
  270. const client = createMockClient(
  271. [{ name: 'echo', inputSchema: { type: 'object' } }],
  272. { content: [{ type: 'text', text: 'hello world' }] },
  273. )
  274. await syncTools(client as never, ctx, defaultOpts, new Map())
  275. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__echo', arguments: { msg: 'hi' } })
  276. expect(result.isError).toBe(false)
  277. expect(result.content).toEqual([{ type: 'text', text: 'hello world' }])
  278. if (result.isError) throw new Error('expected MCP success')
  279. expect(result.value).toEqual({ content: [{ type: 'text', text: 'hello world' }] })
  280. // The wire sees the raw MCP name, never the public name.
  281. expect(client.callTool).toHaveBeenCalledWith(
  282. { name: 'echo', arguments: { msg: 'hi' } },
  283. undefined,
  284. expect.objectContaining({ timeout: 60_000 }),
  285. )
  286. })
  287. it('sends the raw name for normalized public names', async () => {
  288. const client = createMockClient(
  289. [{ name: 'admin.reset', inputSchema: { type: 'object' } }],
  290. { content: [{ type: 'text', text: 'reset done' }] },
  291. )
  292. await syncTools(client as never, ctx, defaultOpts, new Map())
  293. const publicName = publicToolName('srv', 'admin.reset')
  294. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: publicName, arguments: {} })
  295. expect(result.isError).toBe(false)
  296. expect(client.callTool).toHaveBeenCalledWith(
  297. { name: 'admin.reset', arguments: {} },
  298. undefined,
  299. expect.anything(),
  300. )
  301. })
  302. it('joins multiple text blocks with newline', async () => {
  303. const client = createMockClient(
  304. [{ name: 'multi', inputSchema: { type: 'object' } }],
  305. { content: [{ type: 'text', text: 'line1' }, { type: 'text', text: 'line2' }] },
  306. )
  307. await syncTools(client as never, ctx, defaultOpts, new Map())
  308. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__multi', arguments: {} })
  309. expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }])
  310. })
  311. it('preserves full JSON MCP blocks while Native rendering uses placeholders', async () => {
  312. const blocks = [
  313. { type: 'text', text: 'before' },
  314. { type: 'image', mimeType: 'image/png', data: 'base64-data', annotations: { audience: ['assistant'] } },
  315. ] satisfies JsonValue[]
  316. const client = createMockClient(
  317. [{ name: 'img', inputSchema: { type: 'object' } }],
  318. { content: blocks },
  319. )
  320. await syncTools(client as never, ctx, defaultOpts, new Map())
  321. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} })
  322. expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' })
  323. if (result.isError) throw new Error('expected MCP success')
  324. expect(result.value).toEqual({ content: blocks })
  325. })
  326. it('preserves primitive JSON MCP blocks while Native rendering marks them unsupported', async () => {
  327. const blocks = [42, null, ['nested']] satisfies JsonValue[]
  328. const client = createMockClient(
  329. [{ name: 'primitive-blocks', inputSchema: { type: 'object' } }],
  330. { content: blocks },
  331. )
  332. await syncTools(client as never, ctx, defaultOpts, new Map())
  333. const result = await ctx.tools.execute({
  334. signal: testToolSignal,
  335. callId: CallId('primitive'), name: 'mcp__srv__primitive-blocks', arguments: {},
  336. })
  337. expect(result.content[0]).toEqual({
  338. type: 'text',
  339. text: '[unsupported content type: unknown]\n[unsupported content type: unknown]\n[unsupported content type: unknown]',
  340. })
  341. if (result.isError) throw new Error('expected primitive MCP blocks to remain a successful JSON value')
  342. expect(result.value).toEqual({ content: blocks })
  343. })
  344. it('validates structuredContent when the advertised output schema is supported', async () => {
  345. const outputSchema = {
  346. type: 'object',
  347. additionalProperties: false,
  348. properties: { answer: { type: 'integer' } },
  349. required: ['answer'],
  350. }
  351. const valid = createMockClient(
  352. [{ name: 'structured', inputSchema: { type: 'object' }, outputSchema }],
  353. { content: [{ type: 'text', text: '42' }], structuredContent: { answer: 42 } },
  354. )
  355. await syncTools(valid as never, ctx, defaultOpts, new Map())
  356. const success = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('valid'), name: 'mcp__srv__structured', arguments: {} })
  357. if (success.isError) throw new Error('expected supported structuredContent to validate')
  358. expect(success.value).toEqual({ content: [{ type: 'text', text: '42' }], structuredContent: { answer: 42 } })
  359. const invalidCtx = await mountRegistry()
  360. const invalid = createMockClient(
  361. [{ name: 'structured', inputSchema: { type: 'object' }, outputSchema }],
  362. { content: [{ type: 'text', text: 'wrong' }], structuredContent: { answer: 'forty-two' } },
  363. )
  364. await syncTools(invalid as never, invalidCtx, defaultOpts, new Map())
  365. const failure = await invalidCtx.tools.execute({ signal: testToolSignal, callId: CallId('invalid'), name: 'mcp__srv__structured', arguments: {} })
  366. expect(failure.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } })
  367. expect(failure.content[0]?.type === 'text' ? failure.content[0].text : '')
  368. .toContain('value.structuredContent.answer')
  369. })
  370. it('falls back to JsonValue for unsupported advertised output schemas', async () => {
  371. const client = createMockClient(
  372. [{
  373. name: 'future-schema',
  374. inputSchema: { type: 'object' },
  375. outputSchema: { type: 'object', patternProperties: { '^x-': { type: 'string' } } },
  376. }],
  377. { content: [], structuredContent: ['kept', { nested: true }] },
  378. )
  379. await syncTools(client as never, ctx, defaultOpts, new Map())
  380. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('fallback'), name: 'mcp__srv__future-schema', arguments: {} })
  381. if (result.isError) throw new Error('unsupported MCP output schemas must fall back')
  382. expect(result.value).toEqual({ content: [], structuredContent: ['kept', { nested: true }] })
  383. })
  384. it('maps isError to an error result via throw', async () => {
  385. const client = createMockClient(
  386. [{ name: 'fail', inputSchema: { type: 'object' } }],
  387. { content: [{ type: 'text', text: 'something went wrong' }], isError: true },
  388. )
  389. await syncTools(client as never, ctx, defaultOpts, new Map())
  390. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__fail', arguments: {} })
  391. expect(result.isError).toBe(true)
  392. expect(result.content[0]).toEqual({ type: 'text', text: 'Error: something went wrong' })
  393. expect('value' in result).toBe(false)
  394. })
  395. it('rejects tools that require task-based execution', async () => {
  396. const client = createMockClient([
  397. { name: 'task-only', inputSchema: { type: 'object' }, execution: { taskSupport: 'required' } },
  398. ])
  399. await syncTools(client as never, ctx, defaultOpts, new Map())
  400. const result = await ctx.tools.execute({
  401. signal: testToolSignal,
  402. callId: CallId('task-only'), name: 'mcp__srv__task-only', arguments: {},
  403. })
  404. expect(result.isError).toBe(true)
  405. expect(result.error?.message).toContain('requires task-based execution')
  406. expect(client.callTool).not.toHaveBeenCalled()
  407. })
  408. it('passes abort signal to callTool', async () => {
  409. const controller = new AbortController()
  410. const client = createMockClient(
  411. [{ name: 'slow', inputSchema: { type: 'object' } }],
  412. { content: [{ type: 'text', text: 'done' }] },
  413. )
  414. await syncTools(client as never, ctx, defaultOpts, new Map())
  415. await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__slow', arguments: {}, signal: controller.signal })
  416. expect(client.callTool).toHaveBeenCalledWith(
  417. expect.anything(),
  418. undefined,
  419. expect.objectContaining({ signal: controller.signal }),
  420. )
  421. })
  422. it('handles legacy toolResult shape', async () => {
  423. const client = createMockClient(
  424. [{ name: 'legacy', inputSchema: { type: 'object' } }],
  425. )
  426. client.callTool.mockResolvedValue({ toolResult: { key: 'value' } })
  427. await syncTools(client as never, ctx, defaultOpts, new Map())
  428. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__legacy', arguments: {} })
  429. expect(result.isError).toBe(false)
  430. expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value"}' })
  431. })
  432. it('preserves structuredContent on a successful legacy result', async () => {
  433. const client = createMockClient([{ name: 'legacy-structured', inputSchema: { type: 'object' } }])
  434. client.callTool.mockResolvedValue({
  435. toolResult: 'legacy',
  436. structuredContent: { answer: 42 },
  437. })
  438. await syncTools(client as never, ctx, defaultOpts, new Map())
  439. const result = await ctx.tools.execute({
  440. signal: testToolSignal,
  441. callId: CallId('legacy-structured'), name: 'mcp__srv__legacy-structured', arguments: {},
  442. })
  443. if (result.isError) throw new Error('expected legacy structured result success')
  444. expect(result.value).toEqual({
  445. content: [{ type: 'text', text: '"legacy"' }],
  446. structuredContent: { answer: 42 },
  447. })
  448. })
  449. it('maps a legacy isError reply to failure', async () => {
  450. const client = createMockClient([{ name: 'legacy-error', inputSchema: { type: 'object' } }])
  451. client.callTool.mockResolvedValue({ toolResult: { reason: 'nope' }, isError: true })
  452. await syncTools(client as never, ctx, defaultOpts, new Map())
  453. const result = await ctx.tools.execute({
  454. signal: testToolSignal,
  455. callId: CallId('legacy-error'), name: 'mcp__srv__legacy-error', arguments: {},
  456. })
  457. expect(result.isError).toBe(true)
  458. expect(result.error?.message).toBe('{"reason":"nope"}')
  459. })
  460. })
  461. describe('tool execution edge cases', () => {
  462. let ctx: Context
  463. beforeEach(async () => {
  464. ctx = await mountRegistry()
  465. })
  466. it('handles audio content with placeholder', async () => {
  467. const client = createMockClient(
  468. [{ name: 'audio_tool', inputSchema: { type: 'object' } }],
  469. { content: [{ type: 'audio', mimeType: 'audio/mp3' }] },
  470. )
  471. await syncTools(client as never, ctx, defaultOpts, new Map())
  472. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} })
  473. expect(result.content[0]).toEqual({ type: 'text', text: '[audio: audio/mp3, content discarded]' })
  474. })
  475. it('handles resource content with placeholder', async () => {
  476. const client = createMockClient(
  477. [{ name: 'res_tool', inputSchema: { type: 'object' } }],
  478. { content: [{ type: 'resource' }] },
  479. )
  480. await syncTools(client as never, ctx, defaultOpts, new Map())
  481. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__res_tool', arguments: {} })
  482. expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' })
  483. })
  484. it('handles resource_link content with placeholder', async () => {
  485. const client = createMockClient(
  486. [{ name: 'link_tool', inputSchema: { type: 'object' } }],
  487. { content: [{ type: 'resource_link' }] },
  488. )
  489. await syncTools(client as never, ctx, defaultOpts, new Map())
  490. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__link_tool', arguments: {} })
  491. expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' })
  492. })
  493. it('handles unknown content types', async () => {
  494. const client = createMockClient(
  495. [{ name: 'unknown_tool', inputSchema: { type: 'object' } }],
  496. { content: [{ type: 'video' }] },
  497. )
  498. await syncTools(client as never, ctx, defaultOpts, new Map())
  499. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} })
  500. expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported content type: video]' })
  501. })
  502. it('handles image with missing mimeType (buggy server)', async () => {
  503. const client = createMockClient(
  504. [{ name: 'img2', inputSchema: { type: 'object' } }],
  505. { content: [{ type: 'image' }] },
  506. )
  507. await syncTools(client as never, ctx, defaultOpts, new Map())
  508. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__img2', arguments: {} })
  509. expect(result.content[0]).toEqual({ type: 'text', text: '[image: unknown, content discarded]' })
  510. })
  511. it('handles audio with missing mimeType (buggy server)', async () => {
  512. const client = createMockClient(
  513. [{ name: 'audio_no_mime', inputSchema: { type: 'object' } }],
  514. { content: [{ type: 'audio' }] },
  515. )
  516. await syncTools(client as never, ctx, defaultOpts, new Map())
  517. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} })
  518. expect(result.content[0]).toEqual({ type: 'text', text: '[audio: unknown, content discarded]' })
  519. })
  520. it('handles text block with missing text (buggy server)', async () => {
  521. const client = createMockClient(
  522. [{ name: 'notext', inputSchema: { type: 'object' } }],
  523. { content: [{ type: 'text' }] },
  524. )
  525. await syncTools(client as never, ctx, defaultOpts, new Map())
  526. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__notext', arguments: {} })
  527. expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' })
  528. })
  529. it('handles empty content array', async () => {
  530. const client = createMockClient(
  531. [{ name: 'empty_tool', inputSchema: { type: 'object' } }],
  532. { content: [] },
  533. )
  534. await syncTools(client as never, ctx, defaultOpts, new Map())
  535. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} })
  536. expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' })
  537. })
  538. it('handles legacy toolResult with undefined value', async () => {
  539. const client = createMockClient(
  540. [{ name: 'legacy2', inputSchema: { type: 'object' } }],
  541. )
  542. client.callTool.mockResolvedValue({ toolResult: undefined, structuredContent: undefined })
  543. await syncTools(client as never, ctx, defaultOpts, new Map())
  544. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} })
  545. expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' })
  546. })
  547. it('handles a legacy result with neither content nor toolResult', async () => {
  548. const client = createMockClient(
  549. [{ name: 'legacy-empty', inputSchema: { type: 'object' } }],
  550. )
  551. client.callTool.mockResolvedValue({})
  552. await syncTools(client as never, ctx, defaultOpts, new Map())
  553. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('legacy-empty'), name: 'mcp__srv__legacy-empty', arguments: {} })
  554. expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' })
  555. })
  556. it('handles isError with non-text content (fallback error message)', async () => {
  557. const client = createMockClient(
  558. [{ name: 'err_notext', inputSchema: { type: 'object' } }],
  559. { content: [{ type: 'image', mimeType: 'image/png' }], isError: true },
  560. )
  561. await syncTools(client as never, ctx, defaultOpts, new Map())
  562. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__err_notext', arguments: {} })
  563. expect(result.isError).toBe(true)
  564. expect(result.content[0]).toEqual({ type: 'text', text: 'Error: [image: image/png, content discarded]' })
  565. })
  566. it('uses tool description when provided', async () => {
  567. const client = createMockClient([
  568. { name: 'described', description: 'A described tool', inputSchema: { type: 'object' } },
  569. ])
  570. await syncTools(client as never, ctx, defaultOpts, new Map())
  571. const tool = ctx.tools.get('mcp__srv__described')
  572. expect(tool?.description).toBe('A described tool')
  573. })
  574. it('uses empty description when tool has no description', async () => {
  575. const client = createMockClient([
  576. { name: 'nodesc', inputSchema: { type: 'object' } },
  577. ])
  578. await syncTools(client as never, ctx, defaultOpts, new Map())
  579. const tool = ctx.tools.get('mcp__srv__nodesc')
  580. expect(tool?.description).toBe('')
  581. })
  582. })
  583. describe('createTransport', () => {
  584. it('creates StdioClientTransport for stdio config', () => {
  585. const config: Config = {
  586. transport: 'stdio',
  587. serverName: 'srv',
  588. command: 'node',
  589. args: ['server.js'],
  590. env: {},
  591. cwd: '/tmp',
  592. toolCallTimeoutMs: 60_000,
  593. }
  594. const transport = createTransport(config)
  595. expect(transport).toBeDefined()
  596. expect(transport).toHaveProperty('start')
  597. expect(transport).toHaveProperty('close')
  598. })
  599. it('creates StreamableHTTPClientTransport for http config without headers', () => {
  600. const config: Config = {
  601. transport: 'streamable-http',
  602. serverName: 'srv',
  603. url: 'http://localhost:3000/mcp',
  604. headers: {},
  605. toolCallTimeoutMs: 60_000,
  606. }
  607. const transport = createTransport(config)
  608. expect(transport).toBeDefined()
  609. expect(transport).toHaveProperty('start')
  610. expect(transport).toHaveProperty('close')
  611. })
  612. it('creates StreamableHTTPClientTransport for http config with headers', () => {
  613. const config: Config = {
  614. transport: 'streamable-http',
  615. serverName: 'srv',
  616. url: 'http://localhost:3000/mcp',
  617. headers: { Authorization: 'Bearer token' },
  618. toolCallTimeoutMs: 60_000,
  619. }
  620. const transport = createTransport(config)
  621. expect(transport).toBeDefined()
  622. expect(transport).toHaveProperty('start')
  623. expect(transport).toHaveProperty('close')
  624. })
  625. it('scrubs sensitive env vars and forwards the rest', () => {
  626. const original = { ...process.env }
  627. try {
  628. process.env.SAFE_VAR = 'kept'
  629. process.env.MY_SECRET = 'hidden'
  630. process.env.API_KEY = 'hidden'
  631. process.env.AUTH_TOKEN = 'hidden'
  632. const config: Config = {
  633. transport: 'stdio',
  634. serverName: 'srv',
  635. command: 'echo',
  636. args: [],
  637. env: { EXTRA: 'injected' },
  638. cwd: '',
  639. toolCallTimeoutMs: 60_000,
  640. }
  641. // createTransport internally calls buildChildEnv; we verify by inspecting
  642. // the constructed StdioClientTransport. Since we can't inspect private fields
  643. // easily, we at least confirm it doesn't throw and returns a transport.
  644. const transport = createTransport(config)
  645. expect(transport).toBeDefined()
  646. } finally {
  647. // Restore env
  648. delete process.env.SAFE_VAR
  649. delete process.env.MY_SECRET
  650. delete process.env.API_KEY
  651. delete process.env.AUTH_TOKEN
  652. for (const key of Object.keys(process.env)) {
  653. if (!(key in original)) Reflect.deleteProperty(process.env, key)
  654. }
  655. }
  656. })
  657. it('merges explicit env on top of scrubbed ambient env', () => {
  658. const config: Config = {
  659. transport: 'stdio',
  660. serverName: 'srv',
  661. command: 'echo',
  662. args: [],
  663. env: { CUSTOM: 'value' },
  664. cwd: '',
  665. toolCallTimeoutMs: 60_000,
  666. }
  667. const transport = createTransport(config)
  668. expect(transport).toBeDefined()
  669. })
  670. })
  671. describe('tool execution — non-object args fallback', () => {
  672. let ctx: Context
  673. beforeEach(async () => {
  674. ctx = await mountRegistry()
  675. })
  676. it('coerces null args to empty object for callTool', async () => {
  677. const client = createMockClient(
  678. [{ name: 'coerce', inputSchema: { type: 'object' } }],
  679. { content: [{ type: 'text', text: 'ok' }] },
  680. )
  681. await syncTools(client as never, ctx, defaultOpts, new Map())
  682. await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null })
  683. expect(client.callTool).toHaveBeenCalledWith(
  684. { name: 'coerce', arguments: {} },
  685. undefined,
  686. expect.anything(),
  687. )
  688. })
  689. it('coerces primitive string args to empty object for callTool', async () => {
  690. const client = createMockClient(
  691. [{ name: 'coerce2', inputSchema: { type: 'object' } }],
  692. { content: [{ type: 'text', text: 'ok' }] },
  693. )
  694. await syncTools(client as never, ctx, defaultOpts, new Map())
  695. await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__coerce2', arguments: 'bad' })
  696. expect(client.callTool).toHaveBeenCalledWith(
  697. { name: 'coerce2', arguments: {} },
  698. undefined,
  699. expect.anything(),
  700. )
  701. })
  702. })