fix(mcp): project image results through durable attachments
This commit is contained in:
@@ -46,7 +46,7 @@ server.registerTool('image', {
|
||||
}, async () => ({
|
||||
content: [
|
||||
{ type: 'text', text: 'Here is an image:' },
|
||||
{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' },
|
||||
{ type: 'image', data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', mimeType: 'image/png' },
|
||||
{ type: 'text', text: 'End of image.' },
|
||||
],
|
||||
}))
|
||||
|
||||
@@ -19,9 +19,11 @@ 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 LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter, LlmService } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } 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'
|
||||
@@ -43,6 +45,33 @@ async function mountRegistry(): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Exact-route adapter used to prove real MCP image admission without an API key. */
|
||||
class ImageAdapter extends LlmAdapter {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text', 'image'] })
|
||||
}
|
||||
|
||||
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
throw new Error('MCP image e2e never streams')
|
||||
}
|
||||
}
|
||||
|
||||
async function mountImageRegistry(dshHome: string): Promise<Context> {
|
||||
const ctx = await mountRegistry()
|
||||
await ctx.plugin(LocalAttachmentStore, { dshHome })
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['visual'], new ImageAdapter())
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Calling-agent stand-in pinned to the keyless image-capable route. */
|
||||
function imageAgent(): object {
|
||||
return {
|
||||
options: { provider: 'visual', model: 'vision' },
|
||||
session: { requestHeader: () => undefined },
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
setTimeout(gate.resolve, ms)
|
||||
@@ -66,6 +95,7 @@ function nextCallId(): CallId {
|
||||
|
||||
describe('fixture server — controlled scenarios', () => {
|
||||
let ctx: Context
|
||||
let home: string
|
||||
|
||||
const fixtureConfig: Config = {
|
||||
transport: 'stdio',
|
||||
@@ -79,13 +109,15 @@ describe('fixture server — controlled scenarios', () => {
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await mountRegistry()
|
||||
home = await mkdtemp(join(tmpdir(), 'mcp-image-e2e-'))
|
||||
ctx = await mountImageRegistry(home)
|
||||
await apply(ctx, fixtureConfig)
|
||||
}, 30_000)
|
||||
|
||||
afterAll(async () => {
|
||||
if (ctx) await ctx.fiber.dispose()
|
||||
await sleep(200)
|
||||
await rm(home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('discovers all fixture tools under the server namespace', () => {
|
||||
@@ -141,16 +173,23 @@ describe('fixture server — controlled scenarios', () => {
|
||||
expect(result.content[0]).toMatchObject({ type: 'text' })
|
||||
})
|
||||
|
||||
it('executes image() → image placeholder', async () => {
|
||||
it('executes image() → ordered durable image content', async () => {
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
signal: testToolSignal, agent: imageAgent() as never,
|
||||
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.')
|
||||
expect(result.content).toHaveLength(3)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'Here is an image:' })
|
||||
expect(result.content[2]).toEqual({ type: 'text', text: 'End of image.' })
|
||||
const image = result.content[1]
|
||||
if (image?.type !== 'image') throw new Error(`expected an image block, got ${JSON.stringify(image)}`)
|
||||
expect(image.attachment).toMatchObject({ mediaType: 'image/png', width: 1, height: 1 })
|
||||
const stored = await ctx.attachments.readImage(image.attachment)
|
||||
expect(stored.data.byteLength).toBe(image.attachment.bytes)
|
||||
if (result.isError) throw new Error('expected MCP image success')
|
||||
expect(JSON.stringify(result.value)).toContain('iVBORw0KGgo')
|
||||
expect(JSON.stringify(result.content)).not.toContain('iVBORw0KGgo')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -334,13 +373,14 @@ describe('server-everything — official test server', () => {
|
||||
expect(textOf(result.content[0])).toContain('10')
|
||||
})
|
||||
|
||||
it('executes get-tiny-image → image placeholder', async () => {
|
||||
it('executes get-tiny-image → explicit refusal without a durable route', 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]')
|
||||
expect(result.content.map(block => block.type === 'text' ? block.text : '').join('\n'))
|
||||
.toContain('[image unavailable: image/png; no attachment store is mounted;')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,9 +2,14 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment'
|
||||
import { CallId, LlmAdapter, LlmService } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { type JsonValue } from '@deepseek-ai/dsh-tools'
|
||||
import type { PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import { publicToolName, syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
|
||||
import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts'
|
||||
import type { Config } from '@deepseek-ai/dsh-mcp-client'
|
||||
@@ -63,6 +68,79 @@ async function mountRegistry(): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
const IMAGE_LIMITS: ImageAttachmentLimits = {
|
||||
maxImageBytes: 1024,
|
||||
maxImagesPerMessage: 4,
|
||||
maxMessageImageBytes: 2048,
|
||||
maxImagePixels: 1024,
|
||||
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
|
||||
}
|
||||
|
||||
/** Attachment fake that records exact decoded batches while using the real batch contract. */
|
||||
class RecordingAttachmentStore extends AttachmentStore {
|
||||
readonly imageLimits = IMAGE_LIMITS
|
||||
readonly saved: SaveImageAttachment[] = []
|
||||
|
||||
validateImage(_input: SaveImageAttachment): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef> {
|
||||
this.saved.push(input)
|
||||
const marker = input.data[0] ?? 0
|
||||
return Promise.resolve({
|
||||
attachmentId: AttachmentId(`sha256:${marker.toString(16).padStart(64, '0')}`),
|
||||
mediaType: input.mediaType,
|
||||
bytes: input.data.byteLength,
|
||||
width: 1,
|
||||
height: 1,
|
||||
})
|
||||
}
|
||||
|
||||
readImage(_ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
/** Exact-route fake used only for image-capability admission. */
|
||||
class ImageCatalogAdapter extends LlmAdapter {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
inputModalities: model === 'vision' ? ['text', 'image'] : ['text'],
|
||||
})
|
||||
}
|
||||
|
||||
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
throw new Error('MCP bridge tests never stream')
|
||||
}
|
||||
}
|
||||
|
||||
async function mountRichRegistry(): Promise<{ ctx: Context; attachments: RecordingAttachmentStore }> {
|
||||
const ctx = await mountRegistry()
|
||||
await ctx.plugin(RecordingAttachmentStore)
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['visual'], new ImageCatalogAdapter())
|
||||
return { ctx, attachments: ctx.attachments as RecordingAttachmentStore }
|
||||
}
|
||||
|
||||
/** Calling-agent stand-in with no durable request header yet. */
|
||||
function agentOn(model: string | undefined = 'vision'): object {
|
||||
return {
|
||||
options: model === undefined ? {} : { provider: 'visual', model },
|
||||
session: { requestHeader: () => undefined },
|
||||
}
|
||||
}
|
||||
|
||||
/** Require one text block and return its text for diagnostic assertions. */
|
||||
function textAt(content: readonly ContentBlock[], index = 0): string {
|
||||
const block = content[index]
|
||||
if (block?.type !== 'text') throw new Error(`expected text content at index ${index}`)
|
||||
return block.text
|
||||
}
|
||||
|
||||
const defaultOpts: ToolBridgeOptions = {
|
||||
registrationFailure: 'contain',
|
||||
serverName: 'srv',
|
||||
@@ -363,24 +441,306 @@ describe('tool execution', () => {
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }])
|
||||
})
|
||||
|
||||
it('preserves full JSON MCP blocks while Native rendering uses placeholders', async () => {
|
||||
it('preserves canonical MCP JSON while admitting an ordered mixed image result', async () => {
|
||||
const rich = await mountRichRegistry()
|
||||
const blocks = [
|
||||
{ type: 'text', text: 'before' },
|
||||
{ type: 'image', mimeType: 'image/png', data: 'base64-data', annotations: { audience: ['assistant'] } },
|
||||
{ type: 'image', mimeType: 'image/png', data: 'AQ==', annotations: { audience: ['assistant'] } },
|
||||
{ type: 'text', text: 'between' },
|
||||
{ type: 'image', mimeType: 'image/jpeg', data: 'Ag==' },
|
||||
{ type: 'text', text: 'after' },
|
||||
] satisfies JsonValue[]
|
||||
const client = createMockClient(
|
||||
[{ name: 'img', inputSchema: { type: 'object' } }],
|
||||
{ content: blocks },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} })
|
||||
await syncTools(client as never, rich.ctx, defaultOpts, new Map())
|
||||
const result = await rich.ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('c1'),
|
||||
name: 'mcp__srv__img',
|
||||
arguments: {},
|
||||
agent: agentOn() as never,
|
||||
})
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' })
|
||||
expect(result.content.map(block => block.type)).toEqual(['text', 'image', 'text', 'image', 'text'])
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'before' })
|
||||
expect(result.content[2]).toEqual({ type: 'text', text: 'between' })
|
||||
expect(result.content[4]).toEqual({ type: 'text', text: 'after' })
|
||||
const firstImage = result.content[1]
|
||||
const secondImage = result.content[3]
|
||||
if (firstImage?.type !== 'image' || secondImage?.type !== 'image') throw new Error('expected ordered image blocks')
|
||||
expect(firstImage.attachment.mediaType).toBe('image/png')
|
||||
expect(firstImage.attachment.bytes).toBe(1)
|
||||
expect(secondImage.attachment.mediaType).toBe('image/jpeg')
|
||||
expect(secondImage.attachment.bytes).toBe(1)
|
||||
expect(rich.attachments.saved.map(input => [...input.data])).toEqual([[1], [2]])
|
||||
expect(JSON.stringify(result.content)).not.toContain('AQ==')
|
||||
expect(JSON.stringify(result.content)).not.toContain('Ag==')
|
||||
if (result.isError) throw new Error('expected MCP success')
|
||||
expect(result.value).toEqual({ content: blocks })
|
||||
})
|
||||
|
||||
it('keeps a valid raw image result while explicitly refusing it without a durable route', async () => {
|
||||
const blocks = [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] satisfies JsonValue[]
|
||||
const client = createMockClient(
|
||||
[{ name: 'img', inputSchema: { type: 'object' } }],
|
||||
{ content: blocks },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('no-store'),
|
||||
name: 'mcp__srv__img',
|
||||
arguments: {},
|
||||
agent: agentOn() as never,
|
||||
})
|
||||
|
||||
expect(result.content).toEqual([{
|
||||
type: 'text',
|
||||
text: '[image unavailable: image/png; no attachment store is mounted; raw image data remains available to programmatic callers]',
|
||||
}])
|
||||
if (result.isError) throw new Error('image refusal must preserve MCP success')
|
||||
expect(result.value).toEqual({ content: blocks })
|
||||
})
|
||||
|
||||
it('rejects a malformed image batch before storing any member', async () => {
|
||||
const rich = await mountRichRegistry()
|
||||
const blocks = [
|
||||
{ type: 'image', mimeType: 'image/png', data: 'AQ==' },
|
||||
{ type: 'image', mimeType: 'image/png', data: 'not base64' },
|
||||
] satisfies JsonValue[]
|
||||
const client = createMockClient(
|
||||
[{ name: 'img', inputSchema: { type: 'object' } }],
|
||||
{ content: blocks },
|
||||
)
|
||||
|
||||
await syncTools(client as never, rich.ctx, defaultOpts, new Map())
|
||||
const result = await rich.ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('bad-batch'),
|
||||
name: 'mcp__srv__img',
|
||||
arguments: {},
|
||||
agent: agentOn() as never,
|
||||
})
|
||||
|
||||
expect(rich.attachments.saved).toEqual([])
|
||||
expect(result.content).toHaveLength(2)
|
||||
expect(textAt(result.content, 0)).toContain('another image in the same result was invalid')
|
||||
expect(textAt(result.content, 1)).toContain('not canonical base64')
|
||||
})
|
||||
|
||||
it('rejects non-canonical and incomplete image blocks 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' },
|
||||
] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, rich.ctx, defaultOpts, new Map())
|
||||
const result = await rich.ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('strict-batch'),
|
||||
name: 'mcp__srv__img',
|
||||
arguments: {},
|
||||
agent: agentOn() as never,
|
||||
})
|
||||
|
||||
expect(rich.attachments.saved).toEqual([])
|
||||
expect(result.content).toHaveLength(3)
|
||||
expect(textAt(result.content, 0)).toContain('not PNG, JPEG, WebP, or GIF')
|
||||
expect(textAt(result.content, 1)).toContain('not canonical base64')
|
||||
expect(textAt(result.content, 2)).toContain('not canonical base64')
|
||||
})
|
||||
|
||||
it('does not admit images for a route without declared image input', async () => {
|
||||
const rich = await mountRichRegistry()
|
||||
const client = createMockClient(
|
||||
[{ name: 'img', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, rich.ctx, defaultOpts, new Map())
|
||||
const result = await rich.ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('text-route'),
|
||||
name: 'mcp__srv__img',
|
||||
arguments: {},
|
||||
agent: agentOn('text') as never,
|
||||
})
|
||||
|
||||
expect(rich.attachments.saved).toEqual([])
|
||||
expect(textAt(result.content)).toContain('does not declare image input')
|
||||
})
|
||||
|
||||
it('refuses images when the exact route is missing, unverifiable, or canceled', async () => {
|
||||
const rich = await mountRichRegistry()
|
||||
const client = createMockClient(
|
||||
[{ name: 'img', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] },
|
||||
)
|
||||
await syncTools(client as never, rich.ctx, defaultOpts, new Map())
|
||||
|
||||
const noProvider = await rich.ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('no-provider'),
|
||||
name: 'mcp__srv__img',
|
||||
arguments: {},
|
||||
agent: { options: { model: 'vision' }, session: { requestHeader: () => undefined } } as never,
|
||||
})
|
||||
expect(textAt(noProvider.content)).toContain('route could not be resolved')
|
||||
|
||||
const noModel = await rich.ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('no-model'),
|
||||
name: 'mcp__srv__img',
|
||||
arguments: {},
|
||||
agent: { options: { provider: 'visual' }, session: { requestHeader: () => undefined } } as never,
|
||||
})
|
||||
expect(textAt(noModel.content)).toContain('route could not be resolved')
|
||||
|
||||
const noLlmCtx = await mountRegistry()
|
||||
await noLlmCtx.plugin(RecordingAttachmentStore)
|
||||
await syncTools(client as never, noLlmCtx, defaultOpts, new Map())
|
||||
const noLlm = await noLlmCtx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('no-llm'),
|
||||
name: 'mcp__srv__img',
|
||||
arguments: {},
|
||||
agent: agentOn() as never,
|
||||
})
|
||||
expect(textAt(noLlm.content)).toContain('route could not be resolved')
|
||||
|
||||
vi.spyOn(rich.ctx.llm, 'resolveModelInfo').mockRejectedValueOnce(new Error('catalog down'))
|
||||
const unverified = await rich.ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('unverified'),
|
||||
name: 'mcp__srv__img',
|
||||
arguments: {},
|
||||
agent: agentOn() as never,
|
||||
})
|
||||
expect(textAt(unverified.content)).toContain('route could not be verified')
|
||||
|
||||
vi.spyOn(rich.ctx.llm, 'resolveModelInfo').mockResolvedValueOnce({
|
||||
provider: 'visual', id: 'vision', name: 'vision',
|
||||
})
|
||||
const unknown = await rich.ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('unknown-modalities'),
|
||||
name: 'mcp__srv__img',
|
||||
arguments: {},
|
||||
agent: agentOn() as never,
|
||||
})
|
||||
expect(textAt(unknown.content)).toContain('does not declare image input')
|
||||
|
||||
const controller = new AbortController()
|
||||
vi.spyOn(rich.ctx.llm, 'resolveModelInfo').mockImplementationOnce(async (provider, model) => {
|
||||
controller.abort(new Error('stop'))
|
||||
return { provider, id: model, name: model, inputModalities: ['text', 'image'] }
|
||||
})
|
||||
const canceled = await rich.ctx.tools.execute({
|
||||
signal: controller.signal,
|
||||
callId: CallId('canceled'),
|
||||
name: 'mcp__srv__img',
|
||||
arguments: {},
|
||||
agent: agentOn() as never,
|
||||
})
|
||||
expect(canceled.isError).toBe(true)
|
||||
expect(canceled.content[0]).toEqual({ type: 'text', text: 'Error: tool call aborted' })
|
||||
expect(rich.attachments.saved).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses images when attachment storage rejects the admitted batch', async () => {
|
||||
const rich = await mountRichRegistry()
|
||||
vi.spyOn(rich.attachments, 'saveImages').mockRejectedValueOnce(new Error('disk full'))
|
||||
const client = createMockClient(
|
||||
[{ name: 'img', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, rich.ctx, defaultOpts, new Map())
|
||||
const result = await rich.ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('store-rejected'),
|
||||
name: 'mcp__srv__img',
|
||||
arguments: {},
|
||||
agent: agentOn() as never,
|
||||
})
|
||||
|
||||
expect(textAt(result.content)).toContain('durable image storage rejected the result')
|
||||
})
|
||||
|
||||
it('lets post-execute replacement win over a prepared image projection', async () => {
|
||||
const rich = await mountRichRegistry()
|
||||
rich.ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
|
||||
kind: 'accept',
|
||||
content: [{ type: 'text', text: 'policy replacement' }],
|
||||
}))
|
||||
const client = createMockClient(
|
||||
[{ name: 'img', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, rich.ctx, defaultOpts, new Map())
|
||||
const result = await rich.ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('replaced'),
|
||||
name: 'mcp__srv__img',
|
||||
arguments: {},
|
||||
agent: agentOn() as never,
|
||||
})
|
||||
|
||||
expect(rich.attachments.saved).toHaveLength(1)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'policy replacement' }])
|
||||
})
|
||||
|
||||
it('lets post-execute value replacement and blocking discard prepared projections', async () => {
|
||||
const valueRich = await mountRichRegistry()
|
||||
valueRich.ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
|
||||
kind: 'accept',
|
||||
value: { content: [{ type: 'text', text: 'value replacement' }] },
|
||||
}))
|
||||
const valueClient = createMockClient(
|
||||
[{ name: 'img', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] },
|
||||
)
|
||||
await syncTools(valueClient as never, valueRich.ctx, defaultOpts, new Map())
|
||||
const replaced = await valueRich.ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('value-replaced'),
|
||||
name: 'mcp__srv__img',
|
||||
arguments: {},
|
||||
agent: agentOn() as never,
|
||||
})
|
||||
expect(replaced.content).toEqual([{ type: 'text', text: 'value replacement' }])
|
||||
|
||||
const blockedRich = await mountRichRegistry()
|
||||
blockedRich.ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
|
||||
kind: 'block',
|
||||
feedback: [{ type: 'text', text: 'blocked by policy' }],
|
||||
}))
|
||||
const blockedClient = createMockClient(
|
||||
[{ name: 'img', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'image', mimeType: 'image/png', data: 'Ag==' }] },
|
||||
)
|
||||
await syncTools(blockedClient as never, blockedRich.ctx, defaultOpts, new Map())
|
||||
const blocked = await blockedRich.ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('blocked'),
|
||||
name: 'mcp__srv__img',
|
||||
arguments: {},
|
||||
agent: agentOn() as never,
|
||||
})
|
||||
expect(blocked.isError).toBe(true)
|
||||
expect(blocked.content).toEqual([{ type: 'text', text: 'blocked by policy' }])
|
||||
})
|
||||
|
||||
it('preserves primitive JSON MCP blocks while Native rendering marks them unsupported', async () => {
|
||||
const blocks = [42, null, ['nested']] satisfies JsonValue[]
|
||||
const client = createMockClient(
|
||||
@@ -396,7 +756,7 @@ describe('tool execution', () => {
|
||||
|
||||
expect(result.content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: '[unsupported content type: unknown]\n[unsupported content type: unknown]\n[unsupported content type: unknown]',
|
||||
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')
|
||||
expect(result.value).toEqual({ content: blocks })
|
||||
@@ -547,7 +907,7 @@ describe('tool execution edge cases', () => {
|
||||
ctx = await mountRegistry()
|
||||
})
|
||||
|
||||
it('handles audio content with placeholder', async () => {
|
||||
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' }] },
|
||||
@@ -556,10 +916,13 @@ describe('tool execution edge cases', () => {
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '[audio: audio/mp3, content discarded]' })
|
||||
expect(result.content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: '[audio result unsupported: audio/mp3; raw audio data remains available to programmatic callers]',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles resource content with placeholder', async () => {
|
||||
it('reports unsupported embedded resources without discarding the raw block', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'res_tool', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'resource' }] },
|
||||
@@ -568,19 +931,36 @@ describe('tool execution edge cases', () => {
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__res_tool', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' })
|
||||
expect(result.content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: '[embedded resource unsupported; raw resource data remains available to programmatic callers]',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles resource_link content with placeholder', async () => {
|
||||
it('preserves resource-link name and URI in the model projection', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'link_tool', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'resource_link' }] },
|
||||
{ content: [{ type: 'resource_link', name: 'Design', uri: 'https://example.test/design' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__link_tool', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' })
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'Resource link: Design (https://example.test/design)' })
|
||||
})
|
||||
|
||||
it('diagnoses an incomplete resource link', async () => {
|
||||
const client = createMockClient(
|
||||
[{ name: 'link_tool', inputSchema: { type: 'object' } }],
|
||||
{ content: [{ type: 'resource_link', name: 'Missing URI' }] },
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('missing-link'), name: 'mcp__srv__link_tool', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({
|
||||
type: 'text', text: '[resource link unavailable: the MCP block is missing its name or URI]',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles unknown content types', async () => {
|
||||
@@ -592,7 +972,7 @@ describe('tool execution edge cases', () => {
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported content type: video]' })
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported MCP content type: video]' })
|
||||
})
|
||||
|
||||
it('handles image with missing mimeType (buggy server)', async () => {
|
||||
@@ -604,7 +984,10 @@ describe('tool execution edge cases', () => {
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__img2', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '[image: unknown, content discarded]' })
|
||||
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]',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles audio with missing mimeType (buggy server)', async () => {
|
||||
@@ -616,7 +999,10 @@ describe('tool execution edge cases', () => {
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '[audio: unknown, content discarded]' })
|
||||
expect(result.content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: '[audio result unsupported: unknown media type; raw audio data remains available to programmatic callers]',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles text block with missing text (buggy server)', async () => {
|
||||
@@ -628,7 +1014,7 @@ describe('tool execution edge cases', () => {
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__notext', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' })
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no model-visible content)' })
|
||||
})
|
||||
|
||||
it('handles empty content array', async () => {
|
||||
@@ -640,7 +1026,7 @@ describe('tool execution edge cases', () => {
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} })
|
||||
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' })
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no model-visible content)' })
|
||||
})
|
||||
|
||||
|
||||
@@ -678,7 +1064,10 @@ describe('tool execution edge cases', () => {
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__err_notext', arguments: {} })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'Error: [image: image/png, content discarded]' })
|
||||
expect(result.content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Error: [image unavailable: image/png; this result was not admitted to durable model context; raw image data remains available to programmatic callers]',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user