|
|
@@ -1,6 +1,5 @@
|
|
|
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|
|
-import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
|
-import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
|
|
+import { Client, InMemoryTransport } from '@modelcontextprotocol/client'
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
|
import AttachmentStore, { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
|
|
|
import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment'
|
|
|
@@ -39,21 +38,12 @@ function createMockClient(tools: MockTool[], callResult: MockCallResult = { cont
|
|
|
): Promise<{ tools: MockTool[]; nextCursor: string | undefined }> => ({ tools, nextCursor: undefined }))
|
|
|
const callTool = vi.fn(async (
|
|
|
_params?: Record<string, unknown>,
|
|
|
- _compatibilitySchema?: unknown,
|
|
|
_options?: unknown,
|
|
|
): Promise<Record<string, unknown>> => ({ ...callResult }))
|
|
|
return {
|
|
|
listTools,
|
|
|
callTool,
|
|
|
- request: vi.fn(async (
|
|
|
- request: { method: string; params?: Record<string, unknown> },
|
|
|
- _schema: unknown,
|
|
|
- options?: unknown,
|
|
|
- ): Promise<unknown> => {
|
|
|
- if (request.method === 'tools/list') return listTools(request.params)
|
|
|
- if (request.method === 'tools/call') return callTool(request.params, undefined, options)
|
|
|
- throw new Error(`unexpected MCP request: ${request.method}`)
|
|
|
- }),
|
|
|
+ getServerCapabilities: (): object => ({ tools: {} }),
|
|
|
setNotificationHandler: vi.fn(),
|
|
|
connect: vi.fn().mockResolvedValue(undefined),
|
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
|
@@ -296,64 +286,7 @@ describe('syncTools', () => {
|
|
|
expect(secondDisposers.size).toBe(1)
|
|
|
})
|
|
|
|
|
|
- it('drains paginated listTools responses', async () => {
|
|
|
- const client = createMockClient([])
|
|
|
- client.listTools
|
|
|
- .mockResolvedValueOnce({ tools: [{ name: 'page1', inputSchema: { type: 'object' } }], nextCursor: 'cursor1' })
|
|
|
- .mockResolvedValueOnce({ tools: [{ name: 'page2', inputSchema: { type: 'object' } }], nextCursor: undefined })
|
|
|
-
|
|
|
- const disposers = await syncTools(client as never, ctx, defaultOpts, new Map())
|
|
|
-
|
|
|
- expect(disposers.size).toBe(2)
|
|
|
- expect(ctx.tools.get('mcp__srv__page1')).toBeDefined()
|
|
|
- expect(ctx.tools.get('mcp__srv__page2')).toBeDefined()
|
|
|
- })
|
|
|
-
|
|
|
- it.each([
|
|
|
- ['immediate', ['cursor1', 'cursor1']],
|
|
|
- ['multi-page', ['cursor1', 'cursor2', 'cursor1']],
|
|
|
- ])('rejects a pagination cycle through empty pages (%s)', async (_kind, cursors) => {
|
|
|
- const client = createMockClient([])
|
|
|
- client.listTools.mockRejectedValue(new Error('pagination continued after the repeated cursor'))
|
|
|
- for (const nextCursor of cursors) {
|
|
|
- client.listTools.mockResolvedValueOnce({ tools: [], nextCursor })
|
|
|
- }
|
|
|
-
|
|
|
- await expect(syncTools(client as never, ctx, defaultOpts, new Map()))
|
|
|
- .rejects.toThrow('mcp-client(srv): server repeated a tools/list continuation cursor — invalid tool list')
|
|
|
- expect(client.listTools).toHaveBeenCalledTimes(cursors.length)
|
|
|
- expect(ctx.tools.schemas()).toEqual([])
|
|
|
- })
|
|
|
-
|
|
|
- it('keeps callable tools after a pagination cycle and accepts a later complete list', async () => {
|
|
|
- const client = createMockClient([{ name: 'stable', inputSchema: { type: 'object' } }])
|
|
|
- const previous = await syncTools(client as never, ctx, defaultOpts, new Map())
|
|
|
- const stable = ctx.tools.get('mcp__srv__stable')
|
|
|
- client.listTools
|
|
|
- .mockResolvedValueOnce({ tools: [{ name: 'partial', inputSchema: { type: 'object' } }], nextCursor: 'cursor1' })
|
|
|
- .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' })
|
|
|
- .mockRejectedValue(new Error('pagination continued after the repeated cursor'))
|
|
|
-
|
|
|
- await expect(syncTools(client as never, ctx, defaultOpts, previous)).rejects.toThrow(/repeated.*cursor/)
|
|
|
- expect(client.listTools).toHaveBeenCalledTimes(3)
|
|
|
- expect(ctx.tools.get('mcp__srv__stable')).toBe(stable)
|
|
|
- expect(ctx.tools.get('mcp__srv__partial')).toBeUndefined()
|
|
|
- const result = await ctx.tools.execute({
|
|
|
- signal: testToolSignal, callId: ToolCallId('pagination-retained'), name: 'mcp__srv__stable', arguments: {},
|
|
|
- })
|
|
|
- expect(result.isError).toBe(false)
|
|
|
- expect(result.content).toEqual([{ type: 'text', text: 'ok' }])
|
|
|
-
|
|
|
- client.listTools
|
|
|
- .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' })
|
|
|
- .mockResolvedValueOnce({ tools: [{ name: 'recovered', inputSchema: { type: 'object' } }], nextCursor: undefined })
|
|
|
- const recovered = await syncTools(client as never, ctx, defaultOpts, previous)
|
|
|
- expect([...recovered.keys()]).toEqual(['mcp__srv__recovered'])
|
|
|
- expect(ctx.tools.get('mcp__srv__stable')).toBeUndefined()
|
|
|
- expect(client.listTools).toHaveBeenLastCalledWith({ cursor: 'cursor1' })
|
|
|
- })
|
|
|
-
|
|
|
- it('owns output validation independently of the SDK per-page cache', async () => {
|
|
|
+ it('validates outputs with the complete SDK definition after paginated discovery', async () => {
|
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
|
|
serverTransport.onmessage = (message) => {
|
|
|
if (!('id' in message) || !('method' in message)) return
|
|
|
@@ -395,7 +328,7 @@ describe('syncTools', () => {
|
|
|
const name = params && 'name' in params ? params.name : undefined
|
|
|
result = name === 'supported'
|
|
|
? { content: [{ type: 'text', text: 'missing structured content' }] }
|
|
|
- : { content: [42, null], structuredContent: ['kept', { nested: true }] }
|
|
|
+ : { content: [{ type: 'text', text: 'kept' }], structuredContent: { 'x-value': 'kept' } }
|
|
|
} else {
|
|
|
result = {}
|
|
|
}
|
|
|
@@ -412,8 +345,7 @@ describe('syncTools', () => {
|
|
|
signal: testToolSignal,
|
|
|
callId: ToolCallId('missing'), name: 'mcp__srv__supported', arguments: {},
|
|
|
})
|
|
|
- expect(missing.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } })
|
|
|
- expect(missing.error?.message).toContain('structuredContent')
|
|
|
+ expect(missing.error?.message).toContain('structured content')
|
|
|
|
|
|
const fallback = await ctx.tools.execute({
|
|
|
signal: testToolSignal,
|
|
|
@@ -421,8 +353,8 @@ describe('syncTools', () => {
|
|
|
})
|
|
|
if (fallback.isError) throw new Error('unsupported schema must use the bridge fallback')
|
|
|
expect(fallback.value).toEqual({
|
|
|
- content: [42, null],
|
|
|
- structuredContent: ['kept', { nested: true }],
|
|
|
+ content: [{ type: 'text', text: 'kept' }],
|
|
|
+ structuredContent: { 'x-value': 'kept' },
|
|
|
})
|
|
|
} finally {
|
|
|
await client.close()
|
|
|
@@ -453,7 +385,6 @@ describe('tool execution', () => {
|
|
|
// The wire sees the raw MCP name, never the public name.
|
|
|
expect(client.callTool).toHaveBeenCalledWith(
|
|
|
{ name: 'echo', arguments: { msg: 'hi' } },
|
|
|
- undefined,
|
|
|
expect.objectContaining({ timeout: 60_000 }),
|
|
|
)
|
|
|
})
|
|
|
@@ -471,7 +402,6 @@ describe('tool execution', () => {
|
|
|
expect(result.isError).toBe(false)
|
|
|
expect(client.callTool).toHaveBeenCalledWith(
|
|
|
{ name: 'admin.reset', arguments: {} },
|
|
|
- undefined,
|
|
|
expect.anything(),
|
|
|
)
|
|
|
})
|
|
|
@@ -557,7 +487,7 @@ describe('tool execution', () => {
|
|
|
const rich = await mountRichRegistry()
|
|
|
const blocks = [
|
|
|
{ type: 'image', mimeType: 'image/png', data: 'AQ==' },
|
|
|
- { type: 'image', mimeType: 'image/png', data: 'not base64' },
|
|
|
+ { type: 'image', mimeType: 'image/png', data: 'AQ' },
|
|
|
] satisfies JsonValue[]
|
|
|
const client = createMockClient(
|
|
|
[{ name: 'img', inputSchema: { type: 'object' } }],
|
|
|
@@ -579,14 +509,14 @@ describe('tool execution', () => {
|
|
|
expect(textAt(result.content, 1)).toContain('not canonical base64')
|
|
|
})
|
|
|
|
|
|
- it('rejects non-canonical and incomplete image blocks as one atomic batch', async () => {
|
|
|
+ it('rejects unsupported media and base64 aliases as one atomic batch', async () => {
|
|
|
const rich = await mountRichRegistry()
|
|
|
const client = createMockClient(
|
|
|
[{ name: 'img', inputSchema: { type: 'object' } }],
|
|
|
{ content: [
|
|
|
{ type: 'image', mimeType: 'image/tiff', data: 'AQ==' },
|
|
|
{ type: 'image', mimeType: 'image/png', data: 'AB==' },
|
|
|
- { type: 'image', mimeType: 'image/png' },
|
|
|
+ { type: 'image', mimeType: 'image/png', data: 'AQ' },
|
|
|
] },
|
|
|
)
|
|
|
|
|
|
@@ -811,11 +741,12 @@ describe('tool execution', () => {
|
|
|
expect(blocked.content).toEqual([{ type: 'text', text: 'blocked by policy' }])
|
|
|
})
|
|
|
|
|
|
- it('preserves primitive JSON MCP blocks while Native rendering marks them unsupported', async () => {
|
|
|
+ it('marks primitive blocks from canonical-value replacement unsupported', async () => {
|
|
|
const blocks = [42, null, ['nested']] satisfies JsonValue[]
|
|
|
+ ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({ kind: 'accept', value: { content: blocks } }))
|
|
|
const client = createMockClient(
|
|
|
[{ name: 'primitive-blocks', inputSchema: { type: 'object' } }],
|
|
|
- { content: blocks },
|
|
|
+ { content: [] },
|
|
|
)
|
|
|
|
|
|
await syncTools(client as never, ctx, defaultOpts, new Map())
|
|
|
@@ -828,7 +759,7 @@ describe('tool execution', () => {
|
|
|
type: 'text',
|
|
|
text: '[unsupported MCP content block: expected an object]\n[unsupported MCP content block: expected an object]\n[unsupported MCP content block: expected an object]',
|
|
|
})
|
|
|
- if (result.isError) throw new Error('expected primitive MCP blocks to remain a successful JSON value')
|
|
|
+ if (result.isError) throw new Error('expected canonical-value replacement to remain a successful JSON value')
|
|
|
expect(result.value).toEqual({ content: blocks })
|
|
|
})
|
|
|
|
|
|
@@ -917,70 +848,14 @@ describe('tool execution', () => {
|
|
|
|
|
|
expect(client.callTool).toHaveBeenCalledWith(
|
|
|
expect.anything(),
|
|
|
- undefined,
|
|
|
expect.objectContaining({ signal: controller.signal }),
|
|
|
)
|
|
|
})
|
|
|
|
|
|
- it('handles legacy toolResult shape', async () => {
|
|
|
- const client = createMockClient(
|
|
|
- [{ name: 'legacy', inputSchema: { type: 'object' } }],
|
|
|
- )
|
|
|
- client.callTool.mockResolvedValue({ toolResult: { key: 'value' } })
|
|
|
-
|
|
|
- await syncTools(client as never, ctx, defaultOpts, new Map())
|
|
|
- const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__legacy', arguments: {} })
|
|
|
-
|
|
|
- expect(result.isError).toBe(false)
|
|
|
- expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value"}' })
|
|
|
- })
|
|
|
-
|
|
|
- it('preserves structuredContent on a successful legacy result', async () => {
|
|
|
- const client = createMockClient([{ name: 'legacy-structured', inputSchema: { type: 'object' } }])
|
|
|
- client.callTool.mockResolvedValue({
|
|
|
- toolResult: 'legacy',
|
|
|
- structuredContent: { answer: 42 },
|
|
|
- })
|
|
|
-
|
|
|
- await syncTools(client as never, ctx, defaultOpts, new Map())
|
|
|
- const result = await ctx.tools.execute({
|
|
|
- signal: testToolSignal,
|
|
|
- callId: ToolCallId('legacy-structured'), name: 'mcp__srv__legacy-structured', arguments: {},
|
|
|
- })
|
|
|
-
|
|
|
- if (result.isError) throw new Error('expected legacy structured result success')
|
|
|
- expect(result.value).toEqual({
|
|
|
- content: [{ type: 'text', text: '"legacy"' }],
|
|
|
- structuredContent: { answer: 42 },
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- it('maps a legacy isError reply to failure', async () => {
|
|
|
- const client = createMockClient([{ name: 'legacy-error', inputSchema: { type: 'object' } }])
|
|
|
- client.callTool.mockResolvedValue({ toolResult: { reason: 'nope' }, isError: true })
|
|
|
-
|
|
|
- await syncTools(client as never, ctx, defaultOpts, new Map())
|
|
|
- const result = await ctx.tools.execute({
|
|
|
- signal: testToolSignal,
|
|
|
- callId: ToolCallId('legacy-error'), name: 'mcp__srv__legacy-error', arguments: {},
|
|
|
- })
|
|
|
-
|
|
|
- expect(result.isError).toBe(true)
|
|
|
- expect(result.error?.message).toBe('{"reason":"nope"}')
|
|
|
- })
|
|
|
-})
|
|
|
-
|
|
|
-describe('tool execution edge cases', () => {
|
|
|
- let ctx: Context
|
|
|
-
|
|
|
- beforeEach(async () => {
|
|
|
- ctx = await mountRegistry()
|
|
|
- })
|
|
|
-
|
|
|
it('reports unsupported audio without claiming the raw block was discarded', async () => {
|
|
|
const client = createMockClient(
|
|
|
[{ name: 'audio_tool', inputSchema: { type: 'object' } }],
|
|
|
- { content: [{ type: 'audio', mimeType: 'audio/mp3' }] },
|
|
|
+ { content: [{ type: 'audio', mimeType: 'audio/mp3', data: 'AQ==' }] },
|
|
|
)
|
|
|
|
|
|
await syncTools(client as never, ctx, defaultOpts, new Map())
|
|
|
@@ -995,7 +870,7 @@ describe('tool execution edge cases', () => {
|
|
|
it('reports unsupported embedded resources without discarding the raw block', async () => {
|
|
|
const client = createMockClient(
|
|
|
[{ name: 'res_tool', inputSchema: { type: 'object' } }],
|
|
|
- { content: [{ type: 'resource' }] },
|
|
|
+ { content: [{ type: 'resource', resource: { uri: 'memory://document', text: 'Recorded text.' } }] },
|
|
|
)
|
|
|
|
|
|
await syncTools(client as never, ctx, defaultOpts, new Map())
|
|
|
@@ -1019,10 +894,13 @@ describe('tool execution edge cases', () => {
|
|
|
expect(result.content[0]).toEqual({ type: 'text', text: 'Resource link: Design (https://example.test/design)' })
|
|
|
})
|
|
|
|
|
|
- it('diagnoses an incomplete resource link', async () => {
|
|
|
+ it('diagnoses an incomplete resource link from canonical-value replacement', async () => {
|
|
|
+ ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
|
|
|
+ kind: 'accept', value: { content: [{ type: 'resource_link', name: 'Missing URI' }] },
|
|
|
+ }))
|
|
|
const client = createMockClient(
|
|
|
[{ name: 'link_tool', inputSchema: { type: 'object' } }],
|
|
|
- { content: [{ type: 'resource_link', name: 'Missing URI' }] },
|
|
|
+ { content: [] },
|
|
|
)
|
|
|
|
|
|
await syncTools(client as never, ctx, defaultOpts, new Map())
|
|
|
@@ -1033,10 +911,11 @@ describe('tool execution edge cases', () => {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
- it('handles unknown content types', async () => {
|
|
|
+ it('diagnoses unknown content types from canonical-value replacement', async () => {
|
|
|
+ ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({ kind: 'accept', value: { content: [{ type: 'video' }] } }))
|
|
|
const client = createMockClient(
|
|
|
[{ name: 'unknown_tool', inputSchema: { type: 'object' } }],
|
|
|
- { content: [{ type: 'video' }] },
|
|
|
+ { content: [] },
|
|
|
)
|
|
|
|
|
|
await syncTools(client as never, ctx, defaultOpts, new Map())
|
|
|
@@ -1045,10 +924,11 @@ describe('tool execution edge cases', () => {
|
|
|
expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported MCP content type: video]' })
|
|
|
})
|
|
|
|
|
|
- it('handles image with missing mimeType (buggy server)', async () => {
|
|
|
+ it('diagnoses an image without media type from canonical-value replacement', async () => {
|
|
|
+ ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({ kind: 'accept', value: { content: [{ type: 'image' }] } }))
|
|
|
const client = createMockClient(
|
|
|
[{ name: 'img2', inputSchema: { type: 'object' } }],
|
|
|
- { content: [{ type: 'image' }] },
|
|
|
+ { content: [] },
|
|
|
)
|
|
|
|
|
|
await syncTools(client as never, ctx, defaultOpts, new Map())
|
|
|
@@ -1056,14 +936,15 @@ describe('tool execution edge cases', () => {
|
|
|
|
|
|
expect(result.content[0]).toEqual({
|
|
|
type: 'text',
|
|
|
- text: '[image unavailable: unknown media type; the declared media type is not PNG, JPEG, WebP, or GIF; raw image data remains available to programmatic callers]',
|
|
|
+ text: '[image unavailable: unknown media type; this result was not admitted to durable model context; raw image data remains available to programmatic callers]',
|
|
|
})
|
|
|
})
|
|
|
|
|
|
- it('handles audio with missing mimeType (buggy server)', async () => {
|
|
|
+ it('diagnoses audio without media type from canonical-value replacement', async () => {
|
|
|
+ ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({ kind: 'accept', value: { content: [{ type: 'audio' }] } }))
|
|
|
const client = createMockClient(
|
|
|
[{ name: 'audio_no_mime', inputSchema: { type: 'object' } }],
|
|
|
- { content: [{ type: 'audio' }] },
|
|
|
+ { content: [] },
|
|
|
)
|
|
|
|
|
|
await syncTools(client as never, ctx, defaultOpts, new Map())
|
|
|
@@ -1075,10 +956,11 @@ describe('tool execution edge cases', () => {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
- it('handles text block with missing text (buggy server)', async () => {
|
|
|
+ it('diagnoses missing text from canonical-value replacement', async () => {
|
|
|
+ ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({ kind: 'accept', value: { content: [{ type: 'text' }] } }))
|
|
|
const client = createMockClient(
|
|
|
[{ name: 'notext', inputSchema: { type: 'object' } }],
|
|
|
- { content: [{ type: 'text' }] },
|
|
|
+ { content: [] },
|
|
|
)
|
|
|
|
|
|
await syncTools(client as never, ctx, defaultOpts, new Map())
|
|
|
@@ -1100,34 +982,10 @@ describe('tool execution edge cases', () => {
|
|
|
})
|
|
|
|
|
|
|
|
|
- it('handles legacy toolResult with undefined value', async () => {
|
|
|
- const client = createMockClient(
|
|
|
- [{ name: 'legacy2', inputSchema: { type: 'object' } }],
|
|
|
- )
|
|
|
- client.callTool.mockResolvedValue({ toolResult: undefined, structuredContent: undefined })
|
|
|
-
|
|
|
- await syncTools(client as never, ctx, defaultOpts, new Map())
|
|
|
- const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__legacy2', arguments: {} })
|
|
|
-
|
|
|
- expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' })
|
|
|
- })
|
|
|
-
|
|
|
- it('handles a legacy result with neither content nor toolResult', async () => {
|
|
|
- const client = createMockClient(
|
|
|
- [{ name: 'legacy-empty', inputSchema: { type: 'object' } }],
|
|
|
- )
|
|
|
- client.callTool.mockResolvedValue({})
|
|
|
-
|
|
|
- await syncTools(client as never, ctx, defaultOpts, new Map())
|
|
|
- const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('legacy-empty'), name: 'mcp__srv__legacy-empty', arguments: {} })
|
|
|
-
|
|
|
- expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' })
|
|
|
- })
|
|
|
-
|
|
|
it('handles isError with non-text content (fallback error message)', async () => {
|
|
|
const client = createMockClient(
|
|
|
[{ name: 'err_notext', inputSchema: { type: 'object' } }],
|
|
|
- { content: [{ type: 'image', mimeType: 'image/png' }], isError: true },
|
|
|
+ { content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }], isError: true },
|
|
|
)
|
|
|
|
|
|
await syncTools(client as never, ctx, defaultOpts, new Map())
|
|
|
@@ -1277,7 +1135,6 @@ describe('tool execution — non-object args fallback', () => {
|
|
|
|
|
|
expect(client.callTool).toHaveBeenCalledWith(
|
|
|
{ name: 'coerce', arguments: {} },
|
|
|
- undefined,
|
|
|
expect.anything(),
|
|
|
)
|
|
|
})
|
|
|
@@ -1293,7 +1150,6 @@ describe('tool execution — non-object args fallback', () => {
|
|
|
|
|
|
expect(client.callTool).toHaveBeenCalledWith(
|
|
|
{ name: 'coerce2', arguments: {} },
|
|
|
- undefined,
|
|
|
expect.anything(),
|
|
|
)
|
|
|
})
|