Bläddra i källkod

feat(mcp): negotiate modern protocols with the official SDK

Preserve the verified SDK layer tree from c8b248b480 before rebasing its complete net change.
Tianyi Cui 5 dagar sedan
förälder
incheckning
489c3ac715
34 ändrade filer med 570 tillägg och 602 borttagningar
  1. 2 2
      .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml
  2. 4 4
      .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md
  3. 4 4
      .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-09-12-mcp-sdk-protocol-negotiation.i18n.yaml
  5. 29 0
      .agents/notes/implemented/feature/2026-09-12-mcp-sdk-protocol-negotiation.md
  6. 29 0
      .agents/notes/implemented/feature/2026-09-12-mcp-sdk-protocol-negotiation.zh.md
  7. 3 0
      THIRD_PARTY_NOTICES.md
  8. 1 1
      apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt
  9. 6 6
      apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts
  10. 27 4
      packages/experimental/computer-use-cua-driver-mcp/tests/composition.spec.ts
  11. 5 0
      packages/experimental/computer-use-cua-driver-mcp/tests/fixtures/driver.mjs
  12. 2 2
      packages/experimental/computer-use-cua-driver-native/src/index.ts
  13. 2 2
      packages/mcp/mcp-client/README.i18n.yaml
  14. 8 8
      packages/mcp/mcp-client/README.md
  15. 8 8
      packages/mcp/mcp-client/README.zh.md
  16. 5 3
      packages/mcp/mcp-client/package.json
  17. 21 19
      packages/mcp/mcp-client/src/connection.ts
  18. 39 99
      packages/mcp/mcp-client/src/tools.ts
  19. 4 8
      packages/mcp/mcp-client/src/transport.ts
  20. 20 77
      packages/mcp/mcp-client/tests/apply.spec.ts
  21. 1 1
      packages/mcp/mcp-client/tests/egress.spec.ts
  22. 62 60
      packages/mcp/mcp-client/tests/fixture-server.ts
  23. 15 0
      packages/mcp/mcp-client/tests/fixtures/pagination-limit-server.ts
  24. 2 2
      packages/mcp/mcp-client/tests/fixtures/pagination-limit.patch.yml
  25. 0 18
      packages/mcp/mcp-client/tests/fixtures/repeated-cursor-server.ts
  26. 18 16
      packages/mcp/mcp-client/tests/http-fixture.ts
  27. 18 25
      packages/mcp/mcp-client/tests/mcp-client.e2e.ts
  28. 35 179
      packages/mcp/mcp-client/tests/mcp-client.spec.ts
  29. 80 0
      packages/mcp/mcp-client/tests/protocol.spec.ts
  30. 18 27
      packages/mcp/mcp-client/tests/reconnect.spec.ts
  31. 30 21
      packages/mcp/mcp-client/tests/tool-definition.spec.ts
  32. 60 6
      pnpm-lock.yaml
  33. 1 0
      scripts/smoke-python-runtime.py
  34. 5 0
      snapshots/session/computer-use-cua-driver-mcp/workspace/driver.mjs

+ 2 - 2
.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml

@@ -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: 91c1bf428973eae7935e9c2a14dda7028a734313
-2026-07-07-mcp-client-plugin.zh.md: c0c2dbae5b854734240f8035edfc8d0b5a33408f
+2026-07-07-mcp-client-plugin.md: ac0815b7f30fd4f8ed4eee67584d2c54461b63de
+2026-07-07-mcp-client-plugin.zh.md: 245b181d7dfd2bb1aa3ddb39caed661b12343897

+ 4 - 4
.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md

@@ -18,7 +18,7 @@ A single package `@deepseek-ai/dsh-mcp-client` at `packages/mcp/mcp-client/`. No
 
 ### SDK
 
-Use the official [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) (`Client`, `StdioClientTransport`, `StreamableHTTPClientTransport`). The harness does not implement its own JSON-RPC — consistent with how ACP delegates to `@agentclientprotocol/sdk`.
+Use the official [`@modelcontextprotocol/client`](https://github.com/modelcontextprotocol/typescript-sdk) (`Client`, `StdioClientTransport`, `StreamableHTTPClientTransport`). The [protocol adoption note](2026-09-12-mcp-sdk-protocol-negotiation.md) owns negotiation and SDK validation. The harness does not implement JSON-RPC framing.
 
 ### Scope
 
@@ -128,7 +128,7 @@ MCP guarantees tool-name uniqueness only [within one server](https://modelcontex
 - A server listing the same tool name twice is an invalid tool list: the sync throws and the previous generation stays registered.
 - A registry conflict during the swap can only mean a foreign tool squats on this server's `mcp__<serverName>__` namespace: the partial generation is rolled back (zero tools from this server) and the error is logged loudly.
 
-Tools are never silently skipped; which tools are available never depends on plugin load order.
+The SDK admits protocol-valid tools, including its modern HTTP header-declaration checks. Registration order does not decide ownership of an admitted name.
 
 ### Naming invariants
 
@@ -144,7 +144,7 @@ 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. Preserve canonical success as `{ content: JsonValue[], structuredContent? }`; complete MCP JSON blocks remain the programmatic/PTC 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.
+3. Prepare a separate ordered Native projection. Text runs join with `'\n'`; resource links preserve name and URI as text; audio and embedded resources become explicit diagnostics. The SDK rejects malformed wire results. 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. PTC 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.
@@ -216,7 +216,7 @@ Coverage is named per tier; each behavior lives at the cheapest tier that can ex
 - A `cordis.yml` entry per MCP server is the entire integration cost: `serverName: filesystem` + a stdio command (or a Streamable HTTP URL) puts `mcp__filesystem__read_file` in the model's tool list, callable, with the raw `read_file` on the wire.
 - Public names are part of session history and permission/configuration APIs; tests pin the naming algorithm, and changing it after release is a breaking change.
 - The `mcp__<serverName>__` qualifier costs tokens on every name. Accepted: descriptions and JSON schemas dominate tool-definition tokens, and the qualifier buys stable identity, collision isolation, and MCP-wide policy shapes (`mcp__*`, `mcp__github__*`).
-- **MCP SDK stability**: the `@modelcontextprotocol/sdk` is still evolving; breaking changes require updating the bridge. The version is pinned, and the SDK is widely adopted (Claude Desktop, Cursor, VS Code) so breaking changes are unlikely to be silent.
+- **MCP SDK stability**: the `@modelcontextprotocol/client` is still evolving; breaking changes require updating the bridge. The version is pinned, and the SDK is widely adopted (Claude Desktop, Cursor, VS Code) so breaking changes are unlikely to be silent.
 - **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](../../archived/feature/2026-08-06-mcp-client-auto-reconnect.md); manual reload remains the path after exhaustion or with `reconnect.enabled: false`.

+ 4 - 4
.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md

@@ -18,7 +18,7 @@ harness 此前无法消费 MCP(Model Context Protocol)生态中的工具。M
 
 ### SDK
 
-使用官方 [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk)(`Client`、`StdioClientTransport`、`StreamableHTTPClientTransport`)。harness 不自行实现 JSON-RPC,与 ACP 委托给 `@agentclientprotocol/sdk` 的做法一致
+使用官方 [`@modelcontextprotocol/client`](https://github.com/modelcontextprotocol/typescript-sdk)(`Client`、`StdioClientTransport`、`StreamableHTTPClientTransport`)。[协议采用记录](2026-09-12-mcp-sdk-protocol-negotiation.zh.md) 负责协商与 SDK 校验。Harness 不实现 JSON-RPC 帧处理
 
 ### 范围
 
@@ -128,7 +128,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp
 - 服务器列出重复的工具名属于无效工具列表:同步抛出异常,上一代注册保持不变。
 - 替换期间的注册表冲突只可能意味着外部工具占据了该服务器的 `mcp__<serverName>__` 命名空间:部分代注册被回滚(该服务器零工具),并以醒目日志记录错误。
 
-工具永远不会被静默跳过;哪些工具可用永远不取决于插件加载顺序
+SDK 接受符合协议的工具,并执行现代 HTTP header 声明检查。注册顺序不决定已接受名称的归属
 
 ### 命名不变式
 
@@ -144,7 +144,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp
 
 1. 解析 `rawName`(执行器闭包持有它),以配置的超时时间调用 `client.callTool({ name: rawName, arguments }, { signal: exec.signal })`——公开名称永远不发送给服务器。
 2. 把规范成功值保留为 `{ content: JsonValue[], structuredContent? }`;完整 MCP JSON 块仍是程序化调用/PTC mode 值。`isError: true` 会在持久化任何图片前抛出,使失败路径归注册表所有。
-3. 另行准备有序 Native 投影。连续文本块以 `'\n'` 连接;资源链接以文本保留名称和 URI;音频、嵌入资源、格式错误的块和未知类型成为明确诊断。只要存在图片,桥接层就严格解码完整批次,解析调用 agent 的最新确切路由,要求附件存储以及模型明确支持图片输入,再把全成员校验和有序持久化委托给 `AttachmentStore.saveImages()`。任何解码、能力或存储拒绝都会把全部图片渲染为诊断文本,且不返回部分引用。
+3. 另行准备有序 Native 投影。连续文本块以 `'\n'` 连接;资源链接以文本保留名称和 URI;音频和嵌入资源成为明确诊断。SDK 拒绝格式错误的协议结果。只要存在图片,桥接层就严格解码完整批次,解析调用 agent 的最新确切路由,要求附件存储以及模型明确支持图片输入,再把全成员校验和有序持久化委托给 `AttachmentStore.saveImages()`。任何解码、能力或存储拒绝都会把全部图片渲染为诊断文本,且不返回部分引用。
 4. 保持 `output.render` 同步且纯净。执行器把更丰富的投影暂存在按同步世代创建、以确切执行为键的 `WeakMap` 中;只有注册表的 post-execute 结果仍保留原规范值和兜底内容时,`finalizeContent` 才安装该投影。策略阻止、值替换或内容替换仍具有权威性,重新同步也无法让旧世代消费新执行状态。
 5. PTC mode 接收未改动的规范值。其通用分发桥接层会把包含图片的成功最终内容序列经外层 `run_code` 结果延后,因此 MCP 无需私有父 token 特例。
 6. 取消:`exec.signal`(来自 agent loop 的取消)透传给 MCP SDK 的 `callTool`、确切模型查询和存储前门禁。
@@ -216,7 +216,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp
 - 每个 MCP 服务器只需 `cordis.yml` 中的一条配置即完成集成:`serverName: filesystem` 加一条 stdio 命令(或一个 Streamable HTTP URL),就能将 `mcp__filesystem__read_file` 放入模型的工具列表,可调用,协议上使用原始的 `read_file`。
 - 公开名称是会话历史和权限/配置 API 的一部分;测试固定了命名算法,发布后变更即为破坏性变更。
 - `mcp__<serverName>__` 限定符在每个名称上消耗 token。已接受:描述和 JSON Schema 在工具定义 token 中占主导,而限定符换来了稳定标识、冲突隔离和 MCP 全局策略匹配模式(`mcp__*`、`mcp__github__*`)。
-- **MCP SDK 稳定性**:`@modelcontextprotocol/sdk` 仍在演进中;破坏性变更需要更新桥接。版本已固定,且该 SDK 被广泛采用(Claude Desktop、Cursor、VS Code),因此破坏性变更不太可能悄然发生。
+- **MCP SDK 稳定性**:`@modelcontextprotocol/client` 仍在演进中;破坏性变更需要更新桥接。版本已固定,且该 SDK 被广泛采用(Claude Desktop、Cursor、VS Code),因此破坏性变更不太可能悄然发生。
 - **工具 schema 质量**:MCP 服务器可能暴露描述不佳的工具(模糊的描述、不完整的 JSON Schema)。harness 原样透传——垃圾进垃圾出;这是服务器作者的责任,不是桥接的。
 - **Stdio 进程管理**:行为异常的 MCP 服务器如果忽略信号,可能卡住 dispose。Cordis fiber 的 dispose 具有有界的完全停稳过程;卡住的传输层最终会在框架层面超时。
 - 崩溃恢复在[重连预算](../../archived/feature/2026-08-06-mcp-client-auto-reconnect.md)内自动进行;耗尽后或配置 `reconnect.enabled: false` 时回退为手动重新加载。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-12-mcp-sdk-protocol-negotiation.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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-09-12-mcp-sdk-protocol-negotiation.md
+2026-09-12-mcp-sdk-protocol-negotiation.md: aea629b5641d9be7cc14a1174875546b1dd34e71
+2026-09-12-mcp-sdk-protocol-negotiation.zh.md: 4b25b3a30769214fa33da6a69ca01a98605b2fc0

+ 29 - 0
.agents/notes/implemented/feature/2026-09-12-mcp-sdk-protocol-negotiation.md

@@ -0,0 +1,29 @@
+# Agent Note: MCP SDK protocol negotiation
+
+Status: implemented
+
+English | [中文](2026-09-12-mcp-sdk-protocol-negotiation.zh.md)
+
+## Problem
+
+MCP servers use different protocol revisions. A tool bridge that implements discovery and execution around an older SDK can omit modern request headers, subscription setup, or protocol validation.
+
+## Decision
+
+`dsh-mcp-client` uses the official TypeScript client 2.0.0 with automatic protocol negotiation. The SDK owns modern discovery and legacy initialization, transport-specific negotiation, list-change subscriptions, pagination, request headers, cancellation, and output validation. The bridge uses high-level `listTools` and `callTool`, passing the complete discovered definition to each call.
+
+The bridge retains server-qualified names, atomic registration, and durable image admission. Servers without a tools capability publish no tools. Discovery failures preserve the last successful registration; duplicate names still reject the new generation. Malformed cursor chains follow SDK behavior, including stopping at a repeated cursor; the bridge does not add a parallel pagination implementation.
+
+The shared result adapter passes the original `ToolExecution` to each provider callback, preserving its Agent identity and cancellation. Native Cua Driver results use the SDK's public spec-type validation before canonical projection; the adapter does not accept a separate permissive result format.
+
+The [tool bridge note](2026-07-07-mcp-client-plugin.md) retains the independent naming, scope, and image decisions. This note changes its SDK realization without replacing those decisions.
+
+## Alternatives considered
+
+**Keep raw requests for compatibility.** This preserves permissive malformed-result handling but bypasses SDK-owned modern headers and schema validation. Protocol-valid outputs take priority over legacy `toolResult` substitutes.
+
+**Implement modern protocol behavior in the bridge.** This duplicates maintained SDK behavior and creates an additional compatibility implementation.
+
+## Consequences
+
+Stdio negotiation may start a disposable probe process. The SDK bounds discovery with its page limit, and malformed results fail before projection. Valid text, canonical JSON, image admission, cancellation, and registration ownership remain bridge contracts. Elicitation, resources, prompts, and task execution remain unsupported.

+ 29 - 0
.agents/notes/implemented/feature/2026-09-12-mcp-sdk-protocol-negotiation.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: MCP SDK 协议协商
+
+Status: implemented
+
+[English](2026-09-12-mcp-sdk-protocol-negotiation.md) | 中文
+
+## 问题
+
+MCP 服务器使用不同协议版本。围绕旧版 SDK 实现发现与执行的工具桥接器可能遗漏现代请求 header、订阅建立或协议校验。
+
+## 决策
+
+`dsh-mcp-client` 使用官方 TypeScript client 2.0.0 并自动协商协议。SDK 负责现代发现与旧版初始化、按传输方式协商、列表变化订阅、分页、请求 header、取消与输出校验。桥接器使用高层 `listTools` 和 `callTool`,并向每次调用传递完整的发现定义。
+
+桥接器保留服务器限定名称、原子注册和持久图片接纳。未声明 tools 能力的服务器不发布工具。发现失败保留上一次成功注册;重复名称仍拒绝新注册代。格式错误的游标链遵循 SDK 行为,包括遇到重复游标时停止;桥接器不增加并行的分页实现。
+
+共享结果适配函数向每个提供方回调传递原始 `ToolExecution`,保留其 Agent 身份和取消信号。原生 Cua Driver 的结果会先通过 SDK 公开的规范类型校验,再生成规范值投影;适配函数不接受另一种宽松结果格式。
+
+[工具桥接记录](2026-07-07-mcp-client-plugin.zh.md) 保留独立的命名、作用域与图片决策。本记录调整其 SDK 实现,不替代这些决策。
+
+## 考虑过的替代方案
+
+**为兼容性保留原始请求。** 这保留了宽松的错误结果处理,却绕过 SDK 负责的现代 header 与 schema 校验。有效协议输出优先于旧式 `toolResult` 替代结果。
+
+**在桥接器内实现现代协议行为。** 这会重复已维护的 SDK 行为,并增加一份兼容性实现。
+
+## 影响
+
+Stdio 协商可能启动可释放的探测进程。SDK 通过页数上限约束发现,格式错误的结果会在投影前失败。有效文本、规范 JSON、图片接纳、取消与注册归属仍是桥接器的约定。Elicitation、资源、提示模板及任务执行仍不受支持。

+ 3 - 0
THIRD_PARTY_NOTICES.md

@@ -42,6 +42,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`@lexical/plain-text`](https://github.com/facebook/lexical) | MIT |
 | [`@lexical/text`](https://github.com/facebook/lexical) | MIT |
 | [`@lexical/utils`](https://github.com/facebook/lexical) | MIT |
+| [`@modelcontextprotocol/client`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT |
 | [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT |
 | [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | MIT |
 | [`@octokit/webhooks`](https://github.com/octokit/webhooks.js) | MIT |
@@ -146,6 +147,8 @@ External packages **directly declared** for development, tests, types, or toolin
 | [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT |
 | [`@electron/notarize`](https://github.com/electron/notarize) | MIT |
 | [`@lexical/headless`](https://github.com/facebook/lexical) | MIT |
+| [`@modelcontextprotocol/node`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT |
+| [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT |
 | [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 |
 | [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 |
 | [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT |

+ 1 - 1
apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt

@@ -1 +1 @@
-Error: mcp-client(pagination-cycle): server repeated a tools/list continuation cursor — invalid tool list
+SdkError: tools/list: exceeded listMaxPages (64); server pagination did not terminate

+ 6 - 6
apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts

@@ -5,13 +5,13 @@ import { expect, it } from 'vitest'
 import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
 
 const fixtureRoot = new URL('../../../../../../packages/mcp/mcp-client/tests/fixtures/', import.meta.url)
-const configPath = fileURLToPath(new URL('repeated-cursor.patch.yml', fixtureRoot))
+const configPath = fileURLToPath(new URL('pagination-limit.patch.yml', fixtureRoot))
 const headlessOverlayPath = fileURLToPath(new URL('./fixtures/headless-profile.patch.yml', import.meta.url))
 const expectedPath = fileURLToPath(new URL('./expected/mcp-pagination/stderr-cause.txt', import.meta.url))
 
-it('warns about a repeated MCP discovery cursor and completes the headless task', async () => {
+it('warns when MCP discovery exceeds the SDK page limit and completes the headless task', async () => {
   const { stdout, stderr } = await runLoaderSmoke({
-    label: 'MCP discovery pagination cycle',
+    label: 'MCP discovery pagination limit',
     tempDirPrefix: 'dsh-mcp-pagination-',
     binScript: fileURLToPath(new URL('../../../../src/bin.ts', import.meta.url)),
     libBinScript: fileURLToPath(new URL('../../../../lib/bin.js', import.meta.url)),
@@ -24,15 +24,15 @@ it('warns about a repeated MCP discovery cursor and completes the headless task'
     ],
     tsconfigPath: fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url)),
     env: {
-      DSH_MCP_PAGINATION_FIXTURE: fileURLToPath(new URL('repeated-cursor-server.ts', fixtureRoot)),
+      DSH_MCP_PAGINATION_FIXTURE: fileURLToPath(new URL('pagination-limit-server.ts', fixtureRoot)),
       DSH_PERMISSION_MODE: 'danger-full-access',
       DSH_TELEMETRY_DISABLED: '1',
     },
   })
   expect(stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n')
   expect(stderr).toContain('dsh: warning: 1 entry did not activate')
-  expect(stderr).toContain('mcp-pagination-cycle (@deepseek-ai/dsh-mcp-client)')
+  expect(stderr).toContain('mcp-pagination-limit (@deepseek-ai/dsh-mcp-client)')
   expect(stderr).toContain('initial connection or tool synchronization failed')
-  const cause = stderr.split('\n').find(line => line.startsWith('Error: mcp-client(pagination-cycle):'))
+  const cause = stderr.split('\n').find(line => line.includes('exceeded listMaxPages'))
   await expect(`${cause}\n`).toMatchFileSnapshot(expectedPath)
 }, LOADER_SMOKE_TEST_TIMEOUT_MS)

+ 27 - 4
packages/experimental/computer-use-cua-driver-mcp/tests/composition.spec.ts

@@ -107,9 +107,27 @@ async function driverEvents(root: string): Promise<{ event: string; pid: number;
   return (await readFile(join(root, 'driver.ndjson'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as { event: string; pid: number })
 }
 
+function expectProcessExited(pid: number): void {
+  expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
+}
+
+async function expectActiveDriver(root: string, connectionCount: number): Promise<void> {
+  const events = await driverEvents(root)
+  const starts = events.filter(event => event.event === 'start').map(event => event.pid)
+  const probes = events.filter(event => event.event === 'discover').map(event => event.pid)
+  const drivers = events.filter(event => event.event === 'initialize').map(event => event.pid)
+  expect(probes).toHaveLength(connectionCount)
+  expect(drivers).toHaveLength(connectionCount)
+  expect(starts).toHaveLength(connectionCount * 2)
+  expect(new Set(starts)).toEqual(new Set([...probes, ...drivers]))
+  for (const pid of [...probes, ...drivers.slice(0, -1)]) expectProcessExited(pid)
+  expect(() => process.kill(drivers[connectionCount - 1]!, 0)).not.toThrow()
+}
+
 describe('installed Cua Driver Loader composition', () => {
   it('keeps upstream schemas and stores screenshot history as durable images through a real Agent', async () => {
     const { ctx, root, model } = await load()
+    await expectActiveDriver(root, 1)
     const fibers = [...ctx.loader.entries()].flatMap(entry => entry.fiber === undefined ? [] : [entry.fiber])
     expect(fibers.every(fiber => fiber.state === FiberState.ACTIVE)).toBe(true)
     expect(ctx.computerUse.providerName).toBe('cua-driver-mcp')
@@ -142,7 +160,7 @@ describe('installed Cua Driver Loader composition', () => {
     expect(ctx.tools.get(TOOL)).toBeUndefined()
     expect(ctx.computerUse.providerName).toBeUndefined()
     const events = await driverEvents(root)
-    expect(events.filter(event => event.event === 'exit').map(event => event.pid)).toEqual(events.filter(event => event.event === 'start').map(event => event.pid))
+    for (const event of events.filter(event => event.event === 'start')) expectProcessExited(event.pid)
   })
 
   it('retains exclusive ownership when the external process disconnects and reconnects', async () => {
@@ -151,7 +169,7 @@ describe('installed Cua Driver Loader composition', () => {
     expect(ctx.computerUse.providerName).toBe('cua-driver-mcp')
     expect(() => ctx.computerUse.register(ComputerUseProviderName('replacement'))).toThrow('already registered')
     await vi.waitFor(async () => {
-      expect((await driverEvents(root)).filter(event => event.event === 'start')).toHaveLength(2)
+      expect((await driverEvents(root)).filter(event => event.event === 'initialize')).toHaveLength(2)
     }, { timeout: 10_000 })
     await vi.waitFor(async () => {
       const result = await ctx.tools.execute({ name: TOOL, arguments: { display: 2 }, callId: ToolCallId('reconnected'), signal: new AbortController().signal })
@@ -159,6 +177,9 @@ describe('installed Cua Driver Loader composition', () => {
       expect(result.content[0]).toEqual({ type: 'text', text: 'Display 2' })
     })
     expect(ctx.computerUse.providerName).toBe('cua-driver-mcp')
+    await expectActiveDriver(root, 2)
+    await ctx.fiber.dispose()
+    for (const event of (await driverEvents(root)).filter(event => event.event === 'start')) expectProcessExited(event.pid)
   })
 
   it('fails the Loader entry and releases ownership when installed driver initialization fails', async () => {
@@ -169,7 +190,9 @@ describe('installed Cua Driver Loader composition', () => {
     expect(ctx.computerUse.providerName).toBeUndefined()
     expect(ctx.tools.get(TOOL)).toBeUndefined()
     const events = await driverEvents(root)
-    expect(events.filter(event => event.event === 'start')).toHaveLength(1)
-    expect(events.filter(event => event.event === 'exit')).toHaveLength(1)
+    expect(events.filter(event => event.event === 'discover')).toHaveLength(1)
+    expect(events.filter(event => event.event === 'initialize')).toHaveLength(1)
+    expect(events.filter(event => event.event === 'start')).toHaveLength(2)
+    for (const event of events.filter(event => event.event === 'start')) expectProcessExited(event.pid)
   })
 })

+ 5 - 0
packages/experimental/computer-use-cua-driver-mcp/tests/fixtures/driver.mjs

@@ -19,7 +19,12 @@ lines.on('line', (line) => {
   }
   let result
   switch (request.method) {
+    case 'server/discover':
+      record('discover')
+      process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found' } }) + '\n')
+      return
     case 'initialize':
+      record('initialize')
       if (mode === 'fail') process.exit(1)
       result = { protocolVersion: request.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: 'cua-driver-fixture', version: '1.0.0' } }
       break

+ 2 - 2
packages/experimental/computer-use-cua-driver-native/src/index.ts

@@ -106,8 +106,8 @@ export async function apply(ctx: Context): Promise<void> {
         description: tool.description ?? '',
         inputSchema: tool.inputSchema,
         outputSchema: tool.outputSchema,
-        async call(args, signal) {
-          const combined = AbortSignal.any([signal, lifetime.signal])
+        async call(args, execution) {
+          const combined = AbortSignal.any([execution.signal, lifetime.signal])
           combined.throwIfAborted()
           const result = await activeDriver.callTool(tool.name, JSON.stringify(args), { signal: combined })
           combined.throwIfAborted()

+ 2 - 2
packages/mcp/mcp-client/README.i18n.yaml

@@ -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: b24584952c2cfb487e8c2173093b46fd4c835516
-README.zh.md: cffebb6918c17167e4ab86c11dee49d4a73597fa
+README.md: 85724ec89a06d6f8e3e6f4aa894f21e7be1b610f
+README.zh.md: 022de02922a2615408b6f2f69406c07aa22fe773

+ 8 - 8
packages/mcp/mcp-client/README.md

@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
 <a id="use-this-package"></a>
 ## Use this package
 
-Add `dsh-mcp-client` when the model should call tools from an external MCP server as if they were native. One configuration entry per server is the entire setup: give the server a short unique name and a transport, and its tools appear as `mcp__<serverName>__<tool>`. Choose stdio when the server runs as a local program and Streamable HTTP when it runs as a service. If you already use MCP tool servers from another client, the same server rows work here.
+Add `dsh-mcp-client` when the model should call tools from an external MCP server as if they were native. Give each server a unique name and transport. The official SDK selects the 2026-07-28 protocol when available and falls back to supported legacy revisions. Choose stdio for a local program and Streamable HTTP for a service; stdio negotiation starts a temporary probe process before the serving process.
 
 ### Minimal configuration
 
@@ -76,7 +76,7 @@ The model sees each tool under a stable server-qualified name: `mcp__<serverName
 - Two servers publishing the same tool name (for example `search`) coexist under their own namespaces.
 - Two entries using the same server name: the later one fails to load with a clear error.
 - A server that lists the same tool twice gets its tool list rejected as invalid, and the previous tool set stays active.
-- A repeated non-empty `tools/list` continuation cursor rejects that update immediately, including cycles through empty pages; the previous tool set stays active and later updates can still succeed.
+- The SDK owns discovery pagination and its page limit. A discovery failure preserves the previous tools; malformed cursor chains follow the SDK's behavior.
 - An update that conflicts with an already-registered tool name is rejected entirely — you never get a partial tool set from that server.
 
 ### Calling tools and reading results
@@ -119,17 +119,17 @@ This section explains the design decisions behind the bridge and points at the c
 | [`src/transport.ts`](src/transport.ts) | Transport factory: stdio spawn with scrubbed env, Streamable HTTP |
 | — | No runtime invariant companion is published; MCP generations contribute through the tool registry, but the bridge exposes no independent server-to-tool snapshot after an asynchronous resync. |
 
-The exported `createMcpToolDefinition(ctx, options)` adapts an upstream tool schema and raw-result callback to the same canonical values, errors, and durable image projection. Callers own registration, cancellation deadlines, and provider teardown. The native Cua Driver provider uses this adapter without opening an MCP transport.
+The exported `createMcpToolDefinition(ctx, options)` adapts an upstream tool schema and raw-result callback to the same canonical values, errors, and durable image projection. Each callback receives the exact `ToolExecution`, including its Agent and cancellation signal; SDK spec-type validation checks its result before projection. Callers own registration, cancellation deadlines, and provider teardown. The native Cua Driver provider uses this adapter without opening an MCP transport.
 
 ### Lifecycle and sync
 
 `apply` resolves the reconnect policy, reserves the `serverName` inside the current registration scope, starts the supervisor, and awaits the initial connection plus discovery. Independent Agent scopes may reuse the same namespace because their tools and transports are isolated; a duplicate inside one scope fails at load. The supervisor serializes every sync — initial, notification, and reconnect — through one queue so two syncs can never interleave their dispose-previous/register-next swap. Disposal cancels pending reconnects, closes the live client, waits for the in-flight attempt and queued syncs to quiesce, and unregisters the current generation.
 
-The supervisor listens for `notifications/tools/list_changed` and queues a re-sync; a fetch-phase failure keeps the previous generation registered, while a registration conflict rolls back the attempted generation. Each outage shares one attempt budget: after `maxAttempts` consecutive failures the tools are unregistered and reconnection stops, and a connection that stays up past `maxDelayMs` resets the budget.
+The SDK receives tool-list changes through legacy notifications or a modern subscription. The supervisor queues each re-sync; a fetch failure keeps the previous generation registered, while a registration conflict rolls back the attempted generation. Each outage shares one attempt budget: after `maxAttempts` consecutive failures the tools are unregistered and reconnection stops, and a connection that stays up past `maxDelayMs` resets the budget.
 
 ### Tool execution internals
 
-A tool call sends an uncached `tools/call` request carrying the raw MCP name, the JSON arguments, the abort signal, and the configured timeout; the public name is never sent to the server and never parsed back. Canonical success is `{ content: JsonValue[], structuredContent? }`, preserving the complete MCP JSON blocks for programmatic and PTC mode callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`. An MCP `isError` result throws before any image persistence, so the registry produces a failed tool result. Image batches are decoded and validated as a whole before any member is saved; any refusal projects every image as diagnostic text.
+A tool call uses the SDK with the raw name, complete tool definition, JSON arguments, abort signal, and configured timeout. The SDK owns protocol validation, advertised output-schema validation, and modern request headers. Canonical success is `{ content: JsonValue[], structuredContent? }`, preserving valid MCP JSON blocks for programmatic and PTC mode callers. An MCP `isError` result throws before image persistence. The bridge validates each image batch before saving it; a refusal projects every image as diagnostic text.
 
 ### Environment scrubbing (stdio)
 
@@ -159,7 +159,7 @@ Read these pages when the package-level contract is not enough. They move from t
 
 #### What the model sees
 
-After initial discovery succeeds, every advertised MCP tool appears as a native tool named `mcp__<serverName>__<rawName>` (or its deterministic normalized form) with the server-provided description and input schema. A successful re-sync — including the one after an automatic reconnect — replaces the generation; plugin disposal or an exhausted reconnect budget removes it.
+After discovery succeeds, SDK-admitted MCP tools appear as native tools named `mcp__<serverName>__<rawName>` (or their deterministic normalized form), with the server description and input schema. A re-sync replaces the generation; disposal or an exhausted reconnect budget removes it. A server without the tools capability connects with an empty tool set.
 
 #### Token effect
 
@@ -191,10 +191,10 @@ Append-only; newly visible content follows the reusable request prefix and does
 These limits describe what you cannot do with this plugin and when it needs operational attention. They are current package constraints, not a comparison with other MCP clients or a task backlog.
 
 - **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumer mechanism and are deferred.
-- **Startup and discovery timeouts are inherited from the MCP SDK** — the plugin exposes no connection or discovery timeout; each `initialize` and paginated `tools/list` request uses the SDK's 60-second request default, so an unresponsive server or cursor chain can delay both activation and teardown while the initial synchronization settles.
+- **Startup and discovery timeouts are inherited from the MCP SDK** — the plugin exposes no separate connection or discovery timeout. Negotiation and discovery use the SDK's 60-second request default; discovery also uses its page limit.
 - **Reconnect triggers on transport close** — a crashed stdio child fires it; Streamable HTTP failures surface per request through the SDK transport's own recovery, so an unreachable HTTP server is retried per call rather than respawned by the supervisor.
 - **Image is the only durable rich-result bridge** — PNG, JPEG, WebP, and GIF 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.
+- **Invalid protocol results or output schemas fail through the SDK** — the bridge does not accept legacy `toolResult` substitutes or bypass advertised schema validation.
 - **Task-required MCP tools are rejected at call time** — a tool that requires the task-based execution extension throws instead of bridging; the extension is not implemented.
 
 <a id="dev-note"></a>

+ 8 - 8
packages/mcp/mcp-client/README.zh.md

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-当模型需要把外部 MCP 服务器的工具当作原生工具调用时,添加 `dsh-mcp-client`。每台服务器一条配置项就是全部设置:给服务器一个简短的唯一名称和一种传输方式,它的工具就会以 `mcp__<serverName>__<tool>` 形式出现。服务器作为本地程序运行时选择 stdio,作为服务运行时选择 Streamable HTTP。如果你已经用其他客户端连接过 MCP 工具服务器,同样的配置行在这里也能用
+当模型需要像调用原生工具一样调用外部 MCP 服务器时,添加 `dsh-mcp-client`。为每台服务器指定唯一名称和传输方式。官方 SDK 优先选择可用的 2026-07-28 协议,并回退到支持的旧版协议。本地程序使用 stdio,远端服务使用 Streamable HTTP;stdio 协商会先启动临时探测进程,再启动实际服务进程
 
 ### 最小配置
 
@@ -76,7 +76,7 @@ kind: "package-reference"
 - 发布相同工具名称(例如 `search`)的两个服务器会在各自的 namespace 下共存。
 - 两条配置项使用相同的服务器名称时,后加载的一条会在加载时以明确错误失败。
 - 服务器在工具列表中两次列出同一工具时,其工具列表会被作为无效列表拒绝,上一组工具保持可用。
-- `tools/list` 返回重复的非空续传游标时会立即拒绝本次更新,包括经过空页的循环;上一组工具保持可用,后续更新仍可成功
+- SDK 负责发现分页及页数上限。发现失败会保留之前的工具;格式错误的游标链遵循 SDK 的处理行为
 - 工具更新与已有工具名称冲突时,该更新会被整体拒绝——绝不会得到该服务器的部分工具集。
 
 ### 调用工具与读取结果
@@ -119,17 +119,17 @@ kind: "package-reference"
 | [`src/transport.ts`](src/transport.ts) | 传输工厂:带清洗环境的 stdio spawn、Streamable HTTP |
 | — | 不发布运行时不变式伴生入口;MCP 世代会通过工具注册表发挥作用,但桥接在异步重新同步后不提供独立的服务器工具映射快照。 |
 
-导出的 `createMcpToolDefinition(ctx, options)` 将上游工具 schema 和原始结果回调适配到相同的规范值、错误和持久化图像投影。调用方负责注册、取消截止时间和提供方卸载。原生 Cua Driver 提供方使用此适配函数,无需打开 MCP 传输。
+导出的 `createMcpToolDefinition(ctx, options)` 将上游工具 schema 和原始结果回调适配到相同的规范值、错误和持久化图像投影。每次回调都收到原样的 `ToolExecution`,包括其 Agent 和取消信号;SDK 的规范类型校验会在投影前检查返回结果。调用方负责注册、取消截止时间和提供方卸载。原生 Cua Driver 提供方使用此适配函数,无需打开 MCP 传输。
 
 ### 生命周期与同步
 
 `apply` 解析重连策略、在当前注册作用域内预留 `serverName`、启动监督器,并等待初始连接加发现完成。独立 agent(智能体)作用域可以复用相同 namespace,因为其工具与传输彼此隔离;同一作用域内重复会在加载时失败。监督器把所有同步——初始、通知与重连——串行到同一条队列,因此两次同步绝不会交错执行各自的先 dispose 后注册交换。dispose 会取消待执行的重连、关闭活动客户端、等待进行中的尝试与排队同步完全停稳,然后注销当前世代。
 
-监督器监听 `notifications/tools/list_changed` 并排队一次重新同步;获取阶段失败时保留上一世代注册,注册冲突则回滚本次尝试的世代。每次中断共享一个尝试预算:连续失败达到 `maxAttempts` 次后工具被注销、重连停止;连接存活超过 `maxDelayMs` 会重置预算。
+SDK 通过旧版通知或现代协议订阅接收工具列表变化。监督器将每次重新同步排队;获取失败时保留之前的注册代,注册冲突则回滚本次尝试。每次故障共享一个尝试预算:连续失败达到 `maxAttempts` 后注销工具并停止重连;连接持续超过 `maxDelayMs` 则重置预算。
 
 ### 工具执行内部细节
 
-工具调用会发送一次未缓存的 `tools/call` 请求,携带原始 MCP 名称、JSON 参数、中止信号与配置的超时;公开名称绝不会发给服务器,也绝不会被解析还原。规范成功值是 `{ content: JsonValue[], structuredContent? }`,为程序化调用方与 PTC 模式调用方保留完整的 MCP JSON 块。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇回退为不受约束的 `JsonValue`。MCP 的 `isError` 结果会在任何图片持久化之前抛出,使注册表产生失败的工具结果。图片批次会先整体解码并校验,再保存任一成员;任何拒绝都会把每张图片投影为诊断文本。
+工具调用向 SDK 提供原始名称、完整工具定义、JSON 参数、取消信号及配置的超时。SDK 负责协议校验、已声明输出 schema 的校验和现代协议请求 header。成功结果规范值为 `{ content: JsonValue[], structuredContent? }`,为编程调用方及 PTC 模式保留有效的 MCP JSON 块。MCP `isError` 结果会在图片持久化前抛出。桥接器在保存前校验整批图片;拒绝时将每张图片投影为诊断文本。
 
 ### 环境清洗(stdio)
 
@@ -159,7 +159,7 @@ kind: "package-reference"
 
 #### 模型看到什么
 
-初始发现成功后,每个已声明的 MCP 工具都会显示为名为 `mcp__<serverName>__<rawName>`(或其确定性规范化形式)的原生工具,并携带服务器提供的描述与输入 schema。成功的重新同步——包括自动重连后的同步——会替换整个世代;对插件执行 dispose 或重连预算耗尽会移除该世代
+发现成功后,SDK 接受的 MCP 工具以原生工具名称 `mcp__<serverName>__<rawName>`(或其确定性规范化形式)出现,携带服务器描述和输入 schema。重新同步会替换注册代;释放或重连预算耗尽会移除工具。未声明 tools 能力的服务器以空工具集连接
 
 #### Token 影响
 
@@ -191,10 +191,10 @@ kind: "package-reference"
 这些限制说明你无法用本插件做什么、以及何时需要运维注意。它们是当前包约束,不是与其他 MCP 客户端的对比,也不是任务积压。
 
 - **只桥接 MCP 的工具能力**——资源与提示词没有 harness 消费机制,暂缓实现。
-- **启动与发现超时继承自 MCP SDK**——插件不暴露连接或发现超时;每次 `initialize` 与分页 `tools/list` 请求都使用 SDK 默认的 60 秒请求超时,因此无响应的服务器或 cursor chain 在初始同步完成期间可能同时延迟激活与 teardown
+- **启动与发现超时继承自 MCP SDK**——插件不暴露单独的连接或发现超时。协商与发现使用 SDK 默认的 60 秒请求超时;发现也使用 SDK 的页数上限
 - **重连在传输关闭时触发**——崩溃的 stdio 子进程会触发重连;Streamable HTTP 失败按请求经 SDK 传输自身的恢复机制暴露,因此不可达的 HTTP 服务器会按调用重试,而非由 supervisor 重新 spawn。
 - **图片是唯一的持久丰富结果桥接**——PNG、JPEG、WebP 与 GIF 在确切能力得到证明后进入 Native 上下文。音频与嵌入资源载荷仍只存在于执行局部并带明确诊断,资源链接只以文本保留名称与 URI。
-- **不强制执行不受支持的 MCP 输出 schema**——已声明 schema 使用 harness 子集之外的词汇时,`structuredContent` 回退为 `JsonValue`
+- **无效的协议结果或输出 schema 由 SDK 拒绝**——桥接器不接受旧式 `toolResult` 替代结果,也不绕过已声明的 schema 校验
 - **要求基于任务的 MCP 工具在调用时被拒绝**——要求使用基于任务的执行(task-based execution)扩展的工具会抛出异常而非被桥接;该扩展未实现。
 
 <a id="dev-note"></a>

+ 5 - 3
packages/mcp/mcp-client/package.json

@@ -36,9 +36,8 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "dependencies": {
-    "@modelcontextprotocol/sdk": "^1.12.0",
     "@deepseek-ai/schemastery": "workspace:^",
-    "zod": "^4.4.3"
+    "@modelcontextprotocol/client": "2.0.0"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-attachment": "workspace:^",
@@ -51,6 +50,9 @@
     "@modelcontextprotocol/server-everything": "^2026.7.4",
     "@modelcontextprotocol/server-filesystem": "^2026.7.4",
     "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-http-proxy": "workspace:^"
+    "@deepseek-ai/dsh-http-proxy": "workspace:^",
+    "@modelcontextprotocol/server": "2.0.0",
+    "@modelcontextprotocol/node": "2.0.0",
+    "zod": "^4.4.3"
   }
 }

+ 21 - 19
packages/mcp/mcp-client/src/connection.ts

@@ -15,8 +15,7 @@
  * @module
  */
 
-import { Client } from '@modelcontextprotocol/sdk/client/index.js'
-import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
+import { Client } from '@modelcontextprotocol/client'
 import type { Context } from '@deepseek-ai/cordis'
 import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
 import { createTransport } from './transport.ts'
@@ -237,7 +236,17 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe
   async function connectGeneration(startup: boolean): Promise<void> {
     const generation = new Client(
       { name: 'dsh-mcp-client', version: '0.0.1' },
-      { capabilities: {} },
+      {
+        capabilities: {},
+        versionNegotiation: { mode: 'auto' },
+        listChanged: {
+          tools: {
+            autoRefresh: false,
+            debounceMs: 0,
+            onChanged: () => { void refreshTools() },
+          },
+        },
+      },
     )
     const closed: PromiseWithResolvers<void> = Promise.withResolvers()
     let attemptSettled = false
@@ -252,22 +261,15 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe
       // established generation can transition down directly from this signal.
       if (attemptSettled) generationDown(generation)
     }
-    // Registered before connect so a list change during the initial sync is
-    // queued behind it rather than dropped.
-    generation.setNotificationHandler(
-      ToolListChangedNotificationSchema,
-      async () => {
-        if (!isCurrent(generation)) return
-        ctx.logger.info(`${label}: tool list changed, re-syncing`)
-        try {
-          await enqueueSync(generation)
-        } catch (error) {
-          // Fetch-phase failure: the previous generation is still registered
-          // and `disposers` still owns it — keep serving the last good list.
-          if (!disposed) ctx.logger.error(`${label}: tool re-sync failed: ${String(error)}`)
-        }
-      },
-    )
+    async function refreshTools(): Promise<void> {
+      if (!isCurrent(generation)) return
+      ctx.logger.info(`${label}: tool list changed, re-syncing`)
+      try {
+        await enqueueSync(generation)
+      } catch (error) {
+        if (!disposed) ctx.logger.error(`${label}: tool re-sync failed: ${String(error)}`)
+      }
+    }
     try {
       await generation.connect(createTransport(config))
       if (hasClosed()) {

+ 39 - 99
packages/mcp/mcp-client/src/tools.ts

@@ -14,9 +14,7 @@
 
 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 { specTypeSchemas, type Client, type ImageContent } from '@modelcontextprotocol/client'
 import type { Context } from '@deepseek-ai/cordis'
 import { isImageAdmissionError } from '@deepseek-ai/dsh-attachment'
 import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
@@ -55,9 +53,6 @@ const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g
 /** Hex chars of the SHA-256 identity hash appended on lossy normalization. */
 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',
@@ -69,32 +64,6 @@ const IMAGE_MEDIA_TYPES: readonly ImageMediaType[] = [
 /** 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(
-    { method: 'tools/list', ...cursor === undefined ? {} : { params: { cursor } } },
-    ListToolsResultSchema,
-  )
-}
-
-/** Call without the SDK pre-validating an output schema the bridge may not support. */
-function callToolUncached(
-  client: Client,
-  rawName: string,
-  args: Record<string, unknown>,
-  signal: AbortSignal,
-  opts: ToolBridgeOptions,
-) {
-  return client.request(
-    { method: 'tools/call', params: { name: rawName, arguments: args } },
-    RawCallToolResultSchema,
-    {
-      signal,
-      timeout: opts.toolCallTimeoutMs,
-    },
-  )
-}
-
 /**
  * Derive the model-facing public name for one MCP tool.
  *
@@ -122,9 +91,9 @@ export function publicToolName(serverName: string, rawName: string): string {
  *
  * Two phases keep the swap safe:
  *
- * 1. Fetch: drain uncached `tools/list` pagination and build the full next
+ * 1. Fetch: let the SDK aggregate `tools/list` and build the full next
  *    generation of `ToolDefinition`s under public names. Any failure here
- *    (network error, duplicate raw name, repeated continuation cursor) rejects
+ *    (network error or duplicate raw name) rejects
  *    and leaves the previous generation registered untouched.
  * 2. Swap: dispose the previous generation, register the new one. A registry
  *    conflict here can only mean a foreign registration squats on this
@@ -149,37 +118,29 @@ export async function syncTools(
 ): Promise<ToolDisposers> {
   // Phase 1: fetch and build the next generation without touching the registry.
   const definitions = new Map<string, ToolDefinition>()
-  const seenCursors = new Set<string>()
-  let cursor: string | undefined
-  do {
-    const response = await listToolsUncached(client, cursor)
-    for (const tool of response.tools) {
-      const publicName = publicToolName(opts.serverName, tool.name)
-      if (definitions.has(publicName)) {
-        throw new Error(
-          `mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`,
-        )
-      }
-      definitions.set(publicName, createMcpToolDefinition(ctx, {
-        name: publicName,
-        rawName: tool.name,
-        description: tool.description ?? '',
-        inputSchema: tool.inputSchema,
-        outputSchema: tool.outputSchema,
-        taskRequired: tool.execution?.taskSupport === 'required',
-        call: (args, signal) => callToolUncached(client, tool.name, args, signal, opts),
-      }))
-    }
-    cursor = response.nextCursor
-    if (cursor) {
-      if (seenCursors.has(cursor)) {
-        throw new Error(
-          `mcp-client(${opts.serverName}): server repeated a tools/list continuation cursor — invalid tool list`,
-        )
-      }
-      seenCursors.add(cursor)
+  const response = client.getServerCapabilities()?.tools === undefined
+    ? { tools: [] }
+    : await client.listTools(undefined, { cacheMode: 'refresh' })
+  for (const tool of response.tools) {
+    const publicName = publicToolName(opts.serverName, tool.name)
+    if (definitions.has(publicName)) {
+      throw new Error(
+        `mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`,
+      )
     }
-  } while (cursor)
+    definitions.set(publicName, createMcpToolDefinition(ctx, {
+      name: publicName,
+      rawName: tool.name,
+      description: tool.description ?? '',
+      inputSchema: tool.inputSchema,
+      outputSchema: tool.outputSchema,
+      taskRequired: tool.execution?.taskSupport === 'required',
+      call: (args, execution) => client.callTool(
+        { name: tool.name, arguments: args },
+        { signal: execution.signal, timeout: opts.toolCallTimeoutMs, toolDefinition: tool },
+      ),
+    }))
+  }
 
   // Phase 2: swap generations.
   for (const dispose of previous.values()) dispose()
@@ -200,12 +161,7 @@ export async function syncTools(
   return disposers
 }
 
-/**
- * The shape we read from each MCP content block. Intentionally looser than the
- * SDK's `ContentBlock` type: we're at a network trust boundary (data arrives
- * from an external MCP server process via JSON-RPC), so fields that the SDK
- * declares required may be absent at runtime if the server is buggy.
- */
+/** Fields read from canonical content, including policy-owned value replacements. */
 interface McpContentBlock {
   type: string
   text?: string
@@ -253,10 +209,10 @@ export interface McpToolDefinitionOptions {
   /**
    * Obtain one raw MCP result from the provider.
    * @param args - model arguments admitted by the ToolRuntime.
-   * @param signal - cancellation for this invocation.
+   * @param execution - exact ToolRuntime invocation, including its Agent and cancellation.
    * @returns the external result object, validated before content projection.
    */
-  call(args: Record<string, unknown>, signal: AbortSignal): Promise<unknown>
+  call(args: Record<string, unknown>, execution: ToolExecution): Promise<unknown>
 }
 
 /**
@@ -271,13 +227,12 @@ export function createMcpToolDefinition(
   options: McpToolDefinitionOptions,
 ): ToolDefinition {
   const { name, rawName, description, inputSchema } = options
-  const structuredSchema = supportedOutputSchema(options.outputSchema)
   const projections = new WeakMap<ToolExecution, PreparedProjection>()
   return {
     name,
     description,
     parameters: inputSchema,
-    output: createOutput(rawName, structuredSchema),
+    output: createOutput(rawName, supportedOutputSchema(options.outputSchema)),
     execute: createExecutor(ctx, options, projections),
     finalizeContent(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>) {
       const projection = projections.get(exec)
@@ -329,26 +284,12 @@ function createExecutor(
     // string/number/null). Fallback to {} lets the MCP server produce a
     // specific "missing required param" error the model can learn from.
     const argsObj = (typeof args === 'object' && args !== null ? args : {}) as Record<string, unknown>
-    const result = RawCallToolResultSchema.parse(await options.call(argsObj, exec.signal))
-
-    // The SDK may return a legacy `toolResult` shape; normalize to content array.
-    if (!Array.isArray(result.content)) {
-      const rendered: unknown = 'toolResult' in result
-        ? JSON.stringify(result.toolResult)
-        : '(no output)'
-      const text = typeof rendered === 'string' ? rendered : '(no output)'
-      if (result.isError === true) throw new Error(text)
-      return {
-        content: [{ type: 'text', text }],
-        ...result.structuredContent !== undefined
-          ? { structuredContent: result.structuredContent as JsonValue }
-          : {},
-      }
+    const parsed = specTypeSchemas.CallToolResult['~standard'].validate(await options.call(argsObj, exec))
+    if (parsed.issues !== undefined) {
+      throw new Error(`Tool "${rawName}" returned an invalid MCP result: ${parsed.issues.map(issue => issue.message).join('; ')}`)
     }
+    const result = parsed.value
 
-    // Trust boundary: the SDK's return type erases to `any[]` due to the
-    // union of CallToolResult | CompatibilityCallToolResult; extractText
-    // validates each element.
     const content = result.content as unknown as JsonValue[]
     const text = extractText(content, rawName)
 
@@ -387,12 +328,12 @@ 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)) {
+/** Decode one SDK-admitted image without accepting base64 aliases. */
+function decodeImage(block: ImageContent): SaveImageAttachment {
+  if (!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)) {
+  if (!CANONICAL_BASE64.test(block.data)) {
     throw new Error('the image data is not canonical base64')
   }
   const data = Buffer.from(block.data, 'base64')
@@ -455,7 +396,7 @@ async function prepareImageProjection(
     if (!isRecord(value) || value.type !== 'image') continue
     imageIndexes.push(index)
     try {
-      decoded.push(decodeImage(value as unknown as McpContentBlock))
+      decoded.push(decodeImage(value as unknown as ImageContent))
     } catch (error: unknown) {
       // decodeImage owns every throw above and always produces Error.
       validationErrors.set(index, (error as Error).message)
@@ -503,8 +444,7 @@ async function prepareImageProjection(
  * - text blocks: join with '\n'
  * - image/audio/resource blocks: replaced with a placeholder
  *
- * Defensive: fields that the MCP spec declares required (mimeType, text) are
- * guarded with fallbacks because this is a network trust boundary.
+ * Policy-owned canonical-value replacements may omit fields required on the MCP wire.
  */
 function extractText(mcpContent: JsonValue[], toolName: string): string {
   const content = projectContent(mcpContent, toolName)

+ 4 - 8
packages/mcp/mcp-client/src/transport.ts

@@ -6,9 +6,9 @@
  * @module
  */
 
-import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
-import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
-import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import type { Transport } from '@modelcontextprotocol/client'
+import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
 import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
 import type { Config } from './index.ts'
 
@@ -38,13 +38,9 @@ export function createTransport(config: Config): Transport {
         cwd: config.cwd,
       })
     case 'streamable-http':
-      // The MCP SDK's StreamableHTTPClientTransport has optional callback
-      // properties typed without `| undefined` (exactOptionalPropertyTypes
-      // mismatch with the Transport interface); the SDK constructed the
-      // object, so the cast records only that widening.
       return new StreamableHTTPClientTransport(
         new URL(config.url),
         { requestInit: { headers: config.headers } },
-      ) as Transport
+      )
   }
 }

+ 20 - 77
packages/mcp/mcp-client/tests/apply.spec.ts

@@ -18,41 +18,31 @@ const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotification
   const mockClose = vi.fn<() => Promise<void>>()
   const mockListTools = vi.fn<(_params?: Record<string, unknown>) => Promise<unknown>>()
   const mockCallTool = vi.fn<(
-    _params?: Record<string, unknown>, _compatibilitySchema?: unknown, _options?: unknown,
+    _params?: Record<string, unknown>, _options?: unknown,
   ) => Promise<unknown>>()
   const mockSetNotificationHandler = vi.fn()
-  const mockRequest = vi.fn(async (
-    request: { method: string; params?: Record<string, unknown> },
-    _schema: unknown,
-    options?: unknown,
-  ): Promise<unknown> => {
-    if (request.method === 'tools/list') return await mockListTools(request.params)
-    if (request.method === 'tools/call') return await mockCallTool(request.params, undefined, options)
-    throw new Error(`unexpected MCP request: ${request.method}`)
-  })
   class MockClient {
     connect = mockConnect
     close = mockClose
     listTools = mockListTools
     callTool = mockCallTool
-    request = mockRequest
-    setNotificationHandler = mockSetNotificationHandler
+    constructor(_info: unknown, options: { listChanged: { tools: { onChanged: () => void } } }) {
+      mockSetNotificationHandler('notifications/tools/list_changed', options.listChanged.tools.onChanged)
+    }
+    getServerCapabilities = () => ({ tools: {} })
   }
   return { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient }
 })
 
-vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
+vi.mock('@modelcontextprotocol/client', () => ({
   Client: MockClient,
+  StreamableHTTPClientTransport: vi.fn(),
 }))
 
-vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
+vi.mock('@modelcontextprotocol/client/stdio', () => ({
   StdioClientTransport: vi.fn(),
 }))
 
-vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
-  StreamableHTTPClientTransport: vi.fn(),
-}))
-
 // vi.mock is hoisted above static imports, so the module under test sees the
 // mocked SDK even through a static import.
 import { apply, name, inject, Config as ConfigSchema } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
@@ -303,28 +293,6 @@ describe('apply (plugin lifecycle)', () => {
     expect(mockClose).toHaveBeenCalled()
   })
 
-  it('rejects strict startup on a repeated discovery cursor and closes the client', async () => {
-    mockListTools
-      .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' })
-      .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' })
-      .mockRejectedValue(new Error('pagination continued after the repeated cursor'))
-    try {
-      await expect(apply(ctx, {
-        ...stdioConfig,
-        failOnStartupError: true,
-        reconnect: { enabled: false },
-      })).rejects.toMatchObject({
-        message: 'mcp-client(srv): initial connection or tool synchronization failed',
-        cause: new Error('mcp-client(srv): server repeated a tools/list continuation cursor — invalid tool list'),
-      })
-      expect(mockListTools).toHaveBeenCalledTimes(2)
-      expect(mockClose).toHaveBeenCalledTimes(1)
-      expect(ctx.tools.schemas()).toEqual([])
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
-
   it('preserves strict startup registration when list_changed arrives before connect resolves', async () => {
     ctx.tools.register({
       name: 'mcp__srv__remote',
@@ -337,8 +305,8 @@ describe('apply (plugin lifecycle)', () => {
       execute: async () => 'foreign',
     })
     mockConnect.mockImplementation(async () => {
-      const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
-      await handler()
+      const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => void
+      handler()
     })
 
     await expect(apply(ctx, {
@@ -361,50 +329,25 @@ describe('apply (plugin lifecycle)', () => {
       nextCursor: undefined,
     })
 
-    const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
-    await handler()
+    const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => void
+    handler()
 
-    expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
-    expect(ctx.tools.get('mcp__srv__updated')).toBeDefined()
+    await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__updated')).toBeDefined() })
   })
 
   it('keeps the previous generation when a re-sync fails', async () => {
     await apply(ctx, stdioConfig)
     expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
 
+    const reported = vi.spyOn(ctx.logger, 'error')
     mockListTools.mockRejectedValue(new Error('flaky server'))
-    const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
-    // Must not reject (contained), and must keep the last good generation.
-    await handler()
+    const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => void
+    handler()
+    await vi.waitFor(() => { expect(reported).toHaveBeenCalledWith(expect.stringContaining('flaky server')) })
 
     expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
   })
 
-  it('continues notification synchronization after rejecting a pagination cycle', async () => {
-    try {
-      await apply(ctx, stdioConfig)
-      const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
-      mockListTools
-        .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' })
-        .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' })
-        .mockRejectedValue(new Error('pagination continued after the repeated cursor'))
-
-      await handler()
-      expect(mockListTools).toHaveBeenCalledTimes(3)
-      expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
-
-      mockListTools
-        .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' })
-        .mockResolvedValueOnce({ tools: [{ name: 'updated', inputSchema: { type: 'object' } }], nextCursor: undefined })
-      await handler()
-      expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
-      expect(ctx.tools.get('mcp__srv__updated')).toBeDefined()
-    } finally {
-      await ctx.fiber.dispose()
-    }
-    expect(mockClose).toHaveBeenCalledTimes(1)
-  })
-
   it('effect disposer unregisters the CURRENT generation and closes client', async () => {
     // Load through ctx.plugin so ONLY the plugin's fiber is disposed — the
     // registry must survive to observe the unregistration.
@@ -416,9 +359,9 @@ describe('apply (plugin lifecycle)', () => {
       tools: [{ name: 'updated', inputSchema: { type: 'object' } }],
       nextCursor: undefined,
     })
-    const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
-    await handler()
-    expect(ctx.tools.get('mcp__srv__updated')).toBeDefined()
+    const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => void
+    handler()
+    await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__updated')).toBeDefined() })
 
     await fiber.dispose()
     await sleep(50)

+ 1 - 1
packages/mcp/mcp-client/tests/egress.spec.ts

@@ -31,7 +31,7 @@ async function observe(run: () => Promise<unknown>): Promise<string[]> {
   try { await run().catch(() => undefined) } finally { await dispose() }
   return seen
 }
-import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
 describe('mcp streamable-http egress', () => {
   it('goes through the proxy', async () => {
     const t = new StreamableHTTPClientTransport(new URL('http://mcp-probe.invalid/mcp'))

+ 62 - 60
packages/mcp/mcp-client/tests/fixture-server.ts

@@ -5,72 +5,74 @@
  * Run: node fixture-server.ts
  */
 
-import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
-import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
+import { McpServer, type CallToolResult } from '@modelcontextprotocol/server'
+import { serveStdio } from '@modelcontextprotocol/server/stdio'
 import { z } from 'zod'
 
-const server = new McpServer(
-  { name: 'fixture-server', version: '1.0.0' },
-  { capabilities: { tools: { listChanged: true } } },
-)
+function createFixtureServer(): McpServer {
+  const server = new McpServer(
+    { name: 'fixture-server', version: '1.0.0' },
+    { capabilities: { tools: { listChanged: true } } },
+  )
 
-server.registerTool('add', {
-  title: 'Add Tool',
-  description: 'Adds two numbers.',
-  inputSchema: { a: z.number().describe('First number'), b: z.number().describe('Second number') },
-}, async args => ({
-  content: [{ type: 'text', text: String(args.a + args.b) }],
-}))
+  server.registerTool('add', {
+    title: 'Add Tool',
+    description: 'Adds two numbers.',
+    inputSchema: z.object({ a: z.number().describe('First number'), b: z.number().describe('Second number') }),
+  }, async args => ({
+    content: [{ type: 'text', text: String(args.a + args.b) }],
+  }))
 
-server.registerTool('greet', {
-  title: 'Greet Tool',
-  description: 'Greets a person by name.',
-  inputSchema: { name: z.string().describe('Name to greet') },
-}, async args => ({
-  content: [{ type: 'text', text: `Hello, ${args.name}!` }],
-}))
+  server.registerTool('greet', {
+    title: 'Greet Tool',
+    description: 'Greets a person by name.',
+    inputSchema: z.object({ name: z.string().describe('Name to greet') }),
+  }, async args => ({
+    content: [{ type: 'text', text: `Hello, ${args.name}!` }],
+  }))
 
-server.registerTool('fail', {
-  title: 'Fail Tool',
-  description: 'Always returns an error.',
-  inputSchema: {},
-}, async () => ({
-  content: [{ type: 'text', text: 'Something went wrong' }],
-  isError: true,
-}))
+  server.registerTool('fail', {
+    title: 'Fail Tool',
+    description: 'Always returns an error.',
+    inputSchema: z.object({}),
+  }, async (): Promise<CallToolResult> => ({
+    content: [{ type: 'text', text: 'Something went wrong' }],
+    isError: true,
+  }))
 
-server.registerTool('image', {
-  title: 'Image Tool',
-  description: 'Returns an image content block.',
-  inputSchema: {},
-}, async () => ({
-  content: [
-    { type: 'text', text: 'Here is an image:' },
-    { type: 'image', data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', mimeType: 'image/png' },
-    { type: 'text', text: 'End of image.' },
-  ],
-}))
+  server.registerTool('image', {
+    title: 'Image Tool',
+    description: 'Returns an image content block.',
+    inputSchema: z.object({}),
+  }, async (): Promise<CallToolResult> => ({
+    content: [
+      { type: 'text', text: 'Here is an image:' },
+      { type: 'image', data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', mimeType: 'image/png' },
+      { type: 'text', text: 'End of image.' },
+    ],
+  }))
 
-server.registerTool('crash', {
-  title: 'Crash Tool',
-  description: 'Replies, then exits the server process (crash-recovery test).',
-  inputSchema: {},
-}, async () => {
-  // Exit AFTER the response flushes so the caller observes a clean result
-  // followed by a transport close, like a real post-reply crash.
-  setTimeout(() => process.exit(7), 25)
-  return { content: [{ type: 'text', text: 'crashing' }] }
-})
+  server.registerTool('crash', {
+    title: 'Crash Tool',
+    description: 'Replies, then exits the server process (crash-recovery test).',
+    inputSchema: z.object({}),
+  }, async (): Promise<CallToolResult> => {
+    // Exit AFTER the response flushes so the caller observes a clean result
+    // followed by a transport close, like a real post-reply crash.
+    setTimeout(() => process.exit(7), 25)
+    return { content: [{ type: 'text', text: 'crashing' }] }
+  })
 
-// Dotted name: legal in MCP, illegal in the DeepSeek function-name contract.
-// Exercises the bridge's normalize-and-hash public-name path end to end.
-server.registerTool('admin.reset', {
-  title: 'Admin Reset Tool',
-  description: 'Tool with a dotted name (normalization test).',
-  inputSchema: {},
-}, async () => ({
-  content: [{ type: 'text', text: 'reset done' }],
-}))
+  // Dotted name: legal in MCP, illegal in the DeepSeek function-name contract.
+  // Exercises the bridge's normalize-and-hash public-name path end to end.
+  server.registerTool('admin.reset', {
+    title: 'Admin Reset Tool',
+    description: 'Tool with a dotted name (normalization test).',
+    inputSchema: z.object({}),
+  }, async (): Promise<CallToolResult> => ({
+    content: [{ type: 'text', text: 'reset done' }],
+  }))
+  return server
+}
 
-const transport = new StdioServerTransport()
-await server.connect(transport)
+serveStdio(createFixtureServer)

+ 15 - 0
packages/mcp/mcp-client/tests/fixtures/pagination-limit-server.ts

@@ -0,0 +1,15 @@
+/** MCP wire fixture returning a non-terminating discovery pagination. */
+
+import { McpServer } from '@modelcontextprotocol/server'
+import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'
+
+const server = new McpServer(
+  { name: 'pagination-limit', version: '1.0.0' },
+  { capabilities: { tools: {} } },
+)
+let requests = 0
+server.server.setRequestHandler('tools/list', () => {
+  return { tools: [], nextCursor: String(++requests) }
+})
+
+await server.connect(new StdioServerTransport())

+ 2 - 2
packages/mcp/mcp-client/tests/fixtures/repeated-cursor.patch.yml → packages/mcp/mcp-client/tests/fixtures/pagination-limit.patch.yml

@@ -1,9 +1,9 @@
 - insert:
-    - id: mcp-pagination-cycle
+    - id: mcp-pagination-limit
       name: '@deepseek-ai/dsh-mcp-client'
       config:
         transport: stdio
-        serverName: pagination-cycle
+        serverName: pagination-limit
         command: !!js process.execPath
         args: !!js '[process.env.DSH_MCP_PAGINATION_FIXTURE]'
         failOnStartupError: true

+ 0 - 18
packages/mcp/mcp-client/tests/fixtures/repeated-cursor-server.ts

@@ -1,18 +0,0 @@
-/** MCP wire fixture returning a repeated discovery cursor over empty pages. */
-
-import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
-import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
-import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
-
-const server = new McpServer(
-  { name: 'repeated-cursor', version: '1.0.0' },
-  { capabilities: { tools: {} } },
-)
-let requests = 0
-server.server.setRequestHandler(ListToolsRequestSchema, () => {
-  // Bound the broken-client path without relying on a test timeout to kill it.
-  if (++requests > 2) throw new Error('pagination continued after the repeated cursor')
-  return { tools: [], nextCursor: 'same-cursor' }
-})
-
-await server.connect(new StdioServerTransport())

+ 18 - 16
packages/mcp/mcp-client/tests/http-fixture.ts

@@ -1,9 +1,9 @@
+import { z } from 'zod'
 /** Keyless stateless Streamable HTTP MCP fixture for integration tests. */
 
 import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
-import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
-import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
-import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
+import { createMcpHandler, McpServer, type CallToolResult } from '@modelcontextprotocol/server'
+import { toNodeHandler, type NodeIncomingMessageLike } from '@modelcontextprotocol/node'
 
 /** Running HTTP fixture and the request headers it observed. */
 export interface HttpMcpFixture {
@@ -15,22 +15,21 @@ export interface HttpMcpFixture {
 /** Start a local stateless MCP endpoint exposing one `ping` tool. */
 export async function startHttpMcpFixture(): Promise<HttpMcpFixture> {
   const authorization: Array<string | undefined> = []
-  const handleRequest = async (request: IncomingMessage, response: ServerResponse): Promise<void> => {
-    authorization.push(request.headers.authorization)
+  const handler = createMcpHandler(() => {
     const mcp = new McpServer(
       { name: 'http-fixture', version: '1.0.0' },
       { capabilities: { tools: {} } },
     )
-    mcp.registerTool('ping', { description: 'Replies pong.', inputSchema: {} }, async () => ({
+    mcp.registerTool('ping', { description: 'Replies pong.', inputSchema: z.object({}) }, async (): Promise<CallToolResult> => ({
       content: [{ type: 'text', text: 'pong' }],
     }))
-    const transport = new StreamableHTTPServerTransport({})
-    response.on('close', () => {
-      void transport.close()
-      void mcp.close()
-    })
-    await mcp.connect(transport as Transport)
-    await transport.handleRequest(request, response)
+    return mcp
+  })
+  const handle = toNodeHandler(handler)
+  const handleRequest = async (request: IncomingMessage, response: ServerResponse): Promise<void> => {
+    authorization.push(request.headers.authorization)
+    // The adapter excludes explicit undefined on Node's optional HTTP fields.
+    await handle(request as NodeIncomingMessageLike, response)
   }
   const server = createServer((request, response) => {
     handleRequest(request, response).catch((error: unknown) => {
@@ -45,8 +44,11 @@ export async function startHttpMcpFixture(): Promise<HttpMcpFixture> {
   return {
     url: `http://127.0.0.1:${address.port}/mcp`,
     authorization,
-    close: () => new Promise<void>((resolve, reject) => {
-      server.close((error) => { if (error === undefined) resolve(); else reject(error) })
-    }),
+    close: async () => {
+      await handler.close()
+      await new Promise<void>((resolve, reject) => {
+        server.close((error) => { if (error === undefined) resolve(); else reject(error) })
+      })
+    },
   }
 }

+ 18 - 25
packages/mcp/mcp-client/tests/mcp-client.e2e.ts

@@ -3,7 +3,7 @@
  * 1. A self-written fixture server over stdio (controlled edge cases)
  * 2. @modelcontextprotocol/server-everything (official integration test server)
  * 3. @modelcontextprotocol/server-filesystem (real filesystem operations)
- * 4. An in-process StreamableHTTPServerTransport server over Streamable HTTP
+ * 4. An in-process NodeStreamableHTTPServerTransport server over Streamable HTTP
  *
  * No API key needed — all servers are local/keyless.
  */
@@ -15,10 +15,9 @@ import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
-import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
-import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
+import { createMcpHandler, McpServer, type CallToolResult } from '@modelcontextprotocol/server'
+import { toNodeHandler, type NodeIncomingMessageLike } from '@modelcontextprotocol/node'
 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 ToolRuntime from '@deepseek-ai/dsh-tools'
@@ -463,40 +462,33 @@ describe('streamable-http — in-process MCP server', () => {
   let baseUrl: string
   /** Authorization header values observed by the HTTP server, in arrival order. */
   const seenAuth: Array<string | undefined> = []
+  const seenMessageHeaders: Array<string | string[] | undefined> = []
 
-  /**
-   * Stateless Streamable HTTP endpoint: a fresh McpServer + server transport
-   * per request (the SDK's documented stateless pattern — no session id, no
-   * SSE stream to keep). The tool set mirrors a minimal fixture server.
-   */
-  async function handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
-    seenAuth.push(req.headers.authorization)
+  const handler = createMcpHandler(() => {
     const server = new McpServer(
       { name: 'http-fixture', version: '1.0.0' },
       { capabilities: { tools: {} } },
     )
     server.registerTool('ping', {
       description: 'Replies pong.',
-      inputSchema: {},
-    }, async () => ({
+      inputSchema: z.object({}),
+    }, async (): Promise<CallToolResult> => ({
       content: [{ type: 'text', text: 'pong' }],
     }))
     server.registerTool('shout', {
       description: 'Upper-cases a message.',
-      inputSchema: { message: z.string().describe('Message to upper-case') },
+      inputSchema: z.object({ message: z.string().describe('Message to upper-case').meta({ 'x-mcp-header': 'message' }) }),
     }, async args => ({
       content: [{ type: 'text', text: args.message.toUpperCase() }],
     }))
-    // Stateless mode: sessionIdGenerator ABSENT (the runtime treats absent and
-    // explicit-undefined identically; exactOptionalPropertyTypes forbids the
-    // SDK-documented explicit `sessionIdGenerator: undefined` spelling).
-    const transport = new StreamableHTTPServerTransport({})
-    res.on('close', () => { void transport.close(); void server.close() })
-    // Same exactOptionalPropertyTypes mismatch the client transport factory
-    // documents (src/transport.ts): the SDK types optional callbacks without
-    // `| undefined`. The SDK constructed the object; the cast is safe.
-    await server.connect(transport as Transport)
-    await transport.handleRequest(req, res)
+    return server
+  })
+  const handle = toNodeHandler(handler)
+  async function handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
+    seenAuth.push(req.headers.authorization)
+    seenMessageHeaders.push(req.headers['mcp-param-message'])
+    // The adapter excludes explicit undefined on Node's optional HTTP fields.
+    await handle(req as NodeIncomingMessageLike, res)
   }
 
   beforeAll(async () => {
@@ -526,7 +518,7 @@ describe('streamable-http — in-process MCP server', () => {
 
   afterAll(async () => {
     if (ctx) await ctx.fiber.dispose()
-    await sleep(200)
+    await handler.close()
     const closed: PromiseWithResolvers<void> = Promise.withResolvers()
     httpServer.close(() => { closed.resolve() })
     await closed.promise
@@ -554,6 +546,7 @@ describe('streamable-http — in-process MCP server', () => {
     })
     expect(result.isError).toBe(false)
     expect(result.content[0]).toEqual({ type: 'text', text: 'QUIET' })
+    expect(seenMessageHeaders).toContain('quiet')
   })
 
   it('sends configured headers on every HTTP request', () => {

+ 35 - 179
packages/mcp/mcp-client/tests/mcp-client.spec.ts

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

+ 80 - 0
packages/mcp/mcp-client/tests/protocol.spec.ts

@@ -0,0 +1,80 @@
+import { z } from 'zod'
+/** Real SDK negotiation, subscription, and cancellation through the connection supervisor. */
+
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { InMemoryTransport, type Transport } from '@modelcontextprotocol/client'
+import { McpServer } from '@modelcontextprotocol/server'
+import { serveStdio } from '@modelcontextprotocol/server/stdio'
+import { ToolCallId } from '@deepseek-ai/dsh-llm'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRuntime from '@deepseek-ai/dsh-tools'
+import { startConnection, resolveReconnectPolicy } from '../src/connection.ts'
+import type { Config } from '../src/index.ts'
+
+const { mockTransport } = vi.hoisted(() => ({ mockTransport: vi.fn<() => Transport>() }))
+vi.mock('../src/transport.ts', () => ({ createTransport: mockTransport }))
+
+const config: Config = {
+  transport: 'stdio', serverName: 'fixture', command: 'fixture', args: [], env: {}, cwd: '',
+  toolCallTimeoutMs: 60_000, failOnStartupError: true,
+}
+
+async function connect(server: McpServer): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRuntime)
+  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
+  const serving = serveStdio(() => server, { transport: serverTransport })
+  mockTransport.mockReturnValue(clientTransport)
+  const connection = startConnection(ctx, config, resolveReconnectPolicy({ enabled: false }, 'fixture'))
+  onTestFinished(async () => {
+    await connection.dispose()
+    await serving.close()
+    await ctx.fiber.dispose()
+  })
+  expect(await connection.ready).toEqual({})
+  return ctx
+}
+
+describe('modern MCP connections', () => {
+  it('keeps a resource-only server connected without requesting tools', async () => {
+    const server = new McpServer({ name: 'resources', version: '1' })
+    server.registerResource('memo', 'memo://readme', {}, async () => ({
+      contents: [{ uri: 'memo://readme', text: 'memo' }],
+    }))
+    const ctx = await connect(server)
+    expect(ctx.tools.schemas()).toEqual([])
+  })
+
+  it('updates tools through the SDK modern list-change subscription', async () => {
+    const server = new McpServer({ name: 'tools', version: '1' })
+    server.registerTool('first', { inputSchema: z.object({}) }, async () => ({ content: [] }))
+    const ctx = await connect(server)
+    expect(ctx.tools.get('mcp__fixture__first')).toBeDefined()
+    server.registerTool('second', { inputSchema: z.object({}) }, async () => ({ content: [] }))
+    await vi.waitFor(() => { expect(ctx.tools.get('mcp__fixture__second')).toBeDefined() })
+  })
+
+  it('delivers caller cancellation to an executing modern tool', async () => {
+    const entered: PromiseWithResolvers<void> = Promise.withResolvers()
+    const cancelled: PromiseWithResolvers<void> = Promise.withResolvers()
+    const server = new McpServer({ name: 'cancel', version: '1' })
+    server.registerTool('wait', { inputSchema: z.object({}) }, async (_args, context) => {
+      const signal = context.mcpReq.signal
+      signal.addEventListener('abort', () => { cancelled.resolve() }, { once: true })
+      entered.resolve()
+      await cancelled.promise
+      return { content: [] }
+    })
+    const ctx = await connect(server)
+    const controller = new AbortController()
+    const result = ctx.tools.execute({
+      callId: ToolCallId('cancel'), name: 'mcp__fixture__wait', arguments: {}, signal: controller.signal,
+    })
+    await entered.promise
+    controller.abort(new Error('caller stopped'))
+    expect((await result).isError).toBe(true)
+    await cancelled.promise
+  })
+})

+ 18 - 27
packages/mcp/mcp-client/tests/reconnect.spec.ts

@@ -20,42 +20,35 @@ const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotification
   const mockClose = vi.fn<() => Promise<void>>()
   const mockListTools = vi.fn<(_params?: Record<string, unknown>) => Promise<unknown>>()
   const mockCallTool = vi.fn<(
-    _params?: Record<string, unknown>, _compatibilitySchema?: unknown, _options?: unknown,
+    _params?: Record<string, unknown>, _options?: unknown,
   ) => Promise<unknown>>()
   const mockSetNotificationHandler = vi.fn()
-  const mockRequest = vi.fn(async (
-    request: { method: string; params?: Record<string, unknown> },
-    _schema: unknown,
-    options?: unknown,
-  ): Promise<unknown> => {
-    if (request.method === 'tools/list') return await mockListTools(request.params)
-    if (request.method === 'tools/call') return await mockCallTool(request.params, undefined, options)
-    throw new Error(`unexpected MCP request: ${request.method}`)
-  })
   class MockClient {
     onclose: (() => void) | undefined
     connect = mockConnect
     close = mockClose
-    request = mockRequest
-    setNotificationHandler = mockSetNotificationHandler
-    constructor() { instances.push(this) }
+    getServerCapabilities = () => ({ tools: {} })
+    listTools = mockListTools
+    callTool = mockCallTool
+    constructor(_info: unknown, options: { listChanged: { tools: { onChanged: () => void } } }) {
+      instances.push(this)
+      mockSetNotificationHandler('notifications/tools/list_changed', options.listChanged.tools.onChanged)
+    }
   }
   const instances: MockClient[] = []
   return { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient, instances }
 })
 
-vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
+vi.mock('@modelcontextprotocol/client', async importOriginal => ({
+  ...await importOriginal<typeof import('@modelcontextprotocol/client')>(),
   Client: MockClient,
+  StreamableHTTPClientTransport: vi.fn(),
 }))
 
-vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
+vi.mock('@modelcontextprotocol/client/stdio', () => ({
   StdioClientTransport: vi.fn(),
 }))
 
-vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
-  StreamableHTTPClientTransport: vi.fn(),
-}))
-
 // vi.mock is hoisted above static imports, so the modules under test see the
 // mocked SDK even through a static import.
 import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
@@ -203,8 +196,8 @@ describe('reconnect supervisor', () => {
 
     const gate: PromiseWithResolvers<unknown> = Promise.withResolvers()
     mockListTools.mockImplementation(() => gate.promise)
-    const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
-    const resync = handler()
+    const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => void
+    handler()
     await vi.waitFor(() => { expect(mockListTools).toHaveBeenCalledTimes(2) })
 
     mockConnect.mockRejectedValue(new Error('server gone'))
@@ -214,7 +207,6 @@ describe('reconnect supervisor', () => {
     })
 
     gate.resolve(listing('late'))
-    await resync
     await vi.waitFor(() => {
       expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
       expect(ctx.tools.get('mcp__srv__late')).toBeUndefined()
@@ -446,15 +438,14 @@ describe('reconnect supervisor', () => {
 
     const gate: PromiseWithResolvers<unknown> = Promise.withResolvers()
     mockListTools.mockImplementation(() => gate.promise)
-    const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
-    const resync = handler()
+    const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => void
+    handler()
     await vi.waitFor(() => { expect(mockListTools).toHaveBeenCalledTimes(2) })
 
     const disposing = fiber.dispose()
     await sleep(10)
     gate.reject(new Error('Connection closed'))
     await disposing
-    await resync
 
     expect(errors.some(line => line.includes('tool re-sync failed'))).toBe(false)
   })
@@ -468,8 +459,8 @@ describe('reconnect supervisor', () => {
     await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
     const listCalls = mockListTools.mock.calls.length
 
-    const staleHandler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
-    await staleHandler()
+    const staleHandler = mockSetNotificationHandler.mock.calls[0]![1] as () => void
+    staleHandler()
     expect(mockListTools).toHaveBeenCalledTimes(listCalls)
   })
 })

+ 30 - 21
packages/mcp/mcp-client/tests/tool-definition.spec.ts

@@ -2,15 +2,21 @@ import { Context } from '@deepseek-ai/cordis'
 import { ToolCallId } from '@deepseek-ai/dsh-llm'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRuntime from '@deepseek-ai/dsh-tools'
+import type { ToolExecution } from '@deepseek-ai/dsh-tools'
 import { describe, expect, it, vi } from 'vitest'
 import { createMcpToolDefinition } from '../src/index.ts'
 
 describe('MCP result callback adaptation', () => {
-  it('preserves arguments, cancellation and canonical structured results', async () => {
+  it('preserves the exact execution, arguments and canonical structured results', async () => {
     const ctx = new Context()
     try {
       await ctx.plugin(SystemPrompt)
       await ctx.plugin(ToolRuntime)
+      let execution: ToolExecution | undefined
+      ctx.on('tools/execute', (exec, next) => {
+        execution = exec
+        return next()
+      })
       const call = vi.fn(async () => ({
         content: [{ type: 'text', text: 'Observed window.' }],
         structuredContent: { window: 7 },
@@ -25,7 +31,8 @@ describe('MCP result callback adaptation', () => {
       const result = await ctx.tools.execute({
         name: 'native_window', callId: ToolCallId('window-call'), arguments: { window: 7 }, signal,
       })
-      expect(call).toHaveBeenCalledWith({ window: 7 }, signal)
+      expect(call).toHaveBeenCalledWith({ window: 7 }, execution)
+      expect(execution?.signal).toBe(signal)
       expect(result.isError).toBe(false)
       expect(result.value).toEqual({
         content: [{ type: 'text', text: 'Observed window.' }], structuredContent: { window: 7 },
@@ -35,23 +42,25 @@ describe('MCP result callback adaptation', () => {
     }
   })
 
-  it('rejects a non-object external result before exposing content', async () => {
-    const ctx = new Context()
-    try {
-      await ctx.plugin(SystemPrompt)
-      await ctx.plugin(ToolRuntime)
-      ctx.tools.register(createMcpToolDefinition(ctx, {
-        name: 'invalid_result', rawName: 'invalid', description: 'External result fixture.',
-        inputSchema: { type: 'object' }, call: async () => null,
-      }))
-      const result = await ctx.tools.execute({
-        name: 'invalid_result', callId: ToolCallId('invalid-call'), arguments: {},
-        signal: new AbortController().signal,
-      })
-      expect(result.isError).toBe(true)
-      expect(result.value).toBeUndefined()
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
+  it.each([null, { content: [42] }, { content: [{ type: 'image' }] }])(
+    'rejects invalid external results before exposing content (%j)', async (invalid) => {
+      const ctx = new Context()
+      try {
+        await ctx.plugin(SystemPrompt)
+        await ctx.plugin(ToolRuntime)
+        ctx.tools.register(createMcpToolDefinition(ctx, {
+          name: 'invalid_result', rawName: 'invalid', description: 'External result fixture.',
+          inputSchema: { type: 'object' }, call: async () => invalid,
+        }))
+        const result = await ctx.tools.execute({
+          name: 'invalid_result', callId: ToolCallId('invalid-call'), arguments: {},
+          signal: new AbortController().signal,
+        })
+        expect(result.isError).toBe(true)
+        expect(result.value).toBeUndefined()
+      } finally {
+        await ctx.fiber.dispose()
+      }
+    },
+  )
 })

+ 60 - 6
pnpm-lock.yaml

@@ -7536,12 +7536,9 @@ importers:
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
-      '@modelcontextprotocol/sdk':
-        specifier: ^1.12.0
-        version: 1.29.0(zod@4.4.3)
-      zod:
-        specifier: ^4.4.3
-        version: 4.4.3
+      '@modelcontextprotocol/client':
+        specifier: 2.0.0
+        version: 2.0.0
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -7570,12 +7567,21 @@ importers:
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         version: link:../../core/tools
+      '@modelcontextprotocol/node':
+        specifier: 2.0.0
+        version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.12.29)
+      '@modelcontextprotocol/server':
+        specifier: 2.0.0
+        version: 2.0.0
       '@modelcontextprotocol/server-everything':
         specifier: ^2026.7.4
         version: 2026.7.4
       '@modelcontextprotocol/server-filesystem':
         specifier: ^2026.7.4
         version: 2026.7.10(zod@4.4.3)
+      zod:
+        specifier: ^4.4.3
+        version: 4.4.3
 
   packages/plan/plan-mode:
     dependencies:
@@ -13289,6 +13295,24 @@ packages:
   '@mixmark-io/domino@2.2.0':
     resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
 
+  '@modelcontextprotocol/client@2.0.0':
+    resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==}
+    engines: {node: '>=20'}
+
+  '@modelcontextprotocol/core@2.0.0':
+    resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==}
+    engines: {node: '>=20'}
+
+  '@modelcontextprotocol/node@2.0.0':
+    resolution: {integrity: sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==}
+    engines: {node: '>=20'}
+    peerDependencies:
+      '@modelcontextprotocol/server': ^2.0.0
+      hono: ^4.11.4
+    peerDependenciesMeta:
+      hono:
+        optional: true
+
   '@modelcontextprotocol/sdk@1.29.0':
     resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
     engines: {node: '>=18'}
@@ -13307,6 +13331,10 @@ packages:
     resolution: {integrity: sha512-Mmjg4anFBD5OzbPnGJOA0jPPN8645ERhQk38HQLpSenx1ox9bfdPkmAzUnNjeQtqQGFLtKe13J20RtLBmUKMZA==}
     hasBin: true
 
+  '@modelcontextprotocol/server@2.0.0':
+    resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==}
+    engines: {node: '>=20'}
+
   '@napi-rs/canvas-android-arm64@1.0.8':
     resolution: {integrity: sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==}
     engines: {node: '>= 10'}
@@ -20071,6 +20099,27 @@ snapshots:
 
   '@mixmark-io/domino@2.2.0': {}
 
+  '@modelcontextprotocol/client@2.0.0':
+    dependencies:
+      '@modelcontextprotocol/core': 2.0.0
+      cross-spawn: 7.0.6
+      eventsource: 3.0.7
+      eventsource-parser: 3.1.0
+      jose: 6.2.3
+      pkce-challenge: 5.0.1
+      zod: 4.4.3
+
+  '@modelcontextprotocol/core@2.0.0':
+    dependencies:
+      zod: 4.4.3
+
+  '@modelcontextprotocol/node@2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.12.29)':
+    dependencies:
+      '@hono/node-server': 1.19.14(hono@4.12.29)
+      '@modelcontextprotocol/server': 2.0.0
+    optionalDependencies:
+      hono: 4.12.29
+
   '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)':
     dependencies:
       '@hono/node-server': 1.19.14(hono@4.12.29)
@@ -20115,6 +20164,11 @@ snapshots:
       - supports-color
       - zod
 
+  '@modelcontextprotocol/server@2.0.0':
+    dependencies:
+      '@modelcontextprotocol/core': 2.0.0
+      zod: 4.4.3
+
   '@napi-rs/canvas-android-arm64@1.0.8':
     optional: true
 

+ 1 - 0
scripts/smoke-python-runtime.py

@@ -1218,6 +1218,7 @@ def smoke_sdk_mcp(base_url: str, executable: Path | None) -> None:
 
         assert result.final_response == MCP_TEXT, result.final_response
         assert discovery_log.read_text().splitlines() == [
+            "server/discover",
             "initialize",
             "notifications/initialized",
             "tools/list",

+ 5 - 0
snapshots/session/computer-use-cua-driver-mcp/workspace/driver.mjs

@@ -20,7 +20,12 @@ lines.on('line', (line) => {
   }
   let result
   switch (request.method) {
+    case 'server/discover':
+      record('discover')
+      process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found' } }) + '\n')
+      return
     case 'initialize':
+      record('initialize')
       if (mode === 'fail') process.exit(1)
       result = { protocolVersion: request.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: 'cua-driver-fixture', version: '1.0.0' } }
       break