Просмотр исходного кода

fix(mcp): own transports through protocol negotiation

Tianyi Cui 1 неделя назад
Родитель
Сommit
3785b68ecf

+ 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: ac0815b7f30fd4f8ed4eee67584d2c54461b63de
-2026-07-07-mcp-client-plugin.zh.md: 245b181d7dfd2bb1aa3ddb39caed661b12343897
+2026-07-07-mcp-client-plugin.md: 00d815c8d0d6251966e8979f87599f5d96af9e74
+2026-07-07-mcp-client-plugin.zh.md: b8d2e0457d27dc03f8b1e2267039e09ab4210795

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

@@ -102,7 +102,7 @@ This server-qualified shape is the de-facto standard among multi-server agent cl
 4. No `presentCall`/`presentResult` — UI consumers use the provider-neutral generic-card fallback.
 5. Tools are transparent in the system prompt — no "[via MCP]" annotation beyond the name itself.
 
-Each synchronization rejects a repeated non-empty continuation cursor before requesting another page, retaining the previous tool generation. Empty pages cannot establish progress through tool-name uniqueness, so cursor history also detects cycles spanning several pages ([reported failure](https://github.com/deepseek-ai/deepseek-harness/discussions/3660)). Cursor history belongs to one synchronization: a later update may reuse the same cursors. Focused bridge and lifecycle tests cover cycle rejection, retained callable tools, strict startup failure, and notification recovery. This detects repeated cursors; it does not bound a server that continually returns distinct cursors.
+The [protocol adoption note](2026-09-12-mcp-sdk-protocol-negotiation.md) owns SDK pagination and its page limit. Synchronization preserves the previous tool generation when discovery fails.
 
 ### Public name normalization
 

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

@@ -102,7 +102,7 @@ type Config = StdioConfig | StreamableHttpConfig
 4. 无 `presentCall`/`presentResult`——UI 消费方使用提供方无关的通用卡片兜底。
 5. 工具在系统提示词中是透明的——除名称本身外不附加「[via MCP]」标注。
 
-每次同步在请求下一页前拒绝重复的非空续传游标,并保留上一代工具。空页无法通过工具名唯一性证明分页在前进,因此游标记录还会检测跨越多页的循环([问题报告](https://github.com/deepseek-ai/deepseek-harness/discussions/3660))。游标记录只属于一次同步:后续更新可以复用相同游标。定向桥接与生命周期测试覆盖循环拒绝、保留可调用工具、严格启动失败及通知恢复。此机制检测重复游标;它不限制持续返回不同游标的服务器。
+[协议采用记录](2026-09-12-mcp-sdk-protocol-negotiation.zh.md) 负责说明 SDK 分页及其页数上限。发现失败时,同步会保留上一代工具。
 
 ### 公开名称规范化
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-12-mcp-sdk-protocol-negotiation.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-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
+2026-09-12-mcp-sdk-protocol-negotiation.md: 17eccf89e4d1674aa2f2367047918bfc97b6891b
+2026-09-12-mcp-sdk-protocol-negotiation.zh.md: f0e872596e202e5ac1a427511a11caaa959b5b12

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

@@ -12,7 +12,9 @@ MCP servers use different protocol revisions. A tool bridge that implements disc
 
 `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 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 stop at the SDK page limit; the bridge does not add a parallel pagination implementation.
+
+The supervisor owns each transport before the SDK attaches it to its Client. Disposal closes an unattached transport to cancel negotiation, then awaits the attempt so the SDK can reap its probe. A failed probe has no Client close event; after SDK cleanup it uses the normal retry budget. Attached connections retain the close-event barrier that prevents overlapping server processes.
 
 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.
 
@@ -26,4 +28,6 @@ The [tool bridge note](2026-07-07-mcp-client-plugin.md) retains the independent
 
 ## 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.
+Stdio negotiation starts a disposable probe process and waits for its exit before starting the serving 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.
+
+Real-SDK lifecycle tests verify probe disposal, process ordering, HTTP probe retry budgets, and failed stdio spawns. The connection-supervisor tests retain attached-transport close barriers and bounded failure behavior.

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

@@ -12,7 +12,9 @@ MCP 服务器使用不同协议版本。围绕旧版 SDK 实现发现与执行
 
 `dsh-mcp-client` 使用官方 TypeScript client 2.0.0 并自动协商协议。SDK 负责现代发现与旧版初始化、按传输方式协商、列表变化订阅、分页、请求 header、取消与输出校验。桥接器使用高层 `listTools` 和 `callTool`,并向每次调用传递完整的发现定义。
 
-桥接器保留服务器限定名称、原子注册和持久图片接纳。未声明 tools 能力的服务器不发布工具。发现失败保留上一次成功注册;重复名称仍拒绝新注册代。格式错误的游标链遵循 SDK 行为,包括遇到重复游标时停止;桥接器不增加并行的分页实现。
+桥接器保留服务器限定名称、原子注册和持久图片接纳。未声明 tools 能力的服务器不发布工具。发现失败保留上一次成功注册;重复名称仍拒绝新注册代。格式错误的游标链会在 SDK 页数上限处停止;桥接器不增加并行的分页实现。
+
+监督器在 SDK 将传输绑定到 Client 之前就持有该传输。释放时先关闭未绑定的传输以取消协商,再等待连接尝试结束,让 SDK 回收探测进程。失败的探测不会产生 Client 关闭事件;SDK 清理完成后,它使用正常重试预算。已绑定连接保留关闭事件屏障,防止服务器进程重叠。
 
 共享结果适配函数向每个提供方回调传递原始 `ToolExecution`,保留其 Agent 身份和取消信号。原生 Cua Driver 的结果会先通过 SDK 公开的规范类型校验,再生成规范值投影;适配函数不接受另一种宽松结果格式。
 
@@ -26,4 +28,6 @@ MCP 服务器使用不同协议版本。围绕旧版 SDK 实现发现与执行
 
 ## 影响
 
-Stdio 协商可能启动可释放的探测进程。SDK 通过页数上限约束发现,格式错误的结果会在投影前失败。有效文本、规范 JSON、图片接纳、取消与注册归属仍是桥接器的约定。Elicitation、资源、提示模板及任务执行仍不受支持。
+Stdio 协商启动可释放的探测进程,并等待其退出后才启动实际服务进程。SDK 通过页数上限约束发现,格式错误的结果会在投影前失败。有效文本、规范 JSON、图片接纳、取消与注册归属仍是桥接器的约定。Elicitation、资源、提示模板及任务执行仍不受支持。
+
+真实 SDK 生命周期测试验证探测释放、进程顺序、HTTP 探测重试预算及 stdio 启动失败。连接监督器测试保留已绑定传输的关闭屏障与有界失败行为。

+ 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: 85724ec89a06d6f8e3e6f4aa894f21e7be1b610f
-README.zh.md: 022de02922a2615408b6f2f69406c07aa22fe773
+README.md: e1004f2436eb45fe8d8cd81a850411ac71dc6b24
+README.zh.md: 960eb82423dbf64ad19597c7aa50184c6bd5d8eb

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

@@ -123,7 +123,7 @@ The exported `createMcpToolDefinition(ctx, options)` adapts an upstream tool sch
 
 ### 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.
+`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 negotiating transport or attached client, waits for the in-flight attempt and queued syncs to quiesce, and unregisters the current generation.
 
 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.
 
@@ -192,7 +192,7 @@ These limits describe what you cannot do with this plugin and when it needs oper
 
 - **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 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.
+- **Reconnect handles failed negotiation and transport close** — a failed initial probe or crashed stdio child uses the configured reconnect budget. Once HTTP is connected, request failures use the SDK transport's recovery rather than respawning the connection.
 - **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.
 - **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.

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

@@ -123,7 +123,7 @@ kind: "package-reference"
 
 ### 生命周期与同步
 
-`apply` 解析重连策略、在当前注册作用域内预留 `serverName`、启动监督器,并等待初始连接加发现完成。独立 agent(智能体)作用域可以复用相同 namespace,因为其工具与传输彼此隔离;同一作用域内重复会在加载时失败。监督器把所有同步——初始、通知与重连——串行到同一条队列,因此两次同步绝不会交错执行各自的先 dispose 后注册交换。dispose 会取消待执行的重连、关闭活动客户端、等待进行中的尝试与排队同步完全停稳,然后注销当前世代。
+`apply` 解析重连策略、在当前注册作用域内预留 `serverName`、启动监督器,并等待初始连接加发现完成。独立 agent(智能体)作用域可以复用相同 namespace,因为其工具与传输彼此隔离;同一作用域内重复会在加载时失败。监督器把所有同步——初始、通知与重连——串行到同一条队列,因此两次同步绝不会交错执行各自的先 dispose 后注册交换。dispose 会取消待执行的重连、关闭协商中的传输或已绑定的客户端、等待进行中的尝试与排队同步完全停稳,然后注销当前世代。
 
 SDK 通过旧版通知或现代协议订阅接收工具列表变化。监督器将每次重新同步排队;获取失败时保留之前的注册代,注册冲突则回滚本次尝试。每次故障共享一个尝试预算:连续失败达到 `maxAttempts` 后注销工具并停止重连;连接持续超过 `maxDelayMs` 则重置预算。
 
@@ -192,7 +192,7 @@ SDK 通过旧版通知或现代协议订阅接收工具列表变化。监督器
 
 - **只桥接 MCP 的工具能力**——资源与提示词没有 harness 消费机制,暂缓实现。
 - **启动与发现超时继承自 MCP SDK**——插件不暴露单独的连接或发现超时。协商与发现使用 SDK 默认的 60 秒请求超时;发现也使用 SDK 的页数上限。
-- **重连在传输关闭时触发**——崩溃的 stdio 子进程会触发重连;Streamable HTTP 失败按请求经 SDK 传输自身的恢复机制暴露,因此不可达的 HTTP 服务器会按调用重试,而非由 supervisor 重新 spawn。
+- **重连处理协商失败与传输关闭**——初始探测失败或 stdio 子进程崩溃都会使用配置的重连预算。HTTP 建立连接后,请求失败使用 SDK 传输的恢复机制,而非重新创建连接。
 - **图片是唯一的持久丰富结果桥接**——PNG、JPEG、WebP 与 GIF 在确切能力得到证明后进入 Native 上下文。音频与嵌入资源载荷仍只存在于执行局部并带明确诊断,资源链接只以文本保留名称与 URI。
 - **无效的协议结果或输出 schema 由 SDK 拒绝**——桥接器不接受旧式 `toolResult` 替代结果,也不绕过已声明的 schema 校验。
 - **要求基于任务的 MCP 工具在调用时被拒绝**——要求使用基于任务的执行(task-based execution)扩展的工具会抛出异常而非被桥接;该扩展未实现。

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

@@ -15,7 +15,7 @@
  * @module
  */
 
-import { Client } from '@modelcontextprotocol/client'
+import { Client, type Transport } 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'
@@ -103,9 +103,9 @@ export interface ConnectionHandle {
    */
   ready: Promise<ConnectionOutcome>
   /**
-   * Stop reconnection, close the live client, wait for the in-flight attempt
-   * and queued tool syncs to quiesce, then unregister every tool this server
-   * still owns.
+   * Stop reconnection, close the negotiating transport or live client, wait
+   * for the in-flight attempt and queued tool syncs to quiesce, then
+   * unregister every tool this server still owns.
    */
   dispose(): Promise<void>
 }
@@ -121,6 +121,7 @@ export interface ConnectionHandle {
  */
 export function startConnection(ctx: Context, config: Config, policy: ResolvedReconnectPolicy): ConnectionHandle {
   const label = `mcp-client(${config.serverName})`
+  const incompleteDisposalMessage = `${label}: transport closure could not be confirmed during disposal — server shutdown may be incomplete`
   const opts: ToolBridgeOptions = {
     registrationFailure: 'contain',
     serverName: config.serverName,
@@ -136,8 +137,8 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe
   let disposed = false
   /** Current generation: the connecting or connected client; undefined during backoff waits and after final failure. */
   let client: Client | undefined
-  /** Close signal paired with {@link client}; captured by dispose before current ownership is cleared. */
-  let clientClosed: Promise<void> | undefined
+  /** Transport-aware close operation paired with {@link client}. */
+  let closeClient: (() => Promise<boolean>) | undefined
   /** Live tool registrations owned by this server; only {@link enqueueSync} and dispose swap it. */
   let disposers: ToolDisposers = new Map()
   let reconnectTimer: NodeJS.Timeout | undefined
@@ -172,7 +173,7 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe
   function generationDown(generation: Client): void {
     if (!isCurrent(generation)) return
     client = undefined
-    clientClosed = undefined
+    closeClient = undefined
     scheduleReconnect()
   }
 
@@ -251,9 +252,10 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe
     const closed: PromiseWithResolvers<void> = Promise.withResolvers()
     let attemptSettled = false
     let closeObserved = false
+    let transport: Transport | undefined
     const hasClosed = (): boolean => closeObserved
     client = generation
-    clientClosed = closed.promise
+    closeClient = closeGeneration
     generation.onclose = () => {
       closeObserved = true
       closed.resolve()
@@ -261,6 +263,16 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe
       // established generation can transition down directly from this signal.
       if (attemptSettled) generationDown(generation)
     }
+    /** Unattached probes close through their transport; attached clients must also report transport closure. */
+    async function closeGeneration(): Promise<boolean> {
+      const attached = generation.transport !== undefined
+      try {
+        await (attached ? generation.close() : transport?.close())
+      } catch (_error) {
+        if (!attached) return hasClosed()
+      }
+      return !attached || hasClosed() || await waitForClose(closed.promise)
+    }
     async function refreshTools(): Promise<void> {
       if (!isCurrent(generation)) return
       ctx.logger.info(`${label}: tool list changed, re-syncing`)
@@ -271,26 +283,30 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe
       }
     }
     try {
-      await generation.connect(createTransport(config))
+      transport = createTransport(config)
+      await generation.connect(transport)
       if (hasClosed()) {
         attemptSettled = true
         generationDown(generation)
         return
       }
+      if (!isCurrent(generation)) {
+        if (!await closeGeneration()) ctx.logger.error(incompleteDisposalMessage)
+        return
+      }
       await enqueueSync(generation, startup ? startupOpts : opts)
     } catch (error) {
       if (firstAttemptError === undefined) firstAttemptError = error
       // Disposal clears current ownership before it closes the generation, so
       // only a live supervisor reports an attempt failure.
       if (isCurrent(generation)) ctx.logger.warn(`${label}: connection attempt failed: ${String(error)}`)
-      try { await generation.close() } catch { /* transport already gone */ }
-      const quiesced = hasClosed() || await waitForClose(closed.promise)
+      const quiesced = await closeGeneration()
       attemptSettled = true
       if (!isCurrent(generation)) return
       if (!quiesced) {
         client = undefined
-        clientClosed = undefined
-        ctx.logger.error(`${label}: failed generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms — reconnect stopped to avoid overlapping server processes; reload the plugin or restart the Host to retry`)
+        closeClient = undefined
+        ctx.logger.error(`${label}: failed generation could not confirm transport closure — reconnect stopped to avoid overlapping server processes; reload the plugin or restart the Host to retry`)
         return
       }
       generationDown(generation)
@@ -332,15 +348,11 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe
         clearTimeout(reconnectTimer)
         reconnectTimer = undefined
       }
-      const current = client
-      const currentClosed = clientClosed
+      const close = closeClient
       client = undefined
-      clientClosed = undefined
-      if (current !== undefined) {
-        try { await current.close() } catch { /* transport already gone */ }
-        if (currentClosed !== undefined && !await waitForClose(currentClosed)) {
-          ctx.logger.error(`${label}: generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms during disposal — server shutdown may be incomplete`)
-        }
+      closeClient = undefined
+      if (close !== undefined && !await close()) {
+        ctx.logger.error(incompleteDisposalMessage)
       }
       // Quiesce, don't just request it: the in-flight attempt enqueues its
       // sync before settling, so awaiting both leaves `disposers` final.

+ 1 - 1
packages/mcp/mcp-client/src/tools.ts

@@ -328,7 +328,7 @@ function isImageMediaType(value: string): value is ImageMediaType {
   return IMAGE_MEDIA_TYPES.includes(value as ImageMediaType)
 }
 
-/** Decode one SDK-admitted image without accepting base64 aliases. */
+/** Decode one projected 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')

+ 1 - 0
packages/mcp/mcp-client/tests/apply.spec.ts

@@ -22,6 +22,7 @@ const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotification
   ) => Promise<unknown>>()
   const mockSetNotificationHandler = vi.fn()
   class MockClient {
+    transport = {}
     connect = mockConnect
     close = mockClose
     listTools = mockListTools

+ 38 - 0
packages/mcp/mcp-client/tests/fixtures/negotiation-lifecycle.mjs

@@ -0,0 +1,38 @@
+/** Legacy stdio server with a file barrier around protocol discovery. */
+import { appendFileSync, existsSync, readFileSync } from 'node:fs'
+import { createInterface } from 'node:readline'
+import { setTimeout } from 'node:timers/promises'
+
+const [eventsPath, releasePath] = process.argv.slice(2)
+const record = (event, extra = {}) => appendFileSync(eventsPath, `${JSON.stringify({ event, pid: process.pid, ...extra })}\n`)
+const previous = readFileSync(eventsPath, 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line))
+const previousPid = previous.findLast(item => item.event === 'start')?.pid
+let previousAlive = false
+if (previousPid !== undefined) {
+  try {
+    process.kill(previousPid, 0)
+    previousAlive = true
+  } catch (error) {
+    if (error.code !== 'ESRCH') throw error
+  }
+}
+record('start', { previousAlive })
+process.once('exit', () => record('exit'))
+process.stdin.once('end', () => process.exit(0))
+
+createInterface({ input: process.stdin }).on('line', async (line) => {
+  const request = JSON.parse(line)
+  if (request.id === undefined) return
+  record(request.method)
+  if (request.method === 'server/discover') {
+    while (!existsSync(releasePath)) await setTimeout(10)
+    process.stdout.write(`${JSON.stringify({
+      jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Legacy server' },
+    })}\n`)
+    return
+  }
+  const result = request.method === 'initialize'
+    ? { protocolVersion: request.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: 'fixture', version: '1' } }
+    : { tools: [] }
+  process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\n`)
+})

+ 149 - 0
packages/mcp/mcp-client/tests/negotiation-lifecycle.spec.ts

@@ -0,0 +1,149 @@
+/** Real SDK probe ownership, failed negotiation recovery, and subprocess quiescence. */
+import { once } from 'node:events'
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { createServer } from 'node:http'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { StreamableHTTPClientTransport, type Transport } from '@modelcontextprotocol/client'
+import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'
+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: false,
+}
+const fixture = fileURLToPath(new URL('./fixtures/negotiation-lifecycle.mjs', import.meta.url))
+
+async function connection(factory: () => Transport, retry: boolean) {
+  const ctx = new Context()
+  onTestFinished(() => ctx.fiber.dispose())
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRuntime)
+  const errors: string[] = []
+  const warns: string[] = []
+  ctx.logger.error = (message: unknown) => { errors.push(String(message)) }
+  ctx.logger.warn = (message: unknown) => { warns.push(String(message)) }
+  const transports: Transport[] = []
+  mockTransport.mockImplementation(() => {
+    const transport = factory()
+    transports.push(transport)
+    return transport
+  })
+  const handle = startConnection(ctx, config, resolveReconnectPolicy({
+    enabled: retry, initialDelayMs: 1, maxDelayMs: 1, maxAttempts: 3,
+  }, 'fixture'))
+  onTestFinished(async () => {
+    for (const transport of transports) await transport.close()
+    await handle.dispose()
+  })
+  return { ...handle, errors, warns, transports }
+}
+
+async function stdioFixture() {
+  const root = await mkdtemp(join(tmpdir(), 'dsh-mcp-negotiation-'))
+  onTestFinished(() => rm(root, { recursive: true, force: true }))
+  const eventsPath = join(root, 'events.jsonl')
+  const releasePath = join(root, 'release')
+  await writeFile(eventsPath, '')
+  const events = async (): Promise<{ event: string; pid: number; previousAlive?: boolean }[]> => (await readFile(eventsPath, 'utf8'))
+    .split('\n').filter(Boolean).map(line => JSON.parse(line) as { event: string; pid: number; previousAlive?: boolean })
+  const handle = await connection(() => new StdioClientTransport({
+    command: process.execPath, args: [fixture, eventsPath, releasePath], env: {},
+  }), false)
+  return { handle, events, release: () => writeFile(releasePath, '') }
+}
+
+describe('SDK negotiation lifecycle', () => {
+  it('reaps the probe before starting the serving process', async () => {
+    const { handle, events, release } = await stdioFixture()
+    await release()
+    expect(await handle.ready).toEqual({})
+    const observed = await events()
+    const starts = observed.filter(item => item.event === 'start')
+    expect(starts).toHaveLength(2)
+    expect(starts[1]!.previousAlive).toBe(false)
+    await handle.dispose()
+    for (const item of starts) expect(() => process.kill(item.pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
+  })
+
+  it('disposes during a probe without starting or retaining a serving process', async () => {
+    const { handle, events, release } = await stdioFixture()
+    await vi.waitFor(async () => {
+      expect((await events()).some(item => item.event === 'server/discover')).toBe(true)
+    }, { timeout: 15_000 })
+    const disposing = handle.dispose()
+    await release()
+    await disposing
+    const starts = (await events()).filter(item => item.event === 'start')
+    expect(starts).toHaveLength(1)
+    for (const item of starts) expect(() => process.kill(item.pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
+    expect(handle.errors).toEqual([])
+  })
+
+  it('retries failed HTTP probes without waiting for a Client close event', async () => {
+    let requests = 0
+    const server = createServer((_request, response) => {
+      requests += 1
+      response.writeHead(503)
+      response.end('temporary failure')
+    })
+    onTestFinished(async () => {
+      server.closeAllConnections()
+      const closed: PromiseWithResolvers<void> = Promise.withResolvers()
+      server.close((error) => {
+        if (error) closed.reject(error)
+        else closed.resolve()
+      })
+      await closed.promise
+    })
+    server.listen(0, '127.0.0.1')
+    await once(server, 'listening')
+    const address = server.address()
+    if (address === null || typeof address === 'string') throw new Error('HTTP fixture did not bind a TCP address')
+    const url = new URL(`http://127.0.0.1:${address.port}/mcp`)
+    const handle = await connection(() => new StreamableHTTPClientTransport(url), true)
+    expect((await handle.ready).error).toBeDefined()
+    expect(handle.errors).toEqual([])
+    await vi.waitFor(() => { expect(handle.errors.some(line => line.includes('giving up after 3'))).toBe(true) })
+    expect(requests).toBe(4)
+    expect(handle.warns.filter(line => line.includes('connection failed; retrying'))).toHaveLength(3)
+  })
+
+  it('retries failed stdio probes when no child could be spawned', async () => {
+    const handle = await connection(() => new StdioClientTransport({
+      command: join(fixture, 'missing-command'), env: {},
+    }), true)
+    expect((await handle.ready).error).toBeDefined()
+    expect(handle.errors).toEqual([])
+    await vi.waitFor(() => { expect(handle.errors.some(line => line.includes('giving up after 3'))).toBe(true) })
+    expect(handle.transports).toHaveLength(4)
+    expect(handle.warns.filter(line => line.includes('connection failed; retrying'))).toHaveLength(3)
+  })
+
+  it('stops retries when a failed probe cannot confirm transport cleanup', async () => {
+    const close = vi.fn()
+      .mockRejectedValueOnce(new Error('SDK shutdown failed'))
+      .mockRejectedValueOnce(new Error('supervisor shutdown failed'))
+      .mockResolvedValue(undefined)
+    const handle = await connection(() => ({
+      start: () => Promise.reject(new Error('probe failed')),
+      send: () => Promise.resolve(),
+      close,
+    }), true)
+    expect((await handle.ready).error).toBeDefined()
+    expect(handle.errors).toEqual([
+      'mcp-client(fixture): failed generation could not confirm transport closure — reconnect stopped to avoid overlapping server processes; reload the plugin or restart the Host to retry',
+    ])
+    expect(handle.warns.some(line => line.includes('connection failed; retrying'))).toBe(false)
+    expect(handle.transports).toHaveLength(1)
+  })
+})

+ 32 - 4
packages/mcp/mcp-client/tests/reconnect.spec.ts

@@ -24,6 +24,7 @@ const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotification
   ) => Promise<unknown>>()
   const mockSetNotificationHandler = vi.fn()
   class MockClient {
+    transport: object | undefined = {}
     onclose: (() => void) | undefined
     connect = mockConnect
     close = mockClose
@@ -46,7 +47,7 @@ vi.mock('@modelcontextprotocol/client', async importOriginal => ({
 }))
 
 vi.mock('@modelcontextprotocol/client/stdio', () => ({
-  StdioClientTransport: vi.fn(),
+  StdioClientTransport: vi.fn(function () { return { close: () => Promise.resolve() } }),
 }))
 
 // vi.mock is hoisted above static imports, so the modules under test see the
@@ -266,12 +267,13 @@ describe('reconnect supervisor', () => {
     expect(instances).toHaveLength(1)
   })
 
-  it('bounds disposal while a resolving generation never reports that it closed', async () => {
+  it.each(['connect', 'discovery'] as const)('bounds disposal during %s when the transport never reports closure', async (phase) => {
     vi.useFakeTimers()
     try {
       const { errors } = captureLogs(ctx)
       const gate: PromiseWithResolvers<void> = Promise.withResolvers()
-      mockConnect.mockImplementation(() => gate.promise)
+      if (phase === 'connect') mockConnect.mockImplementation(() => gate.promise)
+      else mockListTools.mockImplementation(() => gate.promise.then(() => listing('late')))
       mockClose.mockResolvedValue(undefined)
       const handle = startConnection(ctx, stdioConfig(), resolveReconnectPolicy(undefined, 'reconnect'))
       await vi.advanceTimersByTimeAsync(0)
@@ -279,15 +281,41 @@ describe('reconnect supervisor', () => {
       const disposing = handle.dispose()
       await vi.advanceTimersByTimeAsync(5_000)
       gate.resolve()
+      await vi.advanceTimersByTimeAsync(5_000)
       await disposing
 
-      expect(mockListTools).not.toHaveBeenCalled()
+      expect(mockListTools).toHaveBeenCalledTimes(phase === 'connect' ? 0 : 1)
+      expect(ctx.tools.get('mcp__srv__late')).toBeUndefined()
       expect(errors.some(line => line.includes('server shutdown may be incomplete'))).toBe(true)
     } finally {
       vi.useRealTimers()
     }
   })
 
+  it('closes a transport that attaches after disposal starts', async () => {
+    const gate: PromiseWithResolvers<void> = Promise.withResolvers()
+    mockConnect.mockImplementation(function (this: { transport: object | undefined }) {
+      this.transport = undefined
+      return gate.promise.then(() => { this.transport = {} })
+    })
+    const handle = startConnection(ctx, stdioConfig(), resolveReconnectPolicy(undefined, 'reconnect'))
+    const disposing = handle.dispose()
+    gate.resolve()
+    await disposing
+    expect(mockClose).toHaveBeenCalledTimes(1)
+    expect(mockListTools).not.toHaveBeenCalled()
+  })
+
+  it('discards a queued tool refresh when disposal starts before it runs', async () => {
+    const handle = startConnection(ctx, stdioConfig(), resolveReconnectPolicy(undefined, 'reconnect'))
+    await handle.ready
+    const notify = mockSetNotificationHandler.mock.calls[0]![1] as () => void
+    notify()
+    await handle.dispose()
+    expect(mockListTools).toHaveBeenCalledTimes(1)
+    expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
+  })
+
   it('dispose during the backoff wait cancels the pending reconnect', async () => {
     await apply(ctx, stdioConfig({ initialDelayMs: 60_000, maxDelayMs: 60_000, maxAttempts: 5 }))
     await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })