
qwen-code 守护进程中的 Workspace MCP Transport Pool多会话共享 MCP 传输连接的实现原理【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code本篇围绕 qwen-code 守护进程daemon模式下的McpTransportPool展开说明它如何让同一 workspace 内的多个 ACP 会话按(serverName configFingerprint)元组共享同一条 MCP 传输连接而不是为每个会话各 fork 一份 MCP 子进程。读完后你将掌握池化连接的 acquire/release/restart/drain 全生命周期、指纹与 OAuth 归一化规则、未池化HTTP/SSE/SDK-MCP旁路、预算护栏联动以及 stdio 子进程后代清扫pid-descendants的跨平台实现细节。一、为什么需要 MCP 传输池在多会话守护进程场景中每个 ACP 会话若各自启动自己的 MCP 客户端同一个 MCP 服务器就会被 fork 出 N 份完全相同的子进程浪费内存、CPU 和端口还会让GET /workspace/mcp一类的资源面板失去准确的数据源。McpTransportPool位于 mcp-transport-pool.ts就是 F2 系列#4175 第 5 个提交引入的 workspace 级连接池同一个 runtime 内的多个 ACP 会话对每个唯一的(serverName configFingerprint)元组只共享一条传输连接而不是各自 spawn MCP 子进程。它是防止多会话 daemon 为每个会话 fork 一份所有 MCP 服务器的主要机制。池化模式的生效方式见 acpAgent.ts池启用时每个已启动的 ACP 子进程都拥有独立的池QwenAgent.mcpPool见 acpAgent.ts。生产环境会尽力预热受信任的主子进程primary child以兼容旧行为受信任的次子进程secondary按需启动不受信任的 secondary 既不启动子进程也不构建池。Legacy primary 路由保留既有兼容行为。池在 agent 启动时用 runtime 的 bootstrapConfig构造一次acpAgent.ts 的构造逻辑生命周期长于任何单个会话。条目entry以引用计数跟踪会话附着情况当引用计数归零后经过可配置的宽限期grace period才关闭传输。二、职责边界McpTransportPool的职责可以归纳为八条按(name fingerprint)获取或 spawn 一条 MCP 传输通过spawnInFlight去重并发的冷启动获取。释放每会话引用当最后一个引用 detach 时为该条目武装armdrain 定时器。用硬性MAX_IDLE_MS上限抵御引用计数抖动thrashing——恶意或异常的客户端无法通过反复 attach/detach 让空闲传输永远存活。通过反向索引sessionToEntries维护会话到条目的映射使releaseSession(sessionId)的复杂度为 O(refs) 而不是 O(entries)。按需重启条目restartByName——单条目返回{restarted, durationMs}多条目返回{entries: RestartResult[]}F2 多条目契约。在 daemon 关闭时按可配置超时间歇整个池drainAlldraining 期间拒绝新的 acquire。在acquire时咨询WorkspaceMcpBudget强制执行按名称的预留上限当没有兄弟条目持有同名时在条目关闭时释放槽位。通过SessionMcpView产出按会话过滤的 tools/prompts 快照避免某个会话的发现discovery把工具注册进其他会话。三、公开接口与配置选项3.1 公开 surfaceclass McpTransportPool { constructor(cliConfig: Config, options: McpTransportPoolOptions); acquire( serverName, cfg, sessionId, sessionToolRegistry, sessionPromptRegistry, ): PromisePooledConnection; release(id, sessionId): void; releaseSession(sessionId): void; restartByName( name, opts?, ): PromiseRestartResult | { entries: RestartResult[] }; drainAll(opts?): Promisevoid; getBudget(): WorkspaceMcpBudget | undefined; getSnapshot(): McpPoolSnapshot; }McpTransportPoolOptions定义于 mcp-transport-pool.tsworkspaceContext: WorkspaceContext必填——所有条目共享的 daemon 绑定 workspace 上下文。debugMode: boolean——透传给McpClient的调试日志开关。sendSdkMcpMessage?——每会话回调池模式下绕过 SDK MCP。pooledTransports?: ReadonlySetMcpTransportKind——默认{stdio, websocket}mcp-pool-key.ts 中的POOLED_TRANSPORTS_DEFAULT。HTTP/SSE 默认不池化因为其 headers 可能携带会话级 OAuth 状态运维可用QWEN_SERVE_MCP_POOL_TRANSPORTS显式将其纳入池化。drainDelayMs?——默认30_000。entryOptions?: (transport) PoolEntryOptions——逐条目覆盖。budget?: WorkspaceMcpBudget——workspace 级预算控制器缺省意味着完全不强制每会话McpClientManager的预算机制在池模式下本就不生效。3.2 内部状态状态类型用途entriesMapConnectionId, PoolEntry以connectionIdOf(name, fingerprint)为键的存活池条目。unpooledIdsSetConnectionId不在pooledTransports允许列表内的传输对应的条目。spawnInFlightMapConnectionId, PromisePoolEntry去重同一键的并发冷获取。sessionToEntriesMapstring, SetConnectionId反向索引用于 O(refs) 的releaseSession。drainingbooleandrain 互斥量——一旦置位所有acquire调用被拒绝。nextIndexByNameMapstring, number按服务器名的单调entryIndex计数器新条目出现时面板不重排。以上六块私有状态在 mcp-transport-pool.ts 中集中声明与文档描述一一对应。3.3PoolEntry逐条结构池条目的状态机为spawning → active ⇄ (active ↔ reconnect) → (active → draining on last detach, draining → active on attach OR draining → closed on timer)实现在 mcp-pool-entry.ts。字段用途localStatus: MCPServerStatus由MCPServerStatus生命周期驱动。state: PoolEntryStatespawning/active/draining/closed/failed。generation: number每次 restart 递增订阅方据此比较以检测重连周期。refs: Setstring当前附着会话的 session id 集合。subscribers: Mapstring, SessionMcpView按会话过滤的视图。subscriberHandles: Mapstring, PooledConnectionImplacquire返回的句柄。toolsSnapshot[],promptsSnapshot[]池级规范快照在toolsChanged/promptsChanged时重新发放。drainTimer?在refs.size 0时武装默认 30sattach 时重置。maxIdleTimer?首次空闲时武装不被 acquire/release 抖动重置。默认 5 分钟。firstIdleAt?max-idle 硬性上限的时间基准。restartInFlight?restart()的互斥量。3.4PoolEntryOptions与默认值interface PoolEntryOptions { drainDelayMs: number; // 默认 30_000 maxIdleMs: number; // 默认 5 * 60_000 maxReconnectAttempts: number; // stdio/ws 默认 3http/sse 默认 5 reconnectStrategy: | { kind: fixed; delayMs: number } | { kind: exponential; baseMs: number; capMs: number }; }defaultPoolEntryOptions(transport)mcp-pool-entry.ts返回两类默认值stdio/ws{fixed 5s, 3 attempts}http/ssewebsocket 被归入remote一类{exponential 1s → 16s, 5 attempts}。远程传输获得更长的重试预算因为其故障更常是瞬时的。值得注意的是源码中的一段注释maxReconnectAttempts与reconnectStrategy目前没有被任何池内代码路径消费——池模式下暂无健康监控器——该分类是为将来健康监控落地做的预备字段保留以维持设计契约见 mcp-pool-entry.ts。四、核心工作流4.1acquire获取或冷启动关键点可池化判定由isPoolablemcp-pool-key.ts执行SDK MCP 服务器一律旁路其设计就是每会话的其他传输按运维的pooledTransports选择放行。预算预检冷启动前先tryReserve(name)被拒则记录recordRefusal并抛BudgetExhaustedError。该异常类与每会话McpClientManager抛出的是同一个类mcp-transport-pool.ts 直接从 manager 复用导入保证 SDK 消费者看到的错误类一致。spawn 失败回滚冷启动中途崩溃会释放已预留的预算槽位V21-4否则会永久泄漏预留。4.2release与 drain 时序两个值得注意的实现细节hasNameSibling(name)mcp-transport-pool.ts同时遍历entries.values()与spawnInFlight.keys()后者用parseConnectionId解析。之所以不能对兄弟名用startsWith前缀匹配是因为 MCP 服务器名可以合法地包含::——以${name}::开头的兄弟名会产生假阳性。parseConnectionId使用lastIndexOf(::)切分mcp-pool-key.ts该测试守护点在 mcp-pool-key.test.ts。releaseSession(sessionId)从sessionToEntries反向索引读取以 O(refs) 释放该会话引用的所有条目然后清除索引项。它被 bridge 的会话关闭路径使用避免遍历整张条目表。4.3restartByName在 daemon HTTP 层预检预算检查会在目标槽位尚未被预留且重启会使存活数超过enforce预算时返回{restarted:false, skipped:true, reason:budget_would_exceed}Wave 4 变更控制。在 ACP 代理侧restartByName的实际调用点见 acpAgent.ts。4.4drainAll守护进程关闭时排空全池五、状态与生命周期不变量池的构造是同步的首次acquire触发传输冷启动。drainDelayMs默认 30s在 attach 时被取消。maxIdleMs默认 5 分钟永不被 attach/detach 重置——它在首次空闲时开始计时只在条目真正关闭或到点前重新附着时才停止。这是对抖动客户端的防御。nextIndexByName单调递增旧条目即使在新条目出现后也保留其分配的索引面板读取entryIndex时不会重排。spawn 失败会释放已预留的预算槽位V21-4——没有这一步一次在 connect 中途崩溃的冷启动会永久泄漏预留。六、配置项与开关来源开关效果环境变量QWEN_SERVE_NO_MCP_POOL1Kill switch——QwenAgent.mcpPool保持undefined回退到每会话McpClientManagerF2 之前的路径。CLI Flag--mcp-client-budgetN、--mcp-budget-mode{off,warn,enforce}经childEnvOverrides转发到 ACP 子进程子进程构造WorkspaceMcpBudget并传给池。能力标签条件性mcp_workspace_pool、mcp_pool_restart池开启时同时通告。SDK 会对两者做预检以分支处理池感知的响应形态。Kill switch 的判定逻辑在 acpAgent.ts当process.env[QWEN_SERVE_NO_MCP_POOL] 1时不构建池能力标签mcp_workspace_pool等也在capabilities与 serve 状态路径中联动相关代码见 capabilities.ts 与 status.ts。未池化条目HTTP / SSE / SDK-MCP不在pooledTransports允许列表内的传输默认包括 HTTP、SSE 与 SDK-MCP走单独路径createUnpooledConnection(name, cfg, sessionId, ...)mcp-transport-pool.ts为每个会话创建 id 形如${name}::unpooled-${entryIndex}的条目。与池化条目的差异条目存入entries的同时也被unpooledIds: SetConnectionId跟踪使release/releaseSession可以走 close-on-detach 的快速路径refs 上限恒为 1。直接使用McpClient.discover()不做池快照回放applyTools/applyPrompts为空操作attach()中的 W77 /skipReplay: true因为会话注册表已经持有注册过的内容。workspace 预算依然会拦截它们——F2 预算后续修复了未池化连接绕过tryReserve的漏洞同样的WorkspaceMcpBudget槽位会被预留并在条目关闭时释放无论池化与否。W77 竞态提交cb206da36createUnpooledConnection在await client.connect()/client.discover()之前就把条目存入this.entries但attach()成功之后才把sessionToEntries[sessionId]建索引。若在这段 connect/discover 窗口内并发执行closeStoredSession()/releaseSession(sessionId)会看到空索引让未池化 spawn 完成随后attach()把 tools/prompts 注册进一个已经关闭的会话。修复方案mcp-pool-entry.ts公开的isTerminated(): boolean探针state closed || state failed。mcp-pool-entry.tsmarkActive()在isTerminated()时短路避免已拆毁的条目被复活为active。调用方池的未池化路径在 await 之间探测isTerminated()若父会话已消失则中止 attach。该竞态当时尚属潜伏W61/W71 的每会话releaseSession钩子要到 F4 才落地但那个钩子一到就会引爆因此修复被提前到了 F2 系列内完成。七、GET /workspace/mcp的池感知快照字段池激活时ServeWorkspaceMcpStatus的每个服务器单元格status.ts额外携带三个字段字段类型用途disabledReasonconfig \| budget区分运维禁用的服务器来自disabledMcpServers的disabled: true与预算拒绝status: error, errorKind: budget_exhausted。面板可以只渲染一行服务器而无需交叉读取errors[]或budgets[]。entryCountnumber1池模式下同一 workspace 可能出现多个同名的PoolEntry实例因为不同会话可能注入不同指纹如每会话 OAuth headers。当QWEN_SERVE_NO_MCP_POOL1禁用池时该字段不存在。新客户端在entryCount 1时渲染 N entries 徽章。entrySummaryReadonlyArray{entryIndex, refs, status}逐条目明细。entryIndex是条目创建时分配的稳定不透明整数不是原始指纹因此快照 diff 不会泄漏 OAuth 或 env 轮换时机。refs是当前附着会话数。status让面板在聚合mcpStatus已 connected 的情况下仍能展示逐条目健康度。(entryCount, entrySummary)总是成对广播mcp_workspace_pool能力标签隐含这两个字段。旧版 SDK 客户端在增量式additive协议契约下直接忽略它们。池快照还暴露subprocessCount且只统计stdio族WebSocket、HTTP、SSE 传输连接的是远程服务器不产生本地子进程。早期版本把 WebSocket 传输计为本地子进程导致资源面板虚高。八、drain 覆盖两条关闭路径池的 drain 不只挂在 SIGTERM handler 上。正常的 IDE 关闭路径await connection.closed同样经由drainPoolBeforeExitacpAgent.ts调用drainAll。从源码调用点看三类触发场景分别对应超时/退出路径、进程信号signal、以及 IDE 主动关闭ide_close见 acpAgent.ts。无论 daemon 收到进程信号还是 IDE 干净地关闭连接池都会进入draining、拒绝新 acquire、并等待条目关闭。池内的drainAll({ force: true, timeoutMs })调用点在 acpAgent.ts。九、/mcp refresh与启动发现共用同一网关discoverAllMcpTools启动发现与discoverAllMcpToolsIncremental/mcp refresh/ 热重载在池模式下都先咨询池mcp-client-manager.ts。这条共享网关防止热重载意外创建每会话客户端、双计预算或留下孤儿传输。十、重连期间在途工具调用MCPCallInterruptedError当底层 MCP 传输静默断开连接从active/draining跳到localStatus DISCONNECTED而无显式 close时池将条目标记为failed、从pool.entries中驱逐、并在 detach 订阅视图之前发出failed事件。这个先 emit 后 detach的顺序很关键订阅方能尽早收到failed事件从而把挂起的callToolPromise 路由到MCPCallInterruptedError——卡住的await client.callTool(...)会干净地 reject 而不是永久挂起。forceShutdown使用同样的 emit-then-detach 顺序。十一、指纹与canonicalOAuth归一化池键来自 mcp-pool-key.ts 的fingerprint(cfg)取 SHA-256 前 16 个十六进制字符64 bit。在现实池规模每 workspace 通常 100 条下生日碰撞概率低于 10^-15可安全地直接作为 map 键。哈希覆盖所有传输定义字段transport, command, args, cwd, env, url, httpUrl, tcp, headers, timeout, versionNegotiation, oauth源码中实际还包括authProviderType、targetAudience、targetServiceAccount见 mcp-pool-key.ts。逐会话过滤与元数据字段includeTools、excludeTools、trust、description、extensionName、discoveryTimeoutMs被排除因此带不同过滤器的会话可以共享同一条目。自动版本协商的 opt-in 被纳入哈希因为它改变底层进程的建连方式。对 OAuth 字段canonicalOAuth(o)mcp-pool-key.ts哈希MCPOAuthConfig的每一个字段clientId、clientSecret、排序后的scopes、排序后的audiences、authorizationUrl、tokenUrl、redirectUri、tokenParamName与registrationUrl。这就是凭据隔离契约两个仅在clientSecret、audiences或redirectUri上有差异的会话配置会得到不同指纹、无法共享条目——机密客户端confidential client与多受众multi-audience令牌部署依赖于此。其余归一化规则scopes与audiences排序调用方书写顺序无关显式null归一化使undefined字段与显式null得到相同哈希。键不包含discoveryTimeoutMs同一键、不同超时并发的 acquire 遵循先到者胜与 F2 之前每会话 manager 的行为一致。PoolEntry将cfg: MCPServerConfig保持私有外部需要传输族时只能用entry.transportKindgetter——这防止 env、header 鉴权与 OAuth 字段意外泄漏给消费者。ConnectionId的形式为${name}::${fp16hex}mcp-pool-key.ts同名不同指纹例如 OAuth 令牌或 env 在会话间分化产生不同的 ConnectionId。十二、扩展卸载依赖MAX_IDLE_MS系统有意没有在运行时卸载 MCP 扩展的主动清理路径。那些MCPServerConfig已不再出现在合并后 workspace 设置中的孤儿条目会在最后一个订阅者 detach 之后被MAX_IDLE_MS硬性上限自然回收。为罕见的运维边缘场景增加同步卸载清理路径的复杂度不值得硬性上限把卸载点之后的孤儿进程存活时间限制在默认 5 分钟内。需要更快清理的运维可以重启 daemon或对已下线的服务器名调用POST /workspace/mcp/:server/restart——它会走禁用服务器路径把条目拆掉。十三、自愈可观测性池在自愈路径上产出两类结构化诊断1.McpClient.lastTransportError: Error | undefinedmcp-client.ts——McpClient.onerror把最近一次传输异常存入私有字段并在connect()入口清空。PoolEntry的静默丢弃silent-drop路径读取client.getLastTransportError()并放进emit({kind:failed, lastError})订阅方与面板不必再去 grep stderr 找根因。2.SweepResult内部接口不导出mcp-pool-entry.ts——sweepAndDisconnect(reason)返回PromiseSweepResultinterface SweepResult { pidSweepError?: Error; // listDescendantPids 本身抛异常 descendantsFound?: number; // 发现的 descendant pid 数量 descendantsSignaled?: number; // 成功 SIGTERM 的数量 }唯一消费者是statusChangeListener中的静默丢弃块。它用descendantsFound/descendantsSignaled检测部分信号情形信号数少于发现数通常是listDescendantPids与sigtermPids之间进程退出或发生 EPERM以及 sweep 错误然后记录结构化 warning。forceShutdown与doRestart忽略该返回值因为其 catch 路径已携带更丰富的失败信号。十四、子进程清扫pid-descendants快照路径当McpTransportPool关闭 stdio 子进程时必须枚举其后代进程——npx包装器与 shell 包装器可能产生多层 fork。pid-descendants.ts 暴露listDescendantPids(rootPid) → Promisenumber[]与sigtermPids(pids)供sweepAndDisconnect使用。Linux / macOS 主路径一次ps -A -o pid,ppid快照读取进程表解析成Mapppid, pid[]然后walkDescendants(tree, root)做 BFS 抽取子树。任意深度只需一次psfork。walkDescendants维护visited: Setnumber且把root也纳入该集合以防御 PID 复用造成的环。在快速进程更替下快照理论上可能包含 A→B / B→A 环路没有visitedwalker 会用假数据填满MAX_DESCENDANTS配额挤掉真正的后代。Windows 主路径一次Get-CimInstance Win32_Process | ConvertTo-Csv -Delimiter ,快照发出所有(ProcessId, ParentProcessId)行然后走同一套Map与walkDescendants路径。显式-Delimiter ,是必需的随 Windows 附带的 PowerShell 5.1 中ConvertTo-Csv默认使用系统 locale 的列表分隔符德语、法语、荷兰语、意大利语等 locale 使用;修复前的解析器^(\d),(\d)$永远匹配不上导致每次 daemon 关闭都退化为 per-pid CIM 过滤路径每个子进程额外付出约 0.5–1s 的 PowerShell 启动成本。回退路径BusyBox v1.28 缺少ps -odistroless 容器可能没有ps某些 Windows 环境会经 ACL 截断 CIM 输出。当主路径解析到零行或抛异常时代码回退为 per-pid BFSLinux / macOS 用pgrep -P pidWindows 用Get-CimInstance -Filter ParentProcessId$p其中$p是 PowerShell 变量绑定而非字符串拼接。当前Number.isInteger守卫对入口已足够变量绑定属于纵深防御。共享约束两条路径都受MAX_DESCENDANTS 256与MAX_DEPTH 8限界防止恶意或退化进程树拖垮 sweep。快照路径使用maxBuffer: 8MB足以覆盖约 25 万进程的病态主机Node 默认 1MB 缓冲在约 3 万进程处就会截断子进程输出。性能收益被有意保持温和典型 200–500 进程的开发机解析 10ms比 per-pidpgrep快约 2 倍。主要收益是 fork 卫生与快照一致性BFS 一次性看到完整子树而旧的 per-pid 查询路径可能漏掉两次查询之间 fork 出的孙子进程。十五、嵌入方说明与实现细节15.1McpClientManager构造嵌入方McpClientManager的构造签名是(config, toolRegistry, options?: McpClientManagerOptions)。直接导入该类的嵌入方应当传入new McpClientManager(config, toolRegistry, { eventEmitter, sendSdkMcpMessage, healthConfig, budgetConfig, pool, });测试中建议优先使用mkManager(overrides?)工厂让只关心一两个字段用例保持一行。15.2 内部实现注记以下 helper 是内部的但读源码时可能遇到McpTransportPool.acquire()使用attachPooledSession与rollbackReservationOnSpawnFailure来共享快速路径 attach、spawn 后 attach 与池化 spawn-in-flight 的 catch 行为。运行时行为不变竞态窗口不变量仍留在调用点。SessionMcpView.applyTools/applyPrompts通过compileNameFilter(cfg)把includeTools/excludeTools编译一次再用compiledFilterAccepts(compiled, name)检查每个工具。导出的passesSessionFilter/passesSessionPromptFilter走同一编译路径。excludeTools是精确匹配includeTools会剥掉首个(...)后缀使toolName(args)匹配toolName。十六、已知限制CaveatsHTTP / SSE 传输默认不池化——除非运维显式把二者纳入QW_SERVE_MCP_POOL_TRANSPORTS原文配置名为QWEN_SERVE_MCP_POOL_TRANSPORTS否则每次 acquire 都会铸出只随会话存活的 fresh 条目。其 headers 可能携带会话级 OAuth 状态默认池化有跨会话泄漏凭据的风险。maxIdleMs是穿越 attach/detach 抖动的硬上限。5 分钟空闲硬上限意味着即使激进地 attach/detach 的客户端也无法把空闲传输钉住超过 5 分钟。想要钉住长寿命传输的运维应调大maxIdleMs或把服务器移出池运行。按服务器名的预算槽位意味着两个共享名称但指纹不同的池条目合计只消耗一个槽位。子进程核算通过pool.getSnapshot().subprocessCount单独暴露。startsWith回归已在hasNameSibling中规避——MCP 服务器名可以合法包含::见 mcp-pool-key.test.ts。应始终使用parseConnectionId的lastIndexOf(::)切分绝不用字符串前缀匹配。池 drain 是单向的——drainAll永久置draining true要继续工作必须新建一个池。参考核心实现mcp-transport-pool.ts全文件、mcp-pool-entry.ts条目生命周期、mcp-pool-key.tsconnectionIdOf、parseConnectionId、fingerprint、mcp-pool-events.ts事件类型、session-mcp-view.ts每会话过滤视图、mcp-workspace-budget.ts预算护栏。守护进程侧调用点acpAgent.tsmcpPool构造、restartByName、drainPoolBeforeExit与 kill switch。测试mcp-transport-pool.test.ts、mcp-pool-key.test.ts、session-mcp-view.test.ts、mcp-workspace-budget.test.ts、pid-descendants.test.ts。设计文档f2-mcp-transport-pool.md 第 6 节覆盖传输池状态机、重连、drain 与后代清扫路径设计契约v2.2含 32 项评审合并变更日志为权威来源本文档所在页是开发者深度解读。预算护栏配套文档06-mcp-budget-guardrails.md。【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考