fix(mcp): project image results through durable attachments

This commit is contained in:
Tianyi Cui
2026-08-12 17:48:17 +08:00
parent e00146be73
commit 49426cae02
12 changed files with 787 additions and 81 deletions
@@ -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 .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md
2026-07-07-mcp-client-plugin.md: 24828586645778294ad7bdafc6fd13a9bfb6745f
2026-07-07-mcp-client-plugin.zh.md: 8f58c9359ca8447717cc353f97f1333370fa6fda
2026-07-07-mcp-client-plugin.md: 9d1e12e23ee1140f8827612cc5156185959ccd4f
2026-07-07-mcp-client-plugin.zh.md: 4d41be9d96e64d5e12a318afe087e44df21b5009
@@ -141,11 +141,11 @@ Tools are never silently skipped; which tools are available never depends on plu
A unified `execute` handler for all tools from one MCP server:
1. Resolve `rawName` (the executor closes over it) and call `client.callTool({ name: rawName, arguments }, { signal: exec.signal })` with the configured timeout — the public name is never sent to the server.
2. Map the result:
- Multiple `text` content blocks → join with `'\n'` into a single `TextBlock` (required: `flattenText` uses `join('')` without separator, so multiple blocks would lose inter-block boundaries).
- `image` content blocks → discard with a `ctx.logger.warn` (the harness has no image content block type; [drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md)).
- `isError: true` → map to the harness `isError` result path (`{ content: [...], isError: true }`).
3. Cancellation: `exec.signal` (from the agent loop's cancel) is passed through to the MCP SDK's `callTool`, which sends `$/cancelRequest` to the server.
2. Preserve canonical success as `{ content: JsonValue[], structuredContent? }`; complete MCP JSON blocks remain the programmatic/Code Mode value. `isError: true` throws before any image persistence so the registry owns the failure path.
3. Prepare a separate ordered Native projection. Text runs join with `'\n'`; resource links preserve name and URI as text; audio, embedded resources, malformed blocks, and unknown types become explicit diagnostics. If any image exists, the bridge strictly decodes the complete batch, resolves the calling agent's latest exact route, requires an attachment store plus explicit model image input, and delegates all-member validation and ordered persistence to `AttachmentStore.saveImages()`. Any decode, capability, or storage refusal renders every image as diagnostic text and returns no partial references.
4. Keep `output.render` synchronous and pure. The executor stages its richer projection in a generation-local `WeakMap` keyed by the exact execution; `finalizeContent` installs it only when the registry's post-execute result still has the original canonical value and fallback content. A policy block, value replacement, or content replacement remains authoritative, and a re-sync cannot let an older generation consume new execution state.
5. Code Mode receives the untouched canonical value. Its generic dispatch bridge defers a successful final content sequence containing an image through the outer `run_code` result, so MCP requires no private parent-token special case.
6. Cancellation: `exec.signal` (from the agent loop's cancel) is passed through to the MCP SDK's `callTool`, exact-model lookup, and the pre-storage gate.
### Subprocess environment (stdio transport)
@@ -189,13 +189,25 @@ Rejected. The remote name is untrusted, non-unique across deployments, and chang
Rejected. `flattenText()` in the DeepSeek serializer uses `join('')` (no separator) when flattening `ContentBlock[]` to wire format. Multiple text blocks would silently lose inter-block boundaries — a correctness bug. All existing tools return a single TextBlock; the MCP bridge follows suit.
### Replace the canonical MCP result with core `ContentBlock[]`
Rejected. Programmatic callers need protocol-complete MCP blocks and `structuredContent`, while Native consumers need durable core images rather than base64. One canonical protocol value plus a separate projection preserves both contracts.
### Add a generic RichContent service or perform I/O in `output.render`
Rejected. Core already owns the role-neutral content vocabulary, and a second service would duplicate its logging and ordering contracts. `output.render` is pure, synchronous, and replayable, so attachment I/O belongs in async execution with an exact finalization handoff.
### Let each image-returning tool special-case Code Mode parents
Rejected. That couples leaf tools to composite-tool internals and misses future rich tools. The generic Code Mode bridge observes the final post-policy content and forwards image-bearing results uniformly.
## Testing
Coverage is named per tier; each behavior lives at the cheapest tier that can express it.
- **Unit** (`tests/mcp-client.spec.ts`, `tests/apply.spec.ts`, mocked MCP SDK): the `publicToolName` algorithm (clean, normalize, truncate-and-hash, determinism, distinct-identity separation), raw-vs-public wire discipline, cross-server and native-tool coexistence, duplicate-`serverName` load failure and reservation release, invalid-tool-list rejection, generation swap/rollback, failed-re-sync retention, result mapping, cancellation, config schema validation. 100% per-file coverage gates the package.
- **E2E** (`tests/mcp-client.e2e.ts`, keyless): the real MCP protocol against the in-repo fixture server, `@modelcontextprotocol/server-everything`, and `@modelcontextprotocol/server-filesystem` over stdio, and against an in-process `StreamableHTTPServerTransport` server over Streamable HTTP — discovery under the namespace, dotted-name normalization end to end, execution round-trips, duplicate-`serverName` rejection, disposal.
- **Snapshot**: deliberately none. MCP tools introduce no new presentation shape — they register as raw `ToolDefinition`s and UI consumers use the generic-card fallback already pinned by their presentation suites. Adding an MCP server to a runnable snapshot composition would mutate its pinned system-prompt fixture and make every replay depend on spawning an external MCP server process for no new behavior. If a later change gives MCP tools their own render intent, that change names its snapshot coverage then.
- **Unit** (`tests/mcp-client.spec.ts`, `tests/apply.spec.ts`, mocked MCP SDK): the `publicToolName` algorithm (clean, normalize, truncate-and-hash, determinism, distinct-identity separation), raw-vs-public wire discipline, cross-server and native-tool coexistence, duplicate-`serverName` load failure and reservation release, invalid-tool-list rejection, generation swap/rollback, failed-re-sync retention, lossless canonical results, mixed rich ordering, atomic malformed batches, exact capability/store refusal, explicit non-image diagnostics, post-execute policy precedence, cancellation, and config schema validation. 100% per-file coverage gates the package.
- **E2E** (`tests/mcp-client.e2e.ts`, keyless): the real MCP protocol against the in-repo fixture server, `@modelcontextprotocol/server-everything`, and `@modelcontextprotocol/server-filesystem` over stdio, and against an in-process `StreamableHTTPServerTransport` server over Streamable HTTP — discovery under the namespace, dotted-name normalization end to end, execution round-trips, durable image save/read with base64 retained only in the canonical value, explicit refusal without an image route, duplicate-`serverName` rejection, and disposal.
- **Snapshot**: the assembled ACP example owns the transport-visible inline-image transcript and the Code Mode image-forwarding transcript; package E2E owns the real MCP wire because the runnable snapshot must stay keyless and deterministic rather than spawning third-party server packages. MCP tool cards still use the generic-card fallback and require no package-specific UI snapshot.
## Consequences
@@ -206,3 +218,4 @@ Coverage is named per tier; each behavior lives at the cheapest tier that can ex
- **Tool schema quality**: MCP servers may expose poorly-described tools (vague descriptions, incomplete JSON schemas). The harness passes them through as-is — garbage-in-garbage-out; that is the server author's responsibility, not the bridge's.
- **Stdio process management**: a misbehaving MCP server that ignores signals could wedge dispose. The Cordis fiber disposal has bounded quiescence; a stuck transport eventually times out at the framework level.
- Crash recovery is automatic within the [reconnect budget](2026-08-06-mcp-client-auto-reconnect.md); manual reload remains the path after exhaustion or with `reconnect.enabled: false`.
- Image payloads can enter model context only through the shared durable attachment store and an exact positive route capability. Audio and embedded-resource payloads remain execution-local with explicit diagnostics.
@@ -141,11 +141,11 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp
为来自同一个 MCP 服务器的所有工具提供统一的 `execute` 处理器:
1. 解析 `rawName`(执行器闭包持有它),以配置的超时时间调用 `client.callTool({ name: rawName, arguments }, { signal: exec.signal })`——公开名称永远不发送给服务器。
2. 映射结果:
- 多个 `text` 内容块 → 以 `'\n'` 连接为单个 `TextBlock`(之所以必须这样做,是因为 `flattenText` 使用无分隔符的 `join('')`,多个内容块会丢失块间边界)
- `image` 内容块 → 丢弃并 `ctx.logger.warn`(harness 没有图片内容块类型;[删除图片 Agent Note](../simplification/2026-07-04-drop-image-content-block.md)
- `isError: true` → 映射到 harness 的 `isError` 结果路径(`{ content: [...], isError: true }`
3. 取消:`exec.signal`(来自 agent loop(智能体循环)的取消)透传给 MCP SDK 的 `callTool`,后者向服务器发送 `$/cancelRequest`
2. 把规范成功值保留为 `{ content: JsonValue[], structuredContent? }`;完整 MCP JSON 块仍是程序化调用/Code Mode 值。`isError: true` 会在持久化任何图片前抛出,使失败路径归注册表所有。
3. 另行准备有序 Native 投影。连续文本块以 `'\n'` 连接;资源链接以文本保留名称和 URI;音频、嵌入资源、格式错误的块和未知类型成为明确诊断。只要存在图片,桥接层就严格解码完整批次,解析调用 agent 的最新确切路由,要求附件存储以及模型明确支持图片输入,再把全成员校验和有序持久化委托给 `AttachmentStore.saveImages()`。任何解码、能力或存储拒绝都会把全部图片渲染为诊断文本,且不返回部分引用
4. 保持 `output.render` 同步且纯净。执行器把更丰富的投影暂存在按同步世代创建、以确切执行为键的 `WeakMap` 中;只有注册表的 post-execute 结果仍保留原规范值和兜底内容时,`finalizeContent` 才安装该投影。策略阻止、值替换或内容替换仍具有权威性,重新同步也无法让旧世代消费新执行状态
5. Code Mode 接收未改动的规范值。其通用分发桥接层会把包含图片的成功最终内容序列经外层 `run_code` 结果延后,因此 MCP 无需私有父 token 特例
6. 取消:`exec.signal`(来自 agent loop 的取消)透传给 MCP SDK 的 `callTool`、确切模型查询和存储前门禁
### 子进程环境(stdio 传输)
@@ -189,13 +189,25 @@ v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 ha
否决。DeepSeek 序列化器中的 `flattenText()` 在将 `ContentBlock[]` 扁平化为协议格式(wire format)时使用 `join('')`(无分隔符)。多个 text 块会静默丢失块间边界——这是正确性缺陷。所有现有工具返回单个 TextBlock;MCP 桥接遵循同一做法。
### 用核心 `ContentBlock[]` 替换规范 MCP 结果
不予采用。程序化调用方需要协议完整的 MCP 块和 `structuredContent`,Native 消费方则需要持久核心图片而不是 base64。一份规范协议值加一份独立投影可以同时保留两项契约。
### 添加通用 RichContent 服务,或在 `output.render` 中执行 I/O
不予采用。核心已经拥有角色无关的内容词汇,第二套服务会重复其日志与顺序契约。`output.render` 必须纯净、同步且可回放,因此附件 I/O 属于异步执行,再经确切的最终化交接安装结果。
### 让每个返回图片的工具分别特殊处理 Code Mode 父调用
不予采用。这会把叶子工具与组合工具内部机制耦合,并漏掉未来丰富工具。通用 Code Mode 桥接层观察最终 post-policy 内容,统一转发含图片结果。
## 测试
覆盖范围按层级列出;每项行为都放在能够表达它的最低成本层级。
- **单元测试**`tests/mcp-client.spec.ts``tests/apply.spec.ts`mock MCP SDK):`publicToolName` 算法(干净名称、规范化、截断加 hash、确定性、不同标识的分离)、raw 与 public 的协议纪律、跨服务器与原生工具共存、重复 `serverName` 加载失败与预留释放、无效工具列表拒绝、注册代切换/回滚、重新同步失败时保留上一代注册、结果映射、取消、配置 schema 校验。100% 逐文件覆盖率门禁约束该包。
- **E2E**`tests/mcp-client.e2e.ts`,无需密钥):使用真实 MCP 协议对接仓库内的 fixture(测试前置数据)服务器、`@modelcontextprotocol/server-everything``@modelcontextprotocol/server-filesystem`stdio 传输),以及进程内 `StreamableHTTPServerTransport` 服务器(Streamable HTTP 传输)——命名空间下的发现、带点号名称的端到端规范化、执行往返、重复 `serverName` 拒绝dispose。
- **快照**刻意不做。MCP 工具不引入新的展示形态——它们以原始 `ToolDefinition` 注册,UI 消费方使用各自展示测试套件已固定的通用卡片兜底。将 MCP 服务器添加到某个可运行快照组合会改变其已固定的系统提示词 fixture,且使每次回放依赖于 spawn 外部 MCP 服务器进程,而新增行为为零。如果后续变更为 MCP 工具引入专属渲染意图,该变更届时自行声明快照覆盖
- **单元测试**`tests/mcp-client.spec.ts``tests/apply.spec.ts`mock MCP SDK):`publicToolName` 算法(干净名称、规范化、截断加 hash、确定性、不同标识的分离)、raw 与 public 的协议纪律、跨服务器与原生工具共存、重复 `serverName` 加载失败与预留释放、无效工具列表拒绝、注册代切换/回滚、重新同步失败时保留上一代注册、无损规范结果、丰富内容混合顺序、格式错误批次原子性、确切能力/存储拒绝、明确的非图片诊断、post-execute 策略优先级、取消,以及配置 schema 校验。100% 逐文件覆盖率门禁约束该包。
- **E2E**`tests/mcp-client.e2e.ts`,无需密钥):使用真实 MCP 协议对接仓库内的 fixture(测试前置数据)服务器、`@modelcontextprotocol/server-everything``@modelcontextprotocol/server-filesystem`stdio 传输),以及进程内 `StreamableHTTPServerTransport` 服务器(Streamable HTTP 传输)——命名空间下的发现、带点号名称的端到端规范化、执行往返、持久图片保存/读取且 base64 只保留在规范值中、缺少图片路由时明确拒绝、重复 `serverName` 拒绝,以及 dispose。
- **快照**组装后的 ACP 示例负责传输可见的内联图片 transcript 与 Code Mode 图片转发 transcript;包 E2E 负责真实 MCP 协议,因为可运行快照必须保持无密钥且确定,而不是 spawn 第三方服务器包。MCP 工具卡片仍使用通用卡片兜底,无需包专属 UI 快照
## 后果
@@ -206,3 +218,4 @@ v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 ha
- **工具 schema 质量**:MCP 服务器可能暴露描述不佳的工具(模糊的描述、不完整的 JSON Schema)。harness 原样透传——垃圾进垃圾出;这是服务器作者的责任,不是桥接的。
- **Stdio 进程管理**:行为异常的 MCP 服务器如果忽略信号,可能卡住 dispose。Cordis fiber 的 dispose 具有有界的完全停稳过程;卡住的传输层最终会在框架层面超时。
- 崩溃恢复在[重连预算](2026-08-06-mcp-client-auto-reconnect.md)内自动进行;耗尽后或配置 `reconnect.enabled: false` 时回退为手动重新加载。
- 图片载荷只有通过共享持久附件存储和确切正向路由能力,才能进入模型上下文。音频与嵌入资源载荷仍只存在于执行局部,并附带明确诊断。
+2 -2
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
+6 -4
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.
+6 -4
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` 会重置预算,因此偶尔崩溃的服务器可以无限恢复,而崩溃循环的服务器——即使短暂连接成功——仍会耗尽上限而非永远重启。
- 重连状态在日志中对用户可见:reconnecting(warn,含尝试次数和延迟)、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`
+3
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:^",
+260 -22
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)` }]
}
@@ -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.' },
],
}))
+50 -10
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;')
})
})
+409 -20
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]',
})
})
+6
View File
@@ -5521,6 +5521,12 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/dsh-attachment':
specifier: workspace:^
version: link:../../attachment/attachment
'@deepseek-ai/dsh-attachment-local':
specifier: workspace:^
version: link:../../attachment/attachment-local
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants