fix(mcp): project image results through durable attachments

This commit is contained in:
Tianyi Cui
2026-08-11 15:36:03 +08:00
parent e00146be73
commit 49426cae02
12 changed files with 787 additions and 81 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/mcp/mcp-client/README.md
README.md: 266c3b7c2b38406800ae5dad1eb065c9dcbf50e6
README.zh.md: 1b5b5c523e0a477db30f97a748651dbe7e6992ea
README.md: f3bf65d90d72f9eb3271cbbbb8ae8c586a7fd082
README.zh.md: 1596ec72c28c4811eabb9f5cafe41cd7bdf1cf5e

View File

@@ -65,7 +65,7 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`
- Listens for `notifications/tools/list_changed` → re-syncs; a fetch-phase failure keeps the previous generation registered, while a registration conflict rolls back the attempted generation and leaves no tools from that server.
- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server.
- Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`.
- Native/model rendering keeps the existing text projection: text blocks join with newlines while image, audio, resource, and unsupported blocks become placeholders.
- Native/model rendering preserves MCP block order. Text-like runs join with newlines; resource links keep their name and URI as text; supported images become durable core image blocks only when `ctx.attachments` is mounted and the exact calling model route explicitly declares image input. The whole image batch is decoded and admitted before any member is saved. A malformed/refused image batch, audio, embedded resources, and unsupported blocks become explicit diagnostic text rather than disappearing.
- On disconnect/crash: the supervisor restarts the original server config with exponential backoff (`reconnect.initialDelayMs` doubling up to `reconnect.maxDelayMs`) and re-runs discovery on success — the recovered generation replaces the previous one, so tools neither duplicate nor leak. During the outage the last good generation stays registered; calls against it fail until recovery.
- Reconnection is budgeted per outage: after `reconnect.maxAttempts` consecutive failures the server's tools are unregistered and reconnection stops until an HMR reload or Host restart. A connection that survives past `maxDelayMs` resets the budget, so an occasionally-crashing server recovers indefinitely while a crash-looping one — even with briefly successful connects — still exhausts the cap instead of restarting forever.
- Reconnect states are user-visible in logs: reconnecting (warn, with attempt count and delay), recovered (info), final failure and disabled-loss (error). Disposal cancels any pending reconnect. With `reconnect.enabled: false`, a lost connection keeps tools registered but failing until a reload — the manual-recovery behavior.
@@ -75,6 +75,8 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`
| Service | Usage |
|---|---|
| `ctx.tools` | Register/unregister MCP tools |
| `ctx.attachments` | Optionally validate and persist image result batches before model projection |
| `ctx.llm` | Optionally prove the exact calling route explicitly supports image input |
## Model Experience
@@ -96,11 +98,11 @@ Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync
#### What the model sees
The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained Native text result; image, audio, resource, and unsupported blocks become short placeholders there. Their full JSON blocks and optional structured content remain in the execution-local canonical value, and MCP `isError` rejects the call through the registry's error path.
The public tool name and JSON arguments remain in assistant history. The execution-local canonical value always retains the complete JSON MCP blocks and optional structured content for programmatic and Code Mode callers. In Native context, supported image blocks are durably projected beside text in their original order after exact route-capability proof; Code Mode additionally ferries that settled rich projection through the outer `run_code` result without changing the canonical binding value. Refused images, audio, embedded resources, resource links, and unknown blocks remain visible as bounded text diagnostics, and MCP `isError` rejects the call before image persistence.
#### Token effect
Arguments and mapped text are retained until compaction. Binary and resource payloads are discarded rather than added to context.
Arguments, mapped text, and durable image references are retained until compaction. Inline MCP base64 stays only in the execution-local canonical value and is never copied into a session event; the provider reads verified bytes from the attachment store. Audio and embedded-resource payloads stay out of model context.
#### KV Cache effect
@@ -111,5 +113,5 @@ Append-only; newly visible content follows the reusable request prefix and does
- **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumer and are deferred.
- **Startup timeout is inherited from the MCP SDK** — DSH does not yet expose a connection/discovery timeout. Each initialize or paginated `tools/list` request uses the SDK's 60-second default, so an unresponsive server or cursor chain can delay both activation and teardown while the initial synchronization settles.
- **Reconnect triggers on transport close** — a crashed stdio child fires it; Streamable HTTP failures surface per request and through the SDK transport's own SSE-stream recovery, so an unreachable HTTP server is retried per call rather than respawned by the supervisor.
- **Native non-text rendering is lossy** — image, audio, and resource payloads become placeholders in model context even though the execution-local canonical value preserves their JSON blocks. Richer Native multimedia projection is deferred.
- **Image is the only durable rich-result bridge** — PNG, JPEG, WebP, and GIF can enter Native context after exact capability proof. Audio and embedded-resource payloads remain execution-local with explicit diagnostics, while resource links preserve only their name and URI as text.
- **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset.

View File

@@ -65,7 +65,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
- 监听 `notifications/tools/list_changed` → 重新同步;获取阶段失败时保留上一世代的注册,注册冲突则会回滚本次尝试的世代,并且不保留该服务器的任何工具。
- 工具执行:`client.callTool({ name: rawName, arguments }, { signal })`,支持超时 + 中止;公开名称绝不会发给服务器。
- 规范成功值是 `{ content: JsonValue[], structuredContent? }`;完整的 JSON MCP 块会保留给编程调用方。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇会回退为不受约束的 `JsonValue`
- Native模型渲染保留现有文本投影:文本块以换行连接,图片、音频、资源和不受支持的块会变成占位符
- Native模型渲染保留 MCP 块顺序。文本类连续块以换行连接;资源链接以文本保留名称和 URI只有挂载 `ctx.attachments` 且确切调用模型路由明确声明支持图片输入时,受支持的图片才会成为持久核心图片块。整个图片批次会先完成解码与准入,再保存任一成员。格式错误或被拒绝的图片批次、音频、嵌入资源和不受支持的块会成为明确诊断文本,而不会消失
- 断开崩溃时supervisor 以指数退避(`reconnect.initialDelayMs` 逐次翻倍,上限 `reconnect.maxDelayMs`)重启原始服务器配置,成功后重新执行发现——恢复的世代会替换前一个,因此工具既不会重复也不会泄漏。中断期间最后一个正常世代保持注册;针对它的调用在恢复前会失败。
- 重连按中断预算控制:连续失败达到 `reconnect.maxAttempts` 次后,该服务器的工具会被注销,重连停止,直到 HMR 重载或重启 Host。连接存活超过 `maxDelayMs` 会重置预算,因此偶尔崩溃的服务器可以无限恢复,而崩溃循环的服务器——即使短暂连接成功——仍会耗尽上限而非永远重启。
- 重连状态在日志中对用户可见reconnectingwarn含尝试次数和延迟、recoveredinfo、最终失败和 disabled-losserror。dispose资源释放会取消任何待执行的重连。设置 `reconnect.enabled: false` 时,连接丢失后工具保持注册但调用失败,直到重载——即手动恢复行为。
@@ -75,6 +75,8 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
| 服务 | 用途 |
|---|---|
| `ctx.tools` | 注册/注销 MCP 工具 |
| `ctx.attachments` | 可选;在模型投影前校验并持久保存图片结果批次 |
| `ctx.llm` | 可选;证明确切调用路由明确支持图片输入 |
## 模型体验
@@ -96,11 +98,11 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
#### 模型看到的内容
公开工具名称和 JSON 参数会保留在 assistant 历史中。文本结果块会以换行连接为一个保留的 Native 文本结果;图片、音频、资源和不受支持的块在其中变为简短占位符。它们的完整 JSON 块及可选结构化内容保留在执行局部的规范值中MCP `isError`通过注册表的错误路径拒绝调用。
公开工具名称和 JSON 参数会保留在 assistant 历史中。执行局部的规范值始终为程序化调用方和 Code Mode 保留完整 JSON MCP 块及可选结构化内容。在 Native 上下文中受支持的图片块会在确切路由能力得到证明后按原始顺序与文本一起持久投影Code Mode 还会经外层 `run_code` 结果转运这份已经结算的丰富投影,而不改变规范绑定值。被拒绝的图片、音频、嵌入资源、资源链接和未知块会继续以有界文本诊断可见MCP `isError`在持久化图片前拒绝调用。
#### Token 影响
参数映射后的文本会保留到压缩compaction发生时。二进制与资源载荷会被丢弃,而不会加入上下文。
参数映射后的文本和持久图片引用会保留到压缩compaction发生时。内联 MCP base64 只存在于执行局部的规范值中,绝不会复制进会话事件;提供方会从附件存储读取经过校验的字节。音频和嵌入资源载荷仍不会进入模型上下文。
#### KV Cache 影响
@@ -111,5 +113,5 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
- **只桥接 MCP 的工具能力**:资源和提示词没有 harness 消费接口,暂缓实现。
- **启动超时继承自 MCP SDK**DSH 尚未公开连接/发现超时。每次 initialize 请求或分页 `tools/list` 请求都使用 SDK 默认的 60 秒,因此在初始同步完成期间,无响应的 server 或 cursor chain 可能同时延迟激活与 teardown。
- **重连在传输关闭时触发**:崩溃的 stdio 子进程会触发重连Streamable HTTP 失败通过每次请求以及 SDK 传输自身的 SSEServer-Sent Events流恢复机制暴露因此不可达的 HTTP 服务器会按调用重试,而非由 supervisor 重新 spawn。
- **Native 非文本渲染有损**:图片、音频与资源载荷在模型上下文中会变成占位符,即使执行局部的规范值保留了其 JSON 块。更丰富的 Native 多媒体投影暂缓实现
- **图片是唯一的持久丰富结果桥接**PNG、JPEG、WebP 和 GIF 可以在确切能力得到证明后进入 Native 上下文。音频和嵌入资源载荷仍只存在于执行局部,并配有明确诊断;资源链接只以文本保留名称和 URI
- **不强制执行不受支持的 MCP 输出 schema**:已声明 schema 使用 harness 子集之外的词汇时,`structuredContent` 会回退到 `JsonValue`

View File

@@ -32,6 +32,7 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
@@ -45,6 +46,8 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-attachment-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",

View File

@@ -13,11 +13,14 @@
*/
import { createHash } from 'node:crypto'
import { isDeepStrictEqual } from 'node:util'
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import type { Context } from '@deepseek-ai/cordis'
import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { ToolDefinition, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools'
import type { JsonSchemaNode, JsonValue } from '@deepseek-ai/dsh-tools'
@@ -53,6 +56,17 @@ const HASH_LENGTH = 12
/** Raw result record: the bridge owns JSON-value validation after transport. */
const RawCallToolResultSchema = z.record(z.string(), z.unknown())
/** Raster formats supported by the durable attachment vocabulary. */
const IMAGE_MEDIA_TYPES: readonly ImageMediaType[] = [
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
]
/** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */
const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
/** List without mutating the SDK's per-page output-validator cache. */
function listToolsUncached(client: Client, cursor?: string) {
return client.request(
@@ -143,13 +157,17 @@ export async function syncTools(
`mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`,
)
}
definitions.set(publicName, {
name: publicName,
description: tool.description ?? '',
parameters: tool.inputSchema,
output: createOutput(tool.name, supportedOutputSchema(tool.outputSchema)),
execute: createExecutor(client, tool.name, tool.execution?.taskSupport === 'required', opts),
})
definitions.set(publicName, createDefinition(
client,
ctx,
publicName,
tool.name,
tool.description ?? '',
tool.inputSchema,
supportedOutputSchema(tool.outputSchema),
tool.execution?.taskSupport === 'required',
opts,
))
}
cursor = response.nextCursor
} while (cursor)
@@ -183,6 +201,19 @@ interface McpContentBlock {
type: string
text?: string
mimeType?: string
data?: string
name?: string
uri?: string
}
/** Async rich projection staged for one exact ToolRegistry execution. */
interface PreparedProjection {
/** Canonical MCP value returned by execute before registry materialization. */
value: McpResult
/** Synchronous output.render projection expected before finalization. */
fallback: ContentBlock[]
/** Image-enriched or explicit-refusal projection prepared during execute. */
content: ContentBlock[]
}
/** Keep a supported advertised schema; unsupported MCP vocabulary falls back to JsonValue. */
@@ -196,6 +227,49 @@ function supportedOutputSchema(candidate: unknown): JsonSchemaNode | undefined {
}
}
/**
* Build one generation-local tool definition and its execution-local rich projections.
* @param client - connected MCP client used for calls.
* @param ctx - plugin context carrying optional attachment and model services.
* @param publicName - registry-qualified public tool name.
* @param rawName - MCP wire tool name.
* @param description - model-facing tool description.
* @param parameters - MCP input schema.
* @param structuredSchema - supported structured-output schema, when advertised.
* @param taskRequired - whether this MCP tool requires unsupported task execution.
* @param opts - bridge timeout and namespace options.
* @returns a complete ToolRegistry definition.
*/
function createDefinition(
client: Client,
ctx: Context,
publicName: string,
rawName: string,
description: string,
parameters: Record<string, unknown>,
structuredSchema: JsonSchemaNode | undefined,
taskRequired: boolean,
opts: ToolBridgeOptions,
): ToolDefinition {
const projections = new WeakMap<ToolExecution, PreparedProjection>()
return {
name: publicName,
description,
parameters,
output: createOutput(rawName, structuredSchema),
execute: createExecutor(client, ctx, rawName, taskRequired, opts, projections),
finalizeContent(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>) {
const projection = projections.get(exec)
if (projection === undefined) return undefined
projections.delete(exec)
if (result.isError) return undefined
if (!isDeepStrictEqual(result.value, projection.value)) return undefined
if (!isDeepStrictEqual(result.content, projection.fallback)) return undefined
return projection.content
},
}
}
/** Build the canonical result schema and existing Native text projection. */
function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefined): ToolDefinition['output'] {
return {
@@ -208,7 +282,7 @@ function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefi
required: structuredSchema === undefined ? ['content'] : ['content', 'structuredContent'],
additionalProperties: false,
},
render(_args, value) {
render(_args: unknown, value: JsonValue) {
const result = value as unknown as McpResult
return [{ type: 'text', text: extractText(result.content, rawName) }]
},
@@ -227,9 +301,11 @@ function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefi
*/
function createExecutor(
client: Client,
ctx: Context,
rawName: string,
taskRequired: boolean,
opts: ToolBridgeOptions,
projections: WeakMap<ToolExecution, PreparedProjection>,
): ToolDefinition['execute'] {
return async (args: unknown, exec: ToolExecution) => {
if (taskRequired) {
@@ -268,12 +344,141 @@ function createExecutor(
throw new Error(text)
}
return {
const value: McpResult = {
content,
...result.structuredContent !== undefined
? { structuredContent: result.structuredContent as JsonValue }
: {},
}
if (containsImage(content)) {
const fallback: ContentBlock[] = [{ type: 'text', text: extractText(content, rawName) }]
const projected = await prepareImageProjection(ctx, exec, content, rawName)
projections.set(exec, { value, fallback, content: projected })
}
return value
}
}
/** Whether an untrusted MCP content array contains a declared image block. */
function containsImage(content: JsonValue[]): boolean {
return content.some(value => isRecord(value) && value.type === 'image')
}
/** Narrow one JSON value to a string-keyed object. */
function isRecord(value: JsonValue): value is { [key: string]: JsonValue } {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Narrow a declared MIME string to the durable image vocabulary. */
function isImageMediaType(value: string): value is ImageMediaType {
return IMAGE_MEDIA_TYPES.includes(value as ImageMediaType)
}
/** Decode one untrusted MCP image block without accepting base64 aliases. */
function decodeImage(block: McpContentBlock): SaveImageAttachment {
if (block.mimeType === undefined || !isImageMediaType(block.mimeType)) {
throw new Error('the declared media type is not PNG, JPEG, WebP, or GIF')
}
if (block.data === undefined || !CANONICAL_BASE64.test(block.data)) {
throw new Error('the image data is not canonical base64')
}
const data = Buffer.from(block.data, 'base64')
if (data.toString('base64') !== block.data) {
throw new Error('the image data is not canonical base64')
}
return { data, mediaType: block.mimeType }
}
/**
* Resolve the active model route and durable store for an image-bearing result.
* @param ctx - plugin context with optional services.
* @param exec - exact tool execution whose agent supplies the latest route.
* @returns the attachment store after exact positive image-capability proof.
*/
async function resolveImageAdmission(ctx: Context, exec: ToolExecution): Promise<AttachmentStore> {
const attachments = ctx.get('attachments')
if (attachments === undefined) throw new Error('no attachment store is mounted')
const routed = exec.agent?.session.requestHeader()?.config
const provider = routed?.provider ?? exec.agent?.options.provider
const model = routed?.model ?? exec.agent?.options.model
const llm = ctx.get('llm')
if (provider === undefined || model === undefined || llm === undefined) {
throw new Error('the current model route could not be resolved')
}
let info: Awaited<ReturnType<typeof llm.resolveModelInfo>>
try {
info = await llm.resolveModelInfo(provider, model, exec.signal)
} catch {
throw new Error('the current model route could not be verified')
}
if (info.inputModalities === undefined || !info.inputModalities.includes('image')) {
throw new Error(`model "${model}" does not declare image input`)
}
if (exec.signal.aborted) throw new Error('the tool call was canceled before image storage')
return attachments
}
/** Stable diagnostic text for an image block that was not admitted. */
function imageDiagnostic(block: McpContentBlock, reason: string): string {
const mediaType = block.mimeType ?? 'unknown media type'
return `[image unavailable: ${mediaType}; ${reason}; raw image data remains available to programmatic callers]`
}
/**
* Decode, preflight, and durably save one MCP result's ordered image batch.
* Any refusal projects every image as text while retaining the canonical raw
* value for programmatic callers.
*/
async function prepareImageProjection(
ctx: Context,
exec: ToolExecution,
content: JsonValue[],
toolName: string,
): Promise<ContentBlock[]> {
const decoded: SaveImageAttachment[] = []
const validationErrors = new Map<number, string>()
const imageIndexes: number[] = []
for (const [index, value] of content.entries()) {
if (!isRecord(value) || value.type !== 'image') continue
imageIndexes.push(index)
try {
decoded.push(decodeImage(value as unknown as McpContentBlock))
} catch (error: unknown) {
// decodeImage owns every throw above and always produces Error.
validationErrors.set(index, (error as Error).message)
}
}
if (validationErrors.size > 0) {
return projectContent(content, toolName, (block, index) => ({
type: 'text',
text: imageDiagnostic(
block,
validationErrors.get(index) ?? 'another image in the same result was invalid',
),
}))
}
let attachments: AttachmentStore
try {
attachments = await resolveImageAdmission(ctx, exec)
} catch (error: unknown) {
// resolveImageAdmission contains provider failures and throws Error only.
const reason = (error as Error).message
return projectContent(content, toolName, block => ({ type: 'text', text: imageDiagnostic(block, reason) }))
}
try {
const refs = await attachments.saveImages(decoded)
const byIndex = new Map(imageIndexes.map((index, offset) => [index, refs[offset] as ImageAttachmentRef] as const))
return projectContent(content, toolName, (_block, index) => ({
type: 'image',
attachment: byIndex.get(index) as ImageAttachmentRef,
}))
} catch {
return projectContent(content, toolName, block => ({
type: 'text',
text: imageDiagnostic(block, 'durable image storage rejected the result'),
}))
}
}
@@ -286,32 +491,65 @@ function createExecutor(
* guarded with fallbacks because this is a network trust boundary.
*/
function extractText(mcpContent: JsonValue[], toolName: string): string {
const parts: string[] = []
const content = projectContent(mcpContent, toolName)
// The default image projector below also returns text, so this local call
// cannot produce a core image block.
return content.map(block => (block as Extract<ContentBlock, { type: 'text' }>).text).join('\n')
}
for (const value of mcpContent) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
parts.push('[unsupported content type: unknown]')
/**
* Project ordered MCP blocks into the core content vocabulary.
* Text-like runs are newline-coalesced; admitted images split those runs at
* their original position.
*/
function projectContent(
mcpContent: JsonValue[],
toolName: string,
image: (block: McpContentBlock, index: number) => ContentBlock = block => ({
type: 'text',
text: imageDiagnostic(block, 'this result was not admitted to durable model context'),
}),
): ContentBlock[] {
const projected: ContentBlock[] = []
const text: string[] = []
const flushText = (): void => {
if (text.length === 0) return
projected.push({ type: 'text', text: text.splice(0).join('\n') })
}
for (const [index, value] of mcpContent.entries()) {
if (!isRecord(value)) {
text.push('[unsupported MCP content block: expected an object]')
continue
}
const block = value as unknown as McpContentBlock
switch (block.type) {
case 'text':
if (block.text !== undefined) parts.push(block.text)
if (block.text !== undefined) text.push(block.text)
break
case 'image':
parts.push(`[image: ${block.mimeType ?? 'unknown'}, content discarded]`)
flushText()
projected.push(image(block, index))
break
case 'resource_link':
if (block.name === undefined || block.uri === undefined) {
text.push('[resource link unavailable: the MCP block is missing its name or URI]')
} else {
text.push(`Resource link: ${block.name} (${block.uri})`)
}
break
case 'audio':
parts.push(`[audio: ${block.mimeType ?? 'unknown'}, content discarded]`)
text.push(`[audio result unsupported: ${block.mimeType ?? 'unknown media type'}; raw audio data remains available to programmatic callers]`)
break
case 'resource':
case 'resource_link':
parts.push('[resource: content discarded]')
text.push('[embedded resource unsupported; raw resource data remains available to programmatic callers]')
break
default:
parts.push(`[unsupported content type: ${block.type}]`)
text.push(`[unsupported MCP content type: ${block.type}]`)
}
}
return parts.join('\n') || `(${toolName} returned no text content)`
flushText()
return projected.length > 0
? projected
: [{ type: 'text', text: `(${toolName} returned no model-visible content)` }]
}

View File

@@ -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.' },
],
}))

View File

@@ -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;')
})
})

View File

@@ -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]',
})
})