From 38d532cdbad56d8ff8ceded012a887c4f44e92c0 Mon Sep 17 00:00:00 2001 From: ytahdn <1294726970@qq.com> Date: Wed, 2 Sep 2026 13:33:37 +0000 Subject: [PATCH] feat(serve): add workspace-scoped MCP management (#10679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(serve): establish workspace runtime ownership * feat(serve): add workspace-scoped MCP management * fix(serve): address workspace MCP review findings * fix(serve): address follow-up MCP review findings * test(serve): stabilize MCP drain race coverage --------- Co-authored-by: 钉萁 --- docs/design/mcp-management-runtime-model.md | 29 ++ docs/design/workspace-runtime-architecture.md | 81 +-- packages/acp-bridge/src/bridge.test.ts | 226 +++++++- packages/acp-bridge/src/bridge.ts | 233 +++++---- packages/acp-bridge/src/bridgeErrors.ts | 7 + packages/acp-bridge/src/bridgeOptions.ts | 7 + packages/acp-bridge/src/status.ts | 14 + .../cli/src/acp-integration/acpAgent.test.ts | 85 ++- packages/cli/src/acp-integration/acpAgent.ts | 18 +- packages/cli/src/serve/acp-session-bridge.ts | 1 + .../src/serve/routes/workspace-mcp-config.ts | 492 ++++++++++++++++++ .../src/serve/routes/workspace-runtime-mcp.ts | 291 +++++++++++ .../src/serve/routes/workspace-settings.ts | 6 +- packages/cli/src/serve/run-qwen-serve.test.ts | 26 + packages/cli/src/serve/run-qwen-serve.ts | 14 + packages/cli/src/serve/server.ts | 19 + .../src/serve/server/error-response.test.ts | 17 +- .../cli/src/serve/server/error-response.ts | 8 + .../serve/workspace-qualified-rest.test.ts | 272 +++++++++- .../workspace-runtime-coordinator.test.ts | 469 ++++++++++++++++- .../serve/workspace-runtime-coordinator.ts | 314 ++++++++++- .../sdk-typescript/src/daemon/DaemonClient.ts | 161 ++++++ packages/sdk-typescript/src/daemon/index.ts | 4 + packages/sdk-typescript/src/daemon/types.ts | 36 ++ packages/sdk-typescript/src/index.ts | 4 + .../test/unit/DaemonClient.test.ts | 86 +++ packages/web-shell/client/App.test.tsx | 468 ++++++++++++++++- packages/web-shell/client/App.tsx | 20 - .../client/components/mcp/McpManagerPage.tsx | 396 +++++++------- .../components/messages/McpStatusMessage.tsx | 88 +++- .../plugins/PluginManagerPage.test.tsx | 175 +++++++ .../components/plugins/PluginManagerPage.tsx | 148 +++--- .../DaemonWorkspaceProvider.test.tsx | 43 +- .../client/daemon/workspace/actions.test.ts | 124 ++++- .../client/daemon/workspace/actions.ts | 200 ++++--- .../workspace/hooks/useDaemonMcp.test.tsx | 133 +++++ .../daemon/workspace/hooks/useDaemonMcp.ts | 158 +++++- .../client/daemon/workspace/types.ts | 23 +- packages/web-shell/client/i18n.tsx | 8 +- 39 files changed, 4330 insertions(+), 574 deletions(-) create mode 100644 packages/cli/src/serve/routes/workspace-mcp-config.ts create mode 100644 packages/cli/src/serve/routes/workspace-runtime-mcp.ts create mode 100644 packages/web-shell/client/components/plugins/PluginManagerPage.test.tsx create mode 100644 packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.test.tsx diff --git a/docs/design/mcp-management-runtime-model.md b/docs/design/mcp-management-runtime-model.md index 106569ce449..a407f768b00 100644 --- a/docs/design/mcp-management-runtime-model.md +++ b/docs/design/mcp-management-runtime-model.md @@ -18,3 +18,32 @@ Server provenance remains distinct: user settings, workspace settings, project `.mcp.json`, and extensions. Disabling project or workspace servers writes the exclusion to workspace-local settings without modifying the shared project file. + +## Daemon API ownership + +Durable MCP configuration uses `/workspace/config/mcp/*` for user scope and +`/workspaces/:workspace/config/mcp/*` for workspace scope. Runtime observation +and control use the matching `/runtime/mcp/*` routes. Qualified routes always +resolve the selected workspace and never fall back to the primary runtime. +Runtime operations and workspace-scoped configuration mutations require trust; +daemon-local configuration reads and user-scoped mutations do not. + +Each workspace coordinator serializes MCP preparation, reload, restart, and +authentication against its physical ACP runtime. Its capability status exposes +an operation `revision` and the observed `runtimeEpoch`; consumers may render +them but do not send them back. The revision rejects stale results from an +older MCP operation, while the epoch rejects results from a replaced ACP child. +A result is ready only when discovery is complete and came from the current +live epoch. Configuration written while the runtime is cold is durable and +reconciles on the next ensure. Configuration written during a drain is replayed +if the drain rolls back. + +OAuth remains owned by each workspace's ACP child, while daemon-global +admission serializes authentication because every child uses the same callback +port. The owning Bridge retains admission until authentication is observed as +settled or its runtime exits. + +The Web Shell MCP manager uses configuration routes for add, edit, remove, +enable, and disable, and runtime routes for status, discovery, tools, +resources, restart, approval, and OAuth. Existing legacy MCP routes remain as +a compatibility surface for older clients. diff --git a/docs/design/workspace-runtime-architecture.md b/docs/design/workspace-runtime-architecture.md index 85172500c59..760bf50c8a2 100644 --- a/docs/design/workspace-runtime-architecture.md +++ b/docs/design/workspace-runtime-architecture.md @@ -6,12 +6,12 @@ Workspace-runtime-centric 的目标设计与堆叠交付契约。该设计由四个可独立合并的 PR 渐进落地;本文描述最终形态,不表示 foundation PR 已实现所有 capability。 -> **当前落地进度(foundation)**:已实现 Bridge 权威的五态 lifecycle snapshot、 +> **当前落地进度(Foundation + MCP)**:已实现 Bridge 权威的五态 lifecycle snapshot、 > workspace 级单调 epoch、完整物理 work lease、绝对启动 deadline、无参数 > `ensure/status`、10 分钟可续期保活、drain/removal/shutdown admission,以及 -> SDK 的 primary/qualified runtime 方法。第 10~13 节描述的 capability -> generation/revision、Catalog 投影和 operation 状态机仍属于后续阶段;当前 -> `ensure` 只保证 ACP runtime 已初始化并可复用,不宣称各领域 Catalog 已 ready。 +> SDK 的 primary/qualified runtime 方法,以及 MCP 的 revision、Catalog 和 +> runtime management。当前 `ensure` 会在同一观察预算内准备 MCP;Extensions、 +> Skills、Tools 的 generation/revision、Catalog 投影和 operation 状态机仍属于后续阶段。 ### 1.1 当前实现与目标设计 @@ -21,14 +21,14 @@ PR 渐进落地;本文描述最终形态,不表示 foundation PR 已实现 - **Target(未实现)**:后续 PR 的目标契约,当前调用方不得依赖; - **Legacy(兼容)**:迁移期间保留的旧入口,新调用方不应采用。 -| 领域 | Foundation(已实现) | Target(后续阶段) | -| ----------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| Runtime lifecycle | Bridge 权威五态、workspace 单调 epoch、物理 work lease、启动 deadline、drain/removal admission | capability 健康状态参与统一对外投影 | -| `ensure` | 无参数;仅确保 ACP Channel 完成 initialize;成功后续期 10 分钟 | 准备 Extensions、MCP、Skills、Tools,并通过 capability status 表达收敛 | -| `status` | lifecycle、`runtimeLive`、`runtimeEpoch` 快照 | capability、generation、revision、error 和 operation 投影 | -| SDK | primary/qualified `ensure` 与 `status` 的 REST 方法 | config/runtime Catalog、operation 和统一 deadline 的完整 owner-aware API | +| 领域 | Foundation(已实现) | Target(后续阶段) | +| ----------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Runtime lifecycle | Bridge 权威五态、workspace 单调 epoch、物理 work lease、启动 deadline、drain/removal admission | capability 健康状态参与统一对外投影 | +| `ensure` | 无参数;确保 ACP Channel 完成 initialize 并准备 MCP;成功后续期 10 分钟 | 准备 Extensions、Skills、Tools,并通过 capability status 表达收敛 | +| `status` | lifecycle、`runtimeLive`、`runtimeEpoch` 与 MCP capability 快照 | 其他 capability、generation、revision、error 和 operation 投影 | +| SDK | primary/qualified runtime 与 MCP config/runtime 方法 | 其他 Catalog、operation 和统一 deadline 的完整 owner-aware API | -除明确标为 Foundation 的段落外,第 9~14 节中 capability、Catalog、generation、 +除明确标为当前实现的 MCP 契约外,第 9~14 节中其他 capability、Catalog、generation、 revision 和 operation 的详细状态机均是 Target 契约。 核心目标只有一个: @@ -386,9 +386,9 @@ startup preheat 是迁移期策略,不是目标架构的启动入口。完成 ### 8.4 Draining 与移除 -Foundation 的 workspace removal activity 已包含 Session 之外的 Bridge 物理 work 和 -在途 ensure。非 `force` 移除遇到这些活动项返回 `workspace_busy`。进入 `draining` -后,Registry 阻止新的路由解析, +当前组合实现的 workspace removal activity 已包含 Session 之外的 Bridge 物理 work、 +在途 ensure 和已接纳的 workspace-scoped management operation。非 `force` 移除遇到 +这些活动项返回 `workspace_busy`。进入 `draining` 后,Registry 阻止新的路由解析, Coordinator 关闭新的 ensure admission;已经解析但尚未开始物理工作的请求以 `workspace_draining` 失败。移除回滚时一并恢复 admission,提交后的强制清理才终止 现有 work。 @@ -510,27 +510,28 @@ POST /workspaces/:workspace/runtime/ensure primary workspace 使用等价的 `POST /workspace/runtime/ensure`。两个入口都拒绝非空 body;调用方不选择 capability,也不传 timeout、keepalive 或初始化顺序。 -#### Foundation(已实现) +#### 当前实现(Foundation + MCP capability) -当前 Coordinator 的职责刻意很薄: +当前 Coordinator: 1. 校验 workspace 已准确解析、受信任且未 draining; 2. 调用 Bridge 的物理 preheat/initialize,等待 ACP Channel handshake 完成; 3. 将本次成功转换为 workspace 级 10 分钟 keepalive;并发调用保留最长窗口; -4. 从 Bridge 读取 lifecycle snapshot 并返回。 +4. 在同一请求观察预算内准备 MCP,并返回 lifecycle 与 `capabilities.mcp`。 -`ensure` 成功只证明 ACP Channel 已完成 initialize 且可由后续 Session、MCP、Skills -等请求复用;它不证明 MCP discovery、Extension refresh 或任一 Catalog 已 ready。 -若同一物理启动正在进行,并发 `ensure` 复用 Bridge 的启动 Promise;每个成功调用都 -从自己的成功时刻续期 keepalive。若启动卡住,Bridge 的绝对启动 deadline 会中止并 -清理该次启动,后续显式 `ensure` 可以发起新的尝试。 +`ensure` 成功证明 ACP Channel 已完成 initialize,并启动或继续当前 MCP revision 的 +准备。只有 `capabilities.mcp.state === 'ready'`、epoch 与 lifecycle 一致且 MCP status +来自 live runtime 时,调用方才可读取 MCP Catalog。若同一物理启动正在进行,并发 +`ensure` 复用 Bridge 的启动 Promise;每个成功调用都从自己的成功时刻续期 +keepalive。若启动卡住,Bridge 的绝对启动 deadline 会中止并清理该次启动,后续显式 +`ensure` 可以发起新的尝试。 -服务端观察预算为 60 秒。预算耗尽时请求以可重试的 -`runtime_still_starting` 错误结束;底层物理启动仍由独立的绝对启动 -deadline 约束。当前没有 capability 后台收敛 operation,也不会返回 capability -`starting`。`GET /runtime/status` 只观察 lifecycle,不启动或重试 runtime。 +服务端观察预算为 60 秒。ACP Channel 尚未就绪时,预算耗尽以可重试的 +`runtime_still_starting` 错误结束;Channel 已就绪但 MCP 尚未完成时,返回 capability +`starting`,后台继续有界收敛。`GET /runtime/status` 只观察 lifecycle 和 capability, +不启动或重试 runtime。 -#### Target(未实现) +#### Target(部分实现) 后续 Coordinator 将在同一次 workspace runtime command 中固定准备标准能力 `extensions -> (mcp, skills, tools)`: @@ -541,10 +542,11 @@ deadline 约束。当前没有 capability 后台收敛 operation,也不会返 4. 初始化标准 capability 集合并更新可轮询状态; 5. 命令完成、失败或安全排空后释放 lease。 +MCP 已按 revision + epoch 实现上述收敛;Extensions、Skills 与 Tools 尚未迁移。 Target 中 Coordinator 先 prepare Extensions,再并行处理其派生能力;同一 capability 的并发工作合并。HTTP 观察预算耗尽可以先返回 capability `starting`,后台收敛受另一 个固定 deadline 约束,客户端通过 `/runtime/status` 观察终态。此语义在 capability -Coordinator 落地前不得由 SDK/UI 假设。 +Coordinator 落地前不得由 SDK/UI 假设;当前只适用于已接入的 MCP。 按 capability 的 prepare 只是 Coordinator 的内部实现,不暴露 HTTP 或 SDK 接口。 新增 capability 时只修改 Coordinator 的标准能力集合和初始化逻辑。 @@ -593,8 +595,9 @@ deadline;前者到达可先返回 `starting`,后者不随 poll 重置。MCP HTTP/SDK 请求超时与 operation deadline 是不同概念: -- Foundation ensure 的 HTTP 预算结束时返回可重试错误;Target capability ensure - 才返回 `starting`;命令型 operation 返回 `operationId`; +- ACP 物理启动在 HTTP 预算结束时返回可重试错误;已实现的 MCP capability prepare + 可以返回 `starting`,其他 Target capability 后续采用相同语义;命令型 operation + 返回 `operationId`; - 请求断开不自动宣告 operation 失败; - operation 是否继续、取消或超时由其 deadline 和取消策略决定; - UI 通过 operation/status 查询观察终态。 @@ -946,17 +949,16 @@ Catalog GET 始终保持只读,不以“页面加载”为理由启动 ACP。 - 让 WorkspaceRuntime 聚合成为唯一运行时边界; - Bridge 收口 channel、epoch、物理 lease 和 idle 回收; -- Coordinator 只投影 runtime lifecycle,并提供无 capability 参数的 ensure/status; +- Foundation Coordinator 投影 runtime lifecycle,并提供无 capability 参数的 ensure/status; - 将 Coordinator 纳入 workspace drain、removal 和 daemon shutdown; - 保持现有领域 mutation 路由不变,不在 foundation 中引入 MCP、Extension 或 Skills 专属状态。 -### 阶段二:收口状态与 operation(未开始) +### 阶段二:收口状态与 operation(部分完成) -- Coordinator 增加 capability、Extension generation、MCP/Skills revision 和 - operation 投影; -- 所有 capability 状态绑定 epoch; -- 清除跨 epoch ready 合并; +- 已完成:Coordinator 增加 MCP capability 与 revision 投影并绑定 epoch,清除 MCP + 跨 epoch ready 合并; +- 待完成:Extension generation、Skills revision 和 operation 投影; - 将 Extension desired/applied generation 更新改为同一 runtime 回执,MCP/Skills 使用 revision + epoch 丢弃迟到结果; - 使用绝对 deadline;幂等收敛由 status、命令型长任务由 operation 暴露终态; @@ -966,10 +968,11 @@ Catalog GET 始终保持只读,不以“页面加载”为理由启动 ACP。 ### 阶段三:迁移模块和 SDK(部分完成) - 已完成:Workspace SDK 的 foundation ensure/status 对 runtime 路由显式使用 REST; -- 待完成:SDK config/runtime Catalog、operation 和 capability status 契约; +- 已完成:SDK MCP config/runtime Catalog 与 capability status 契约; +- 待完成:其他模块的 config/runtime Catalog 与 operation 契约; - 待完成:Skills snapshot 增加 live source、runtime epoch 和 revision freshness; -- 待完成:Web Shell 接入 primary/qualified ensure; -- Extensions、MCP、Skills 页面迁移到统一 config/runtime/operation 模型; +- 已完成:Web Shell MCP 页面接入 primary/qualified ensure 和统一 config/runtime 模型; +- 待完成:Extensions、Skills 页面迁移到统一 config/runtime/operation 模型; - 管理区域以无参数 runtime ensure 启动完整标准能力,各页面不再传 capability; - Extensions 页面从 runtime Catalog 取得实际状态,不从 config refresh 调 Bridge; - User scope 状态失效覆盖所有受影响工作区;可选事件也要 fan out; diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 92b2162517a..99bbed19bfd 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -33,6 +33,7 @@ import { InvalidPermissionOptionError, InvalidSessionMetadataError, InvalidSessionScopeError, + McpAuthenticationInProgressError, NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE, PromptDeadlineExceededError, PromptQueueFullError, @@ -1648,10 +1649,16 @@ describe('createAcpSessionBridge', () => { const bridge = makeBridge({ channelFactory }); await bridge.initializeWorkspaceMcp(); - await bridge.queryWorkspaceStatus( - SERVE_STATUS_EXT_METHODS.workspaceMcp, - () => ({ discoveryState: 'not_started', servers: [] }), - ); + await expect( + bridge.queryWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceMcp, + () => ({ discoveryState: 'not_started', servers: [] }), + ), + ).resolves.toMatchObject({ + discoveryState: 'completed', + runtimeEpoch: 1, + source: 'live', + }); expect(handle.killed).toBe(true); await expect( @@ -1659,7 +1666,11 @@ describe('createAcpSessionBridge', () => { SERVE_STATUS_EXT_METHODS.workspaceMcp, () => ({ discoveryState: 'not_started', servers: [] }), ), - ).resolves.toMatchObject({ discoveryState: 'completed' }); + ).resolves.toMatchObject({ + discoveryState: 'completed', + runtimeEpoch: 1, + source: 'cache', + }); expect(channelFactory).toHaveBeenCalledTimes(1); expect( handle.agent.extMethodCalls.filter( @@ -1671,7 +1682,184 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); - it('preserves other MCP states after disabling in a fresh child', async () => { + it('does not reuse completed MCP status after the runtime epoch changes', async () => { + const first = makeChannel({ + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.workspaceMcpInitialize) { + return { accepted: true }; + } + if (method === SERVE_STATUS_EXT_METHODS.workspaceMcp) { + return { discoveryState: 'completed', servers: [] }; + } + return {}; + }, + }); + const second = makeChannel({ + extMethodImpl: async (method) => + method === SERVE_STATUS_EXT_METHODS.workspaceMcp + ? { discoveryState: 'not_started', servers: [] } + : {}, + }); + const channelFactory = vi + .fn() + .mockResolvedValueOnce(first.channel) + .mockResolvedValueOnce(second.channel); + const bridge = makeBridge({ channelFactory }); + + await bridge.initializeWorkspaceMcp(); + await expect( + bridge.queryWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceMcp, + () => ({ discoveryState: 'not_started', servers: [] }), + ), + ).resolves.toMatchObject({ + discoveryState: 'completed', + runtimeEpoch: 1, + }); + expect(first.killed).toBe(true); + + await bridge.preheat({ keepAliveMs: 600_000 }); + await expect( + bridge.queryWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceMcp, + () => ({ discoveryState: 'not_started', servers: [] }), + ), + ).resolves.toMatchObject({ + discoveryState: 'not_started', + runtimeEpoch: 2, + source: 'live', + }); + + await bridge.shutdown(); + }); + + it('serializes MCP authentication across workspace bridges', async () => { + let authenticationActive = false; + const acquireMcpAuthentication = () => { + if (authenticationActive) return undefined; + authenticationActive = true; + return () => { + authenticationActive = false; + }; + }; + const makeAuthenticationChannel = () => + makeChannel({ + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.workspaceMcpManage) { + return { + serverName: 'aone', + action: 'authenticate', + ok: true, + pending: true, + }; + } + if (method === SERVE_STATUS_EXT_METHODS.workspaceMcp) { + return { + discoveryState: 'completed', + servers: [{ name: 'aone', authenticationState: 'pending' }], + }; + } + return {}; + }, + }); + const firstHandle = makeAuthenticationChannel(); + const secondHandle = makeAuthenticationChannel(); + const first = makeBridge({ + channelFactory: async () => firstHandle.channel, + acquireMcpAuthentication, + }); + const second = makeBridge({ + boundWorkspace: WS_B, + channelFactory: async () => secondHandle.channel, + acquireMcpAuthentication, + }); + + await expect( + first.manageMcpServer('aone', 'authenticate', undefined), + ).resolves.toMatchObject({ pending: true }); + await expect( + second.manageMcpServer('aone', 'authenticate', undefined), + ).rejects.toBeInstanceOf(McpAuthenticationInProgressError); + + await first.shutdown(); + await expect( + second.manageMcpServer('aone', 'authenticate', undefined), + ).resolves.toMatchObject({ pending: true }); + await second.shutdown(); + }); + + it('keeps MCP authentication admission after a timed-out RPC until its channel exits', async () => { + vi.useFakeTimers(); + try { + let authenticationActive = false; + const acquireMcpAuthentication = () => { + if (authenticationActive) return undefined; + authenticationActive = true; + return () => { + authenticationActive = false; + }; + }; + const stalledAuthentication = deferred>(); + const firstHandle = makeChannel({ + extMethodImpl: async (method) => + method === SERVE_CONTROL_EXT_METHODS.workspaceMcpManage + ? await stalledAuthentication.promise + : {}, + }); + const secondHandle = makeChannel({ + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.workspaceMcpManage) { + return { + serverName: 'aone', + action: 'authenticate', + ok: true, + pending: true, + }; + } + if (method === SERVE_STATUS_EXT_METHODS.workspaceMcp) { + return { + discoveryState: 'completed', + servers: [{ name: 'aone', authenticationState: 'pending' }], + }; + } + return {}; + }, + }); + const first = makeBridge({ + channelFactory: async () => firstHandle.channel, + acquireMcpAuthentication, + }); + const second = makeBridge({ + boundWorkspace: WS_B, + channelFactory: async () => secondHandle.channel, + acquireMcpAuthentication, + }); + const session = await first.spawnOrAttach({ workspaceCwd: WS_A }); + + const request = first.manageMcpServer('aone', 'authenticate', undefined); + void request.catch(() => undefined); + await vi.advanceTimersByTimeAsync(600_000); + + await expect(request).rejects.toBeInstanceOf(BridgeTimeoutError); + expect(firstHandle.killed).toBe(false); + await expect( + second.manageMcpServer('aone', 'authenticate', undefined), + ).rejects.toBeInstanceOf(McpAuthenticationInProgressError); + + await first.closeSession(session.sessionId); + await vi.waitFor(() => expect(firstHandle.killed).toBe(true)); + await expect( + second.manageMcpServer('aone', 'authenticate', undefined), + ).resolves.toMatchObject({ pending: true }); + + await first.shutdown(); + await second.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('does not reuse other MCP states after disabling in a fresh child', async () => { const discovery = makeChannel({ extMethodImpl: async (method) => { if (method === SERVE_CONTROL_EXT_METHODS.workspaceMcpInitialize) { @@ -1746,7 +1934,9 @@ describe('createAcpSessionBridge', () => { () => ({ discoveryState: 'not_started', servers: [] }), ), ).resolves.toMatchObject({ - discoveryState: 'completed', + discoveryState: 'not_started', + runtimeEpoch: 2, + source: 'cache', servers: [ expect.objectContaining({ name: 'aone', @@ -1756,7 +1946,7 @@ describe('createAcpSessionBridge', () => { expect.objectContaining({ name: 'yuque', disabled: false, - mcpStatus: 'connected', + mcpStatus: 'disconnected', }), ], }); @@ -1765,7 +1955,7 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); - it('preserves other MCP states while polling authentication', async () => { + it('does not reuse other MCP states while polling in a fresh child', async () => { const discovery = makeChannel({ extMethodImpl: async (method) => { if (method === SERVE_CONTROL_EXT_METHODS.workspaceMcpInitialize) { @@ -1831,14 +2021,14 @@ describe('createAcpSessionBridge', () => { () => ({ discoveryState: 'not_started', servers: [] }), ), ).resolves.toMatchObject({ - discoveryState: 'completed', + discoveryState: 'not_started', servers: [ expect.objectContaining({ name: 'aone', mcpStatus: 'connected', authenticationState: 'succeeded', }), - expect.objectContaining({ name: 'yuque', mcpStatus: 'connected' }), + expect.objectContaining({ name: 'yuque', mcpStatus: 'disconnected' }), ], }); expect(authentication.killed).toBe(true); @@ -1846,7 +2036,7 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); - it('preserves other MCP states after restarting in a fresh child', async () => { + it('does not reuse other MCP states after restarting in a fresh child', async () => { const discovery = makeChannel({ extMethodImpl: async (method) => { if (method === SERVE_CONTROL_EXT_METHODS.workspaceMcpInitialize) { @@ -1903,10 +2093,10 @@ describe('createAcpSessionBridge', () => { () => ({ discoveryState: 'not_started', servers: [] }), ), ).resolves.toMatchObject({ - discoveryState: 'completed', + discoveryState: 'not_started', servers: [ expect.objectContaining({ name: 'aone', mcpStatus: 'connected' }), - expect.objectContaining({ name: 'yuque', mcpStatus: 'connected' }), + expect.objectContaining({ name: 'yuque', mcpStatus: 'disconnected' }), ], }); @@ -32528,7 +32718,7 @@ describe('preheat', () => { await secondBridge.shutdown(); }); - it('keeps an idle-timeout-zero channel alive while workspace status is pending', async () => { + it('reaps an idle-timeout-zero preheat after workspace status settles', async () => { const statusResult = deferred>(); const statusMethod = 'qwen/status/workspace/test'; const handle = makeChannel({ @@ -32561,10 +32751,10 @@ describe('preheat', () => { statusResult.resolve({ ready: true }); await expect(status).resolves.toEqual({ ready: true }); - expect(handle.killed).toBe(false); + expect(handle.killed).toBe(true); expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ - state: 'idle', - runtimeLive: true, + state: 'cold', + runtimeLive: false, activeWork: false, }); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 0549b321367..d2c6cb612cb 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -115,6 +115,7 @@ import { InvalidRewindTargetError, PromptDeadlineExceededError, BridgeChannelQuarantinedError, + McpAuthenticationInProgressError, StandaloneSessionSpawnError, } from './bridgeErrors.js'; import type { BridgeChannelUnavailableReason } from './bridgeErrors.js'; @@ -941,14 +942,13 @@ interface ChannelInfo { sessionSpawnsInFlight: number; /** Workspace-level control calls that use the shared channel without a session. */ workspaceControlInFlight: number; - /** A plain preheat keeps the channel through its first status read. */ - preserveForFirstStatusRead: boolean; /** Background MCP discovery started by the workspace initialize control. */ workspaceMcpDiscoveryInFlight: boolean; workspaceMcpDiscoveryTimer?: NodeJS.Timeout; workspaceMcpDiscoveryRequested: boolean; workspaceMcpAuthenticationServerNames: Set; workspaceMcpAuthenticationTimers: Map; + workspaceMcpAuthenticationReleases: Map void>; /** A timed-out workspace operation will retire this channel after Sessions drain. */ retireWhenSessionsDrain: boolean; /** @@ -3839,13 +3839,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { async function withWorkspaceControl( ci: ChannelInfo, fn: () => Promise, - statusRead = false, ): Promise { - const preservePreheat = - statusRead && - ci.preserveForFirstStatusRead && - resolvedChannelIdleTimeoutMs() <= 0; - ci.preserveForFirstStatusRead = false; if (liveChannelInfo() === ci) cancelIdleTimer(); ci.workspaceControlInFlight++; try { @@ -3859,12 +3853,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ci.workspaceControlInFlight - 1, ); await reapPendingEmptyChannel(ci); - if ( - !preservePreheat && - !ci.isDying && - liveChannelInfo() === ci && - hasNoChannelWork(ci) - ) { + if (!ci.isDying && liveChannelInfo() === ci && hasNoChannelWork(ci)) { await startIdleTimer(ci, 'workspace control'); } } @@ -3886,7 +3875,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ci: ChannelInfo, fn: () => Promise, ): Promise { - return withWorkspaceControl(ci, fn, true); + return withWorkspaceControl(ci, fn); } function startSessionReaper(): void { @@ -4606,11 +4595,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { pendingRestoreIds: new Set(), sessionSpawnsInFlight: 0, workspaceControlInFlight: 0, - preserveForFirstStatusRead: false, workspaceMcpDiscoveryInFlight: false, workspaceMcpDiscoveryRequested: false, workspaceMcpAuthenticationServerNames: new Set(), workspaceMcpAuthenticationTimers: new Map(), + workspaceMcpAuthenticationReleases: new Map(), retireWhenSessionsDrain: false, emptyReapPending: false, unsettledAbandonedRestores: new Set(), @@ -4693,6 +4682,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } info.workspaceMcpAuthenticationTimers.clear(); info.workspaceMcpAuthenticationServerNames.clear(); + for (const release of info.workspaceMcpAuthenticationReleases.values()) { + release(); + } + info.workspaceMcpAuthenticationReleases.clear(); for (const timer of info.restoreSettlementTimers.values()) { clearTimeout(timer); } @@ -5244,7 +5237,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if (ci.isDying) { throw new BridgeChannelClosedError('before newSession'); } - ci.preserveForFirstStatusRead = false; ci.sessionSpawnsInFlight++; if (requestedSessionId !== undefined) { // A caller-supplied id can legitimately reuse an id after an abandoned @@ -6112,9 +6104,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if ( Array.isArray(current.servers) && previous?.discoveryState === 'completed' && + previous.runtimeEpoch === current.runtimeEpoch && current.discoveryState === 'not_started' ) { - if (current.servers.length === 0) return previous; + if (current.servers.length === 0) { + return { + ...previous, + runtimeEpoch: current.runtimeEpoch, + source: current.source, + }; + } const currentServers = new Map( current.servers.map((server) => [server.name, server]), ); @@ -6133,6 +6132,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } return { ...previous, + runtimeEpoch: current.runtimeEpoch, + source: current.source, discoveryState: 'completed', servers, }; @@ -6152,7 +6153,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { method === SERVE_STATUS_EXT_METHODS.workspaceMcp && workspaceMcpStatusCache ) { - return workspaceMcpStatusCache as T; + return { + ...workspaceMcpStatusCache, + source: 'cache', + } as T; } return idle(); } @@ -6168,6 +6172,20 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { initTimeoutMs, method, ); + if ( + isRecord(response) && + (method === SERVE_STATUS_EXT_METHODS.workspaceMcp || + method === SERVE_STATUS_EXT_METHODS.workspaceMcpTools || + method === SERVE_STATUS_EXT_METHODS.workspaceMcpResources) + ) { + response = { + ...response, + runtimeEpoch, + ...(method === SERVE_STATUS_EXT_METHODS.workspaceMcp + ? { source: 'live' } + : {}), + }; + } if (method === SERVE_STATUS_EXT_METHODS.workspaceMcp) { const rawStatus = response as unknown as ServeWorkspaceMcpStatus; if (!Array.isArray(rawStatus.servers)) { @@ -6178,12 +6196,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ...info.workspaceMcpAuthenticationServerNames, ...(managedServerNames ?? []), ]); - if ( - effectiveManagedServerNames.size > 0 || - (workspaceMcpStatusCache?.discoveryState === 'completed' && - rawStatus.discoveryState === 'not_started' && - rawServers.length === 0) - ) { + if (effectiveManagedServerNames.size > 0) { response = mergeManagedWorkspaceMcpStatus( effectiveManagedServerNames, workspaceMcpStatusCache, @@ -6233,6 +6246,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const timer = info.workspaceMcpAuthenticationTimers.get(serverName); if (timer) clearTimeout(timer); info.workspaceMcpAuthenticationTimers.delete(serverName); + info.workspaceMcpAuthenticationReleases.get(serverName)?.(); + info.workspaceMcpAuthenticationReleases.delete(serverName); } } workspaceMcpStatusCache = @@ -8094,7 +8109,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { throw new BridgeChannelClosedError(`before session/${action}`); } ci = restoreChannel; - restoreChannel.preserveForFirstStatusRead = false; restoreChannel.pendingRestoreIds.add(req.sessionId); // Mark this id as in-flight restore BEFORE the ACP // `loadSession`/`unstable_resumeSession` call. Restore-time @@ -13670,68 +13684,106 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }, async manageMcpServer(serverName, action, originatorClientId) { - return await withEnsuredWorkspaceControl(async (info) => { - const timeout = - action === 'authenticate' - ? MCP_OAUTH_TIMEOUT_MS - : MCP_RESTART_TIMEOUT_MS; - const response = (await Promise.race([ - withTimeout( - info.connection.extMethod( - SERVE_CONTROL_EXT_METHODS.workspaceMcpManage, - { serverName, action, originatorClientId }, - ), - timeout, - SERVE_CONTROL_EXT_METHODS.workspaceMcpManage, - ), - getChannelClosedReject(info), - ])) as { - serverName: string; - action: - | 'approve' - | 'enable' - | 'disable' - | 'authenticate' - | 'clear-auth'; - ok: true; - changed?: boolean; - messages?: string[]; - authUrl?: string; - pending?: boolean; - }; - if (action === 'authenticate' && response.pending) { - info.workspaceMcpAuthenticationServerNames.add(serverName); - const previousTimer = - info.workspaceMcpAuthenticationTimers.get(serverName); - if (previousTimer) clearTimeout(previousTimer); - const timer = setTimeout(() => { - void expireWorkspaceMcpAuthentication(info, serverName, timer); - }, MCP_OAUTH_TIMEOUT_MS); - timer.unref(); - info.workspaceMcpAuthenticationTimers.set(serverName, timer); - } - invalidateWorkspaceMcpDetailCache(serverName); - await requestWorkspaceStatus( - SERVE_STATUS_EXT_METHODS.workspaceMcp, - () => { - throw new BridgeChannelClosedError( - 'workspace MCP management status refresh', + let releaseAuthentication = + action === 'authenticate' + ? opts.acquireMcpAuthentication?.(boundWorkspace, serverName) + : undefined; + if ( + action === 'authenticate' && + opts.acquireMcpAuthentication && + !releaseAuthentication + ) { + throw new McpAuthenticationInProgressError(); + } + try { + return await withEnsuredWorkspaceControl(async (info) => { + if (releaseAuthentication) { + info.workspaceMcpAuthenticationReleases.set( + serverName, + releaseAuthentication, ); - }, - {}, - new Set([serverName]), - ); - broadcastWorkspaceEvent({ - type: 'mcp_server_changed', - data: { - serverName: response.serverName, - action: response.action, - originatorClientId, - }, - ...(originatorClientId ? { originatorClientId } : {}), + releaseAuthentication = undefined; + } + const timeout = + action === 'authenticate' + ? MCP_OAUTH_TIMEOUT_MS + : MCP_RESTART_TIMEOUT_MS; + let response: { + serverName: string; + action: + | 'approve' + | 'enable' + | 'disable' + | 'authenticate' + | 'clear-auth'; + ok: true; + changed?: boolean; + messages?: string[]; + authUrl?: string; + pending?: boolean; + }; + try { + response = (await Promise.race([ + withTimeout( + info.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMcpManage, + { serverName, action, originatorClientId }, + ), + timeout, + SERVE_CONTROL_EXT_METHODS.workspaceMcpManage, + ), + getChannelClosedReject(info), + ])) as typeof response; + } catch (error) { + if ( + action === 'authenticate' && + !(error instanceof BridgeTimeoutError) && + !(error instanceof BridgeChannelClosedError) + ) { + info.workspaceMcpAuthenticationReleases.get(serverName)?.(); + info.workspaceMcpAuthenticationReleases.delete(serverName); + } + throw error; + } + if (action === 'authenticate' && response.pending) { + info.workspaceMcpAuthenticationServerNames.add(serverName); + const previousTimer = + info.workspaceMcpAuthenticationTimers.get(serverName); + if (previousTimer) clearTimeout(previousTimer); + const timer = setTimeout(() => { + void expireWorkspaceMcpAuthentication(info, serverName, timer); + }, MCP_OAUTH_TIMEOUT_MS); + timer.unref(); + info.workspaceMcpAuthenticationTimers.set(serverName, timer); + } else if (action === 'authenticate') { + info.workspaceMcpAuthenticationReleases.get(serverName)?.(); + info.workspaceMcpAuthenticationReleases.delete(serverName); + } + invalidateWorkspaceMcpDetailCache(serverName); + await requestWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceMcp, + () => { + throw new BridgeChannelClosedError( + 'workspace MCP management status refresh', + ); + }, + {}, + new Set([serverName]), + ); + broadcastWorkspaceEvent({ + type: 'mcp_server_changed', + data: { + serverName: response.serverName, + action: response.action, + originatorClientId, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + return response; }); - return response; - }); + } finally { + releaseAuthentication?.(); + } }, async initializeWorkspaceMcp() { @@ -14427,19 +14479,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { 'channel.preheat', { 'qwen-code.daemon.bridge.operation': 'channel.preheat' }, async () => { - const existing = liveChannelInfo(); - const info = await ensureChannel(); + await ensureChannel(); if (keepAliveMs !== undefined) { keepAliveUntil = Math.max( keepAliveUntil, Date.now() + keepAliveMs, ); - } else if ( - !existing && - configuredChannelIdleTimeoutMs() === 0 && - hasNoSessionWork(info) - ) { - info.preserveForFirstStatusRead = true; } }, ); diff --git a/packages/acp-bridge/src/bridgeErrors.ts b/packages/acp-bridge/src/bridgeErrors.ts index b90cdccc67d..bb3aec22ef2 100644 --- a/packages/acp-bridge/src/bridgeErrors.ts +++ b/packages/acp-bridge/src/bridgeErrors.ts @@ -704,3 +704,10 @@ export class CdWhilePromptActiveError extends Error { this.sessionId = sessionId; } } + +export class McpAuthenticationInProgressError extends Error { + constructor() { + super('Another MCP authentication is already in progress'); + this.name = 'McpAuthenticationInProgressError'; + } +} diff --git a/packages/acp-bridge/src/bridgeOptions.ts b/packages/acp-bridge/src/bridgeOptions.ts index f9b94be3d69..edee17dd157 100644 --- a/packages/acp-bridge/src/bridgeOptions.ts +++ b/packages/acp-bridge/src/bridgeOptions.ts @@ -99,6 +99,11 @@ export interface BridgeRuntimeEpochSource { allocate(): number; } +export type BridgeMcpAuthenticationAdmission = ( + workspaceCwd: string, + serverName: string, +) => (() => void) | undefined; + /** * Trusted child-to-daemon request made immediately before a tool executor. * `sessionId` and `promptId` are revalidated by BridgeClient against its @@ -256,6 +261,8 @@ export interface BridgeOptions { channelFactory?: ChannelFactory; /** Workspace-scoped epoch source shared across Bridge replacement. */ runtimeEpochSource?: BridgeRuntimeEpochSource; + /** Daemon-global admission for the process-wide MCP OAuth callback port. */ + acquireMcpAuthentication?: BridgeMcpAuthenticationAdmission; /** How long to wait for the child's `initialize` reply before giving up. */ initializeTimeoutMs?: number; /** diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 0938636d0f7..704a4cda9ff 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -10,12 +10,22 @@ import { SkillError } from '@qwen-code/qwen-code-core'; export const STATUS_SCHEMA_VERSION = 1 as const; +export interface ServeWorkspaceRuntimeCapabilityStatus { + state: 'not_started' | 'starting' | 'ready' | 'stale' | 'error'; + revision: number; + runtimeEpoch?: number; + error?: { code: string; message: string }; +} + export interface ServeWorkspaceRuntimeStatus { v: typeof STATUS_SCHEMA_VERSION; workspaceCwd: string; state: 'cold' | 'starting' | 'active' | 'idle' | 'stopping'; runtimeLive: boolean; runtimeEpoch: number; + capabilities?: { + mcp?: ServeWorkspaceRuntimeCapabilityStatus; + }; } /** @@ -418,6 +428,8 @@ export interface ServeWorkspaceMcpStatus { v: typeof STATUS_SCHEMA_VERSION; workspaceCwd: string; initialized: boolean; + runtimeEpoch?: number; + source?: 'live' | 'cache'; discoveryState?: ServeMcpDiscoveryState; servers: ServeWorkspaceMcpServerStatus[]; errors?: ServeStatusCell[]; @@ -450,6 +462,7 @@ export interface ServeWorkspaceMcpToolsStatus { workspaceCwd: string; serverName: string; initialized: boolean; + runtimeEpoch?: number; acpChannelLive: boolean; tools: ServeWorkspaceMcpToolStatus[]; errors?: ServeStatusCell[]; @@ -482,6 +495,7 @@ export interface ServeWorkspaceMcpResourcesStatus { workspaceCwd: string; serverName: string; initialized: boolean; + runtimeEpoch?: number; acpChannelLive: boolean; resources: ServeWorkspaceMcpResourceStatus[]; errors?: ServeStatusCell[]; diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 83b4cc52e8c..ebece83c674 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -37,10 +37,15 @@ const { mockRunExitCleanup } = vi.hoisted(() => ({ const { mockStartNonInteractiveOpenAILogHousekeeping } = vi.hoisted(() => ({ mockStartNonInteractiveOpenAILogHousekeeping: vi.fn(), })); -const { mockMcpPoolDrainAll } = vi.hoisted(() => ({ +const { mockMcpPoolDrainAll, mockMcpPoolGetSnapshot } = vi.hoisted(() => ({ mockMcpPoolDrainAll: vi .fn() .mockResolvedValue({ drained: 0, forced: 0, errors: [] }), + mockMcpPoolGetSnapshot: vi.fn().mockReturnValue({ + total: 0, + subprocessCount: 0, + byName: {}, + }), })); vi.mock('../utils/cleanup.js', () => ({ runExitCleanup: mockRunExitCleanup, @@ -644,11 +649,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ })), McpTransportPool: vi.fn().mockImplementation(() => ({ drainAll: mockMcpPoolDrainAll, - getSnapshot: vi.fn().mockReturnValue({ - total: 0, - subprocessCount: 0, - byName: {}, - }), + getSnapshot: mockMcpPoolGetSnapshot, releaseSession: vi.fn(), restartByName: vi.fn().mockResolvedValue([]), getBudget: vi.fn().mockReturnValue(undefined), @@ -2175,6 +2176,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => { mockMcpPoolDrainAll .mockReset() .mockResolvedValue({ drained: 0, forced: 0, errors: [] }); + mockMcpPoolGetSnapshot.mockReset().mockReturnValue({ + total: 0, + subprocessCount: 0, + byName: {}, + }); mockExtensionManagerState.extensions = []; mockExtensionManagerState.refreshCache.mockResolvedValue(undefined); mockRunManagedAutoMemoryDream.mockReset(); @@ -5356,6 +5362,67 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('uses the pool status for the top-level MCP server status', async () => { + const manager = { + getDiscoveryState: vi.fn().mockReturnValue(MCPDiscoveryState.COMPLETED), + getMcpClientAccounting: vi.fn().mockReturnValue({ + total: 0, + reservedSlots: [], + refusedServerNames: [], + }), + getMcpClientBudget: vi.fn().mockReturnValue(undefined), + getMcpBudgetMode: vi.fn().mockReturnValue('off'), + getServerStatus: vi.fn().mockReturnValue(MCPServerStatus.DISCONNECTED), + }; + mockConfig = { + ...mockConfig, + getMcpServers: vi + .fn() + .mockReturnValue({ code: { httpUrl: 'https://example.com/mcp' } }), + getWorkingDir: vi.fn().mockReturnValue('/tmp'), + getTargetDir: vi.fn().mockReturnValue('/tmp'), + isMcpServerDisabled: vi.fn().mockReturnValue(false), + getToolRegistry: vi.fn().mockReturnValue({ + getMcpClientManager: vi.fn().mockReturnValue(manager), + }), + } as unknown as Config; + mockMcpPoolGetSnapshot.mockReturnValue({ + total: 1, + subprocessCount: 0, + byName: { + code: { + entryCount: 1, + entrySummary: [ + { + entryIndex: 0, + refs: 1, + status: MCPServerStatus.CONNECTED, + }, + ], + }, + }, + }); + const { agent, agentPromise } = await bootAcpAgent(); + + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.workspaceMcp, {}), + ).resolves.toMatchObject({ + servers: [ + expect.objectContaining({ + name: 'code', + status: 'ok', + mcpStatus: 'connected', + entrySummary: [ + expect.objectContaining({ entryIndex: 0, status: 'connected' }), + ], + }), + ], + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('reports authentication-required MCP servers and refuses reconnect', async () => { const server: { httpUrl: string; @@ -24861,6 +24928,7 @@ describe('QwenAgent extMethod runtime MCP add/remove (T2.8)', () => { }; const runtimeServer = { command: 'runtime-server' }; const forceDiscover = vi.fn().mockResolvedValue(undefined); + const setTools = vi.fn().mockResolvedValue(undefined); let finishDiscovery!: () => void; const discoveryPending = new Promise((resolve) => { finishDiscovery = resolve; @@ -24913,6 +24981,10 @@ describe('QwenAgent extMethod runtime MCP add/remove (T2.8)', () => { .mockReturnValue({ runtime: runtimeServer }); mockConfig.getWorkingDir = vi.fn().mockReturnValue('/tmp'); mockConfig.isMcpServerDisabled = vi.fn().mockReturnValue(false); + mockConfig.getLlmClient = vi.fn().mockReturnValue({ + isInitialized: vi.fn().mockReturnValue(true), + setTools, + }); mockConfig.getToolRegistry = vi.fn().mockReturnValue({ discoverToolsForServer: forceDiscover, getMcpClientManager: vi.fn().mockReturnValue({ @@ -24986,6 +25058,7 @@ describe('QwenAgent extMethod runtime MCP add/remove (T2.8)', () => { discovery: discoveryServer, }), ); + expect(setTools).toHaveBeenCalledOnce(); expect(mockConfig.setExcludedMcpServers).toHaveBeenCalledWith(['disabled']); expect(mockConfig.setAllowedMcpServers).toHaveBeenCalledWith([ 'persisted', diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 20fccd9cb6c..26e15caab8e 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -3998,6 +3998,10 @@ class QwenAgent implements Agent { config.setAllowedMcpServers(gating.allowed); config.setPendingMcpServers(gating.pending); await config.reinitializeMcpServers(mcpServers); + const llmClient = config.getLlmClient?.(); + if (llmClient?.isInitialized?.()) { + await llmClient.setTools?.(); + } } catch (error) { syncErrors.push(error); } @@ -6483,7 +6487,18 @@ class QwenAgent implements Agent { } catch { // Match CLI: token lookup errors should not break /mcp status. } - const rawStatus = this.getMcpServerStatus(config, name); + const poolRow = poolByName[name]; + const rawStatus = poolRow + ? poolRow.entrySummary.some( + (entry) => entry.status === MCPServerStatus.CONNECTED, + ) + ? MCPServerStatus.CONNECTED + : poolRow.entrySummary.some( + (entry) => entry.status === MCPServerStatus.CONNECTING, + ) + ? MCPServerStatus.CONNECTING + : MCPServerStatus.DISCONNECTED + : this.getMcpServerStatus(config, name); const requiresAuth = rawStatus !== MCPServerStatus.CONNECTED && (mcpServerRequiresOAuth.get(name) === true || @@ -6619,7 +6634,6 @@ class QwenAgent implements Agent { } } // Pool entries enrichment. - const poolRow = poolByName[name]; if (poolRow) { out.entryCount = poolRow.entryCount; out.entrySummary = poolRow.entrySummary.map((e) => ({ diff --git a/packages/cli/src/serve/acp-session-bridge.ts b/packages/cli/src/serve/acp-session-bridge.ts index ce29320c783..3277a244be7 100644 --- a/packages/cli/src/serve/acp-session-bridge.ts +++ b/packages/cli/src/serve/acp-session-bridge.ts @@ -135,6 +135,7 @@ export { WorkspaceInitPathEscapeError, WorkspaceInitSymlinkError, WorkspaceInitRaceError, + McpAuthenticationInProgressError, McpServerNotFoundError, McpServerRestartFailedError, SessionBusyError, diff --git a/packages/cli/src/serve/routes/workspace-mcp-config.ts b/packages/cli/src/serve/routes/workspace-mcp-config.ts new file mode 100644 index 00000000000..7fa487e1c26 --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-mcp-config.ts @@ -0,0 +1,492 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { matchesAnyServerPattern } from '@qwen-code/qwen-code-core'; +import type { Application, Request, RequestHandler, Response } from 'express'; +import { redactMcpServersSetting } from '../../config/mcp-server-secrets.js'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { + getSettingDefinition, + validateSettingValue, +} from '../../config/settingsUtils.js'; +import type { SendBridgeError } from '../server/error-response.js'; +import { validateMcpRuntimeServerName } from '../server/request-helpers.js'; +import { + requireTrustedWorkspaceRuntime, + resolveWorkspaceEntryFromParam, + resolveWorkspaceRuntimeFromParam, +} from '../workspace-route-runtime.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from '../workspace-registry.js'; +import { getWorkspaceRuntimeCoordinator } from '../workspace-runtime-coordinator.js'; +import { + prepareSettingWrite, + withMcpServerMutationLock, +} from './workspace-settings.js'; + +interface McpConfigRoutesDeps { + workspaceRegistry: WorkspaceRegistry; + mutate: (opts?: { strict?: boolean }) => RequestHandler; + safeBody: (req: Request) => Record; + persistSetting: ( + workspace: string, + scope: SettingScope, + key: string, + value: unknown, + assertGenerationOpen?: () => void, + ) => Promise; + sendBridgeError: SendBridgeError; + invalidateServeFeaturesCache: () => void; + broadcastSettingsChanged: ( + key: string, + value: unknown, + scope: string, + clientId: string | undefined, + ) => void; +} + +type McpConfigScope = 'user' | 'workspace'; + +interface McpConfigTarget { + workspaceCwd: string; + runtime?: WorkspaceRuntime; +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (redactMcpServersSetting(value) as Record) + : {}; +} + +function buildMcpConfigStatus(workspaceCwd: string) { + const settings = loadSettings(workspaceCwd, { + skipLoadEnvironment: true, + }); + const effective = asRecord(settings.merged.mcpServers); + const user = asRecord(settings.user.settings.mcpServers); + const workspace = asRecord(settings.workspace.settings.mcpServers); + return { + v: 1 as const, + effective, + user, + workspace, + }; +} + +function affectedRuntimes( + registry: WorkspaceRegistry, + target: McpConfigTarget, + scope: McpConfigScope, +): readonly WorkspaceRuntime[] { + return scope === 'user' + ? registry.listManaged().filter((runtime) => runtime.trusted) + : target.runtime + ? [target.runtime] + : []; +} + +function scheduleMcpConfiguration( + runtimes: readonly WorkspaceRuntime[], +): 'deferred' | 'reconciling' { + let reconciling = false; + for (const runtime of runtimes) { + try { + if ( + getWorkspaceRuntimeCoordinator(runtime).reconcileMcpConfiguration() === + 'reconciling' + ) { + reconciling = true; + } + } catch { + // Durable configuration is already committed. + } + } + return reconciling ? 'reconciling' : 'deferred'; +} + +function runManagementOperation( + target: McpConfigTarget, + operation: () => Promise, +): Promise { + return target.runtime + ? getWorkspaceRuntimeCoordinator(target.runtime).runManagementOperation( + operation, + ) + : operation(); +} + +function registerFor( + app: Application, + base: string, + scope: McpConfigScope, + resolveReadTarget: (req: Request, res: Response) => McpConfigTarget | null, + resolveMutationTarget: ( + req: Request, + res: Response, + ) => McpConfigTarget | null, + deps: McpConfigRoutesDeps, +): void { + const settingScope = + scope === 'user' ? SettingScope.User : SettingScope.Workspace; + + app.get(`${base}/config/mcp/servers`, (req, res) => { + const target = resolveReadTarget(req, res); + if (!target) return; + try { + res.status(200).json(buildMcpConfigStatus(target.workspaceCwd)); + } catch (error) { + deps.sendBridgeError(res, error, { + route: `GET ${base}/config/mcp/servers`, + }); + } + }); + + app.put( + `${base}/config/mcp/servers/:name`, + deps.mutate({ strict: true }), + async (req, res) => { + const target = resolveMutationTarget(req, res); + if (!target) return; + const name = req.params['name']; + if (!validateMcpRuntimeServerName(name, res)) return; + const body = deps.safeBody(req); + if (body['scope'] !== scope) { + res.status(400).json({ + error: `scope must be ${scope}`, + code: 'invalid_scope', + }); + return; + } + const config = body['config']; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + res.status(400).json({ + error: 'config is required and must be an object', + code: 'invalid_mcp_server_config', + }); + return; + } + try { + const result = await runManagementOperation(target, async () => { + let publicValue: unknown; + await withMcpServerMutationLock( + target.workspaceCwd, + settingScope, + async () => { + const prepared = prepareSettingWrite( + target.workspaceCwd, + settingScope, + 'mcpServers', + config, + { operation: 'set', name }, + ); + const definition = getSettingDefinition('mcpServers'); + const validationError = definition + ? validateSettingValue(definition, prepared.persistedValue) + : undefined; + if (validationError) { + const error = new Error(validationError) as Error & { + code?: string; + }; + error.code = 'invalid_mcp_server_config'; + throw error; + } + await deps.persistSetting( + target.workspaceCwd, + settingScope, + 'mcpServers', + prepared.persistedValue, + target.runtime?.generationGuard?.assertOpen, + ); + publicValue = prepared.publicValue; + }, + ); + const servers = asRecord(publicValue); + return { + config: servers[name], + publicValue, + activation: scheduleMcpConfiguration( + affectedRuntimes(deps.workspaceRegistry, target, scope), + ), + }; + }); + publishConfiguration( + target, + scope, + 'mcpServers', + result.publicValue, + deps, + ); + res.status(200).json({ + name, + scope, + config: result.config, + activation: result.activation, + }); + } catch (error) { + if ( + error instanceof Error && + (error as Error & { code?: string }).code === + 'invalid_mcp_server_config' + ) { + res.status(400).json({ + error: error.message, + code: 'invalid_mcp_server_config', + }); + return; + } + deps.sendBridgeError(res, error, { + route: `PUT ${base}/config/mcp/servers/:name`, + }); + } + }, + ); + + app.delete( + `${base}/config/mcp/servers/:name`, + deps.mutate({ strict: true }), + async (req, res) => { + const target = resolveMutationTarget(req, res); + if (!target) return; + const name = req.params['name']; + if (!validateMcpRuntimeServerName(name, res)) return; + if (req.query['scope'] !== scope) { + res.status(400).json({ + error: `scope must be ${scope}`, + code: 'invalid_scope', + }); + return; + } + try { + const result = await runManagementOperation(target, async () => { + let publicValue: unknown; + await withMcpServerMutationLock( + target.workspaceCwd, + settingScope, + async () => { + const prepared = prepareSettingWrite( + target.workspaceCwd, + settingScope, + 'mcpServers', + {}, + { operation: 'remove', name }, + ); + await deps.persistSetting( + target.workspaceCwd, + settingScope, + 'mcpServers', + prepared.persistedValue, + target.runtime?.generationGuard?.assertOpen, + ); + publicValue = prepared.publicValue; + }, + ); + return { + publicValue, + activation: scheduleMcpConfiguration( + affectedRuntimes(deps.workspaceRegistry, target, scope), + ), + }; + }); + publishConfiguration( + target, + scope, + 'mcpServers', + result.publicValue, + deps, + ); + res.status(200).json({ name, scope, activation: result.activation }); + } catch (error) { + deps.sendBridgeError(res, error, { + route: `DELETE ${base}/config/mcp/servers/:name`, + }); + } + }, + ); + + for (const action of ['enable', 'disable'] as const) { + app.post( + `${base}/config/mcp/:server/${action}`, + deps.mutate({ strict: true }), + async (req, res) => { + const target = resolveMutationTarget(req, res); + if (!target) return; + const serverName = req.params['server']; + if (!validateMcpRuntimeServerName(serverName, res)) return; + try { + const result = await runManagementOperation(target, async () => { + let changed = false; + let next: string[] = []; + let blocking: string[] = []; + await withMcpServerMutationLock( + target.workspaceCwd, + settingScope, + async () => { + const settings = loadSettings(target.workspaceCwd, { + skipLoadEnvironment: true, + }); + const existing = + settings.forScope(settingScope).settings.mcp?.excluded ?? []; + if (action === 'enable') { + const blockingScopes = [ + SettingScope.SystemDefaults, + SettingScope.System, + SettingScope.User, + ...(settingScope === SettingScope.Workspace + ? [SettingScope.Workspace] + : []), + ]; + blocking = blockingScopes.flatMap((candidateScope) => + ( + settings.forScope(candidateScope).settings.mcp + ?.excluded ?? [] + ).filter( + (pattern) => + matchesAnyServerPattern(serverName, [pattern]) && + (candidateScope !== settingScope || + pattern !== serverName), + ), + ); + if (blocking.length > 0) return; + } + next = + action === 'enable' + ? existing.filter((pattern) => pattern !== serverName) + : existing.includes(serverName) + ? existing + : [...existing, serverName]; + changed = next.length !== existing.length; + if (changed) { + await deps.persistSetting( + target.workspaceCwd, + settingScope, + 'mcp.excluded', + next, + target.runtime?.generationGuard?.assertOpen, + ); + } + }, + ); + if (blocking.length > 0) { + res.status(409).json({ + error: `MCP server is excluded by pattern: ${blocking.join(', ')}`, + code: 'mcp_excluded_by_pattern', + patterns: blocking, + }); + return null; + } + return { + changed, + value: next, + activation: changed + ? scheduleMcpConfiguration( + affectedRuntimes(deps.workspaceRegistry, target, scope), + ) + : ('applied' as const), + }; + }); + if (!result) return; + if (result.changed) { + publishConfiguration( + target, + scope, + 'mcp.excluded', + result.value, + deps, + ); + } + res.status(200).json({ + serverName, + action, + ok: true, + changed: result.changed, + activation: result.activation, + }); + } catch (error) { + deps.sendBridgeError(res, error, { + route: `POST ${base}/config/mcp/:server/${action}`, + }); + } + }, + ); + } +} + +function publishConfiguration( + target: McpConfigTarget, + scope: McpConfigScope, + key: string, + value: unknown, + deps: McpConfigRoutesDeps, +): void { + deps.invalidateServeFeaturesCache(); + if (scope === 'user') { + try { + deps.broadcastSettingsChanged(key, value, scope, undefined); + } catch { + // Durable configuration is already committed. + } + for (const candidate of deps.workspaceRegistry.listManaged()) { + if (candidate.workspaceCwd === target.workspaceCwd) continue; + try { + candidate.bridge.publishWorkspaceEvent({ + type: 'settings_changed', + data: { key, value, scope }, + }); + } catch { + // Durable configuration is already committed. + } + } + return; + } + try { + target.runtime?.bridge.publishWorkspaceEvent({ + type: 'settings_changed', + data: { key, value, scope }, + }); + } catch { + // Durable configuration is already committed. + } +} + +export function registerWorkspaceMcpConfigRoutes( + app: Application, + deps: McpConfigRoutesDeps, +): void { + registerFor( + app, + '/workspace', + 'user', + () => ({ workspaceCwd: deps.workspaceRegistry.primaryEntry.workspaceCwd }), + () => ({ workspaceCwd: deps.workspaceRegistry.primaryEntry.workspaceCwd }), + deps, + ); + registerFor( + app, + '/workspaces/:workspace', + 'workspace', + (req, res) => { + const entry = resolveWorkspaceEntryFromParam( + deps.workspaceRegistry, + req, + res, + ); + return entry ? { workspaceCwd: entry.workspaceCwd } : null; + }, + (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + return runtime && requireTrustedWorkspaceRuntime(runtime, res) + ? { workspaceCwd: runtime.workspaceCwd, runtime } + : null; + }, + deps, + ); +} diff --git a/packages/cli/src/serve/routes/workspace-runtime-mcp.ts b/packages/cli/src/serve/routes/workspace-runtime-mcp.ts new file mode 100644 index 00000000000..c1d586abdfd --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-runtime-mcp.ts @@ -0,0 +1,291 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Application, Request, RequestHandler, Response } from 'express'; +import type { SendBridgeError } from '../server/error-response.js'; +import { + createBuildWorkspaceCtx, + MAX_SERVER_NAME_LENGTH, +} from '../server/request-helpers.js'; +import { + requireTrustedWorkspaceRuntime, + resolveWorkspaceRuntimeFromParam, + sendWorkspaceRuntimeUnavailable, +} from '../workspace-route-runtime.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from '../workspace-registry.js'; +import { getWorkspaceRuntimeCoordinatorIfSupported } from '../workspace-runtime-coordinator.js'; + +interface WorkspaceRuntimeMcpRoutesDeps { + workspaceRegistry: WorkspaceRegistry; + mutate: (opts?: { strict?: boolean }) => RequestHandler; + safeBody: (req: Request) => Record; + sendBridgeError: SendBridgeError; +} + +type ResolveRuntime = (req: Request, res: Response) => WorkspaceRuntime | null; + +function validateRuntimeServerName( + name: unknown, + res: Response, +): name is string { + if ( + typeof name === 'string' && + name.length > 0 && + name.length <= MAX_SERVER_NAME_LENGTH && + name !== '__proto__' && + name !== 'constructor' && + name !== 'prototype' + ) + return true; + res.status(400).json({ + error: + name === '__proto__' || name === 'constructor' || name === 'prototype' + ? 'Server name must not be a reserved JS property name' + : typeof name === 'string' && name.length > 0 + ? `Server name exceeds ${MAX_SERVER_NAME_LENGTH}-character limit` + : 'Server name is required and must be a non-empty string', + code: 'invalid_server_name', + }); + return false; +} + +function requireRuntimeCoordinator(runtime: WorkspaceRuntime, res: Response) { + const coordinator = getWorkspaceRuntimeCoordinatorIfSupported(runtime); + if (coordinator) return coordinator; + res.status(501).json({ + error: 'Workspace runtime lifecycle is not supported', + code: 'workspace_runtime_not_supported', + }); + return undefined; +} + +function parseReloadOptions(body: Record, res: Response) { + const forceReconnectAll = body['forceReconnectAll']; + const forceReconnectWhich = body['forceReconnectWhich']; + if ( + forceReconnectAll !== undefined && + typeof forceReconnectAll !== 'boolean' + ) { + res.status(400).json({ + error: '`forceReconnectAll` must be a boolean', + code: 'invalid_force_reconnect_all_flag', + }); + return null; + } + if ( + forceReconnectWhich !== undefined && + (!Array.isArray(forceReconnectWhich) || + forceReconnectWhich.some( + (name) => typeof name !== 'string' || name.length === 0, + )) + ) { + res.status(400).json({ + error: '`forceReconnectWhich` must be an array of server names', + code: 'invalid_force_reconnect_which', + }); + return null; + } + if (forceReconnectAll === true && forceReconnectWhich !== undefined) { + res.status(400).json({ + error: 'Reconnect options cannot be combined', + code: 'conflicting_force_reconnect_options', + }); + return null; + } + return { + ...(forceReconnectAll === undefined ? {} : { forceReconnectAll }), + ...(forceReconnectWhich === undefined + ? {} + : { forceReconnectWhich: forceReconnectWhich as string[] }), + }; +} + +function parseEntryIndex( + req: Request, + res: Response, +): number | undefined | null { + const raw = req.query['entryIndex']; + if (raw === undefined || raw === '*') return undefined; + const value = typeof raw === 'string' ? Number.parseInt(raw, 10) : NaN; + if (!Number.isInteger(value) || value < 0 || String(value) !== raw) { + res.status(400).json({ + error: '`entryIndex` must be a non-negative integer or "*"', + code: 'invalid_entry_index', + }); + return null; + } + return value; +} + +function registerFor( + app: Application, + base: string, + resolveRuntime: ResolveRuntime, + deps: WorkspaceRuntimeMcpRoutesDeps, +): void { + app.get(`${base}/runtime/mcp`, async (req, res) => { + const runtime = resolveRuntime(req, res); + if (!runtime) return; + const route = `GET ${base}/runtime/mcp`; + try { + res + .status(200) + .json( + await runtime.workspaceService.getWorkspaceMcpStatus( + createBuildWorkspaceCtx(runtime.workspaceCwd)(route), + ), + ); + } catch (error) { + deps.sendBridgeError(res, error, { route }); + } + }); + + app.get(`${base}/runtime/mcp/:server/tools`, async (req, res) => { + const runtime = resolveRuntime(req, res); + if (!runtime) return; + const serverName = req.params['server']; + if (!validateRuntimeServerName(serverName, res)) return; + const route = `GET ${base}/runtime/mcp/:server/tools`; + try { + res + .status(200) + .json(await runtime.bridge.getWorkspaceMcpToolsStatus(serverName)); + } catch (error) { + deps.sendBridgeError(res, error, { route }); + } + }); + + app.get(`${base}/runtime/mcp/:server/resources`, async (req, res) => { + const runtime = resolveRuntime(req, res); + if (!runtime) return; + const serverName = req.params['server']; + if (!validateRuntimeServerName(serverName, res)) return; + const route = `GET ${base}/runtime/mcp/:server/resources`; + try { + res + .status(200) + .json(await runtime.bridge.getWorkspaceMcpResourcesStatus(serverName)); + } catch (error) { + deps.sendBridgeError(res, error, { route }); + } + }); + + app.post( + `${base}/runtime/mcp/reload`, + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveRuntime(req, res); + if (!runtime) return; + const coordinator = requireRuntimeCoordinator(runtime, res); + if (!coordinator) return; + const options = parseReloadOptions(deps.safeBody(req), res); + if (!options) return; + const route = `POST ${base}/runtime/mcp/reload`; + try { + const result = await coordinator.runMcpRuntimeMutation(() => + runtime.bridge.reloadWorkspaceMcp(options), + ); + runtime.generationGuard?.assertOpen(); + res.status(200).json(result); + } catch (error) { + deps.sendBridgeError(res, error, { route }); + } + }, + ); + + app.post( + `${base}/runtime/mcp/:server/restart`, + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveRuntime(req, res); + if (!runtime) return; + const coordinator = requireRuntimeCoordinator(runtime, res); + if (!coordinator) return; + const serverName = req.params['server']; + if (!validateRuntimeServerName(serverName, res)) return; + const entryIndex = parseEntryIndex(req, res); + if (entryIndex === null) return; + const route = `POST ${base}/runtime/mcp/:server/restart`; + try { + const result = await coordinator.runMcpRuntimeMutation(() => + runtime.workspaceService.restartMcpServer( + createBuildWorkspaceCtx(runtime.workspaceCwd)(route), + serverName, + entryIndex === undefined ? undefined : { entryIndex }, + ), + ); + runtime.generationGuard?.assertOpen(); + res.status(200).json(result); + } catch (error) { + deps.sendBridgeError(res, error, { route }); + } + }, + ); + + for (const action of ['approve', 'authenticate', 'clear-auth'] as const) { + app.post( + `${base}/runtime/mcp/:server/${action}`, + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveRuntime(req, res); + if (!runtime) return; + const coordinator = requireRuntimeCoordinator(runtime, res); + if (!coordinator) return; + const serverName = req.params['server']; + if (!validateRuntimeServerName(serverName, res)) return; + const route = `POST ${base}/runtime/mcp/:server/${action}`; + try { + const result = await coordinator.runMcpRuntimeMutation(() => + runtime.bridge.manageMcpServer(serverName, action, undefined), + ); + runtime.generationGuard?.assertOpen(); + res.status(200).json(result); + } catch (error) { + deps.sendBridgeError(res, error, { route }); + } + }, + ); + } +} + +export function registerWorkspaceRuntimeMcpRoutes( + app: Application, + deps: WorkspaceRuntimeMcpRoutesDeps, +): void { + registerFor( + app, + '/workspace', + (_req, res) => { + const entry = deps.workspaceRegistry.primaryEntry; + const runtime = + entry.state === 'active' ? entry.current?.runtime : undefined; + if (!runtime) { + sendWorkspaceRuntimeUnavailable(res, entry); + return null; + } + return requireTrustedWorkspaceRuntime(runtime, res) ? runtime : null; + }, + deps, + ); + registerFor( + app, + '/workspaces/:workspace', + (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + return runtime && requireTrustedWorkspaceRuntime(runtime, res) + ? runtime + : null; + }, + deps, + ); +} diff --git a/packages/cli/src/serve/routes/workspace-settings.ts b/packages/cli/src/serve/routes/workspace-settings.ts index 866e15a252a..61268fc2af2 100644 --- a/packages/cli/src/serve/routes/workspace-settings.ts +++ b/packages/cli/src/serve/routes/workspace-settings.ts @@ -73,7 +73,7 @@ const VALID_WRITE_SCOPES = new Set(['workspace', 'user']); const QUALIFIED_WRITE_SCOPES = new Set(['workspace']); const mcpServerMutationQueues = new Map>(); -interface McpServerSettingMutation { +export interface McpServerSettingMutation { operation: 'set' | 'remove'; name: string; } @@ -225,7 +225,7 @@ const SCOPE_MAP: Record = { workspace: SettingScope.Workspace, }; -function prepareSettingWrite( +export function prepareSettingWrite( workspace: string, scope: SettingScope, key: string, @@ -279,7 +279,7 @@ function parseMcpServerMutation( return { operation, name }; } -async function withMcpServerMutationLock( +export async function withMcpServerMutationLock( workspace: string, scope: SettingScope, operation: () => Promise, diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index bba72d1b5d7..78eaf68484a 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -4171,6 +4171,32 @@ describe('runQwenServe telemetry validation', () => { expect(primaryEpochSource).toBeDefined(); expect(secondaryEpochSource).toBeDefined(); expect(primaryEpochSource).not.toBe(secondaryEpochSource); + const primaryMcpAuthenticationAdmission = createBridge.mock.calls.find( + ([options]) => options.boundWorkspace === canonicalizeWorkspace(primary), + )?.[0].acquireMcpAuthentication; + const secondaryMcpAuthenticationAdmission = createBridge.mock.calls.find( + ([options]) => + options.boundWorkspace === canonicalizeWorkspace(secondary), + )?.[0].acquireMcpAuthentication; + expect(primaryMcpAuthenticationAdmission).toBeDefined(); + expect(secondaryMcpAuthenticationAdmission).toBe( + primaryMcpAuthenticationAdmission, + ); + const releaseAuthentication = primaryMcpAuthenticationAdmission!( + primary, + 'aone', + ); + expect(releaseAuthentication).toBeTypeOf('function'); + expect(secondaryMcpAuthenticationAdmission!(secondary, 'yuque')).toBe( + undefined, + ); + releaseAuthentication!(); + const releaseSecondaryAuthentication = secondaryMcpAuthenticationAdmission!( + secondary, + 'yuque', + ); + expect(releaseSecondaryAuthentication).toBeTypeOf('function'); + releaseSecondaryAuthentication!(); for (const [options] of createBridge.mock.calls) { expect(options).toMatchObject({ delegateReadTextFileToClient: false, diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 180a76a2c48..daa3c83da48 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -5160,6 +5160,17 @@ async function runQwenServeImpl( } return source; }; + let mcpAuthenticationActive = false; + const acquireMcpAuthentication = () => { + if (mcpAuthenticationActive) return undefined; + mcpAuthenticationActive = true; + let released = false; + return () => { + if (released) return; + released = true; + mcpAuthenticationActive = false; + }; + }; const totalSessionAdmission = runtime.createTotalSessionAdmissionController( { maxTotalSessions: opts.maxTotalSessions, @@ -5553,6 +5564,7 @@ async function runQwenServeImpl( : {}), boundWorkspace, runtimeEpochSource: runtimeEpochSourceFor(boundWorkspace), + acquireMcpAuthentication, // Prompt terminal ledger: persisted beside the transcript so a // restarted daemon can reconcile dangling prompts on cold load. promptLedger: runtime.createPromptLedgerSink( @@ -6123,6 +6135,7 @@ async function runQwenServeImpl( : {}), boundWorkspace: workspaceInput.cwd, runtimeEpochSource: runtimeEpochSourceFor(workspaceInput.cwd), + acquireMcpAuthentication, promptLedger: runtime.createPromptLedgerSink( workspaceInput.cwd, secondaryEnv.sessionRuntimeBaseDir, @@ -6798,6 +6811,7 @@ async function runQwenServeImpl( : {}), boundWorkspace: cwd, runtimeEpochSource: runtimeEpochSourceFor(cwd), + acquireMcpAuthentication, // Live-conversation workspaces keep transcripts outside the // runtime storage layout, so no ledger sink is wired there. ...(provenance === 'live-conversation' diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index d5fdfdbe578..3cb09d0e008 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -288,6 +288,8 @@ import { registerWorkspaceMcpControlRoutes, registerWorkspaceQualifiedMcpControlRoutes, } from './routes/workspace-mcp-control.js'; +import { registerWorkspaceMcpConfigRoutes } from './routes/workspace-mcp-config.js'; +import { registerWorkspaceRuntimeMcpRoutes } from './routes/workspace-runtime-mcp.js'; import { registerWorkspaceChannelControlRoutes } from './routes/workspace-channel-control.js'; import { registerWorkspaceChannelManagementRoutes } from './routes/workspace-channel-management.js'; import { registerWorkspaceChannelObservedContactRoutes } from './routes/workspace-channel-observed-contacts.js'; @@ -2762,6 +2764,17 @@ export function createServeApp( ...workspaceRegistry.list().map((runtime) => runtime.bridge), ]), }); + registerWorkspaceMcpConfigRoutes(app, { + workspaceRegistry, + mutate, + safeBody, + persistSetting: async (...args) => { + await persistSetting(...args); + }, + sendBridgeError, + invalidateServeFeaturesCache, + broadcastSettingsChanged, + }); } registerWorkspacePermissionsRoutes(app, { boundWorkspace: primaryBoundWorkspace, @@ -2920,6 +2933,12 @@ export function createServeApp( safeBody, sendBridgeError, }); + registerWorkspaceRuntimeMcpRoutes(app, { + workspaceRegistry, + mutate, + safeBody, + sendBridgeError, + }); const channelWorkerControl = deps.getChannelWorkerControl ?? (deps.getChannelWorkerSnapshot diff --git a/packages/cli/src/serve/server/error-response.test.ts b/packages/cli/src/serve/server/error-response.test.ts index fb101357431..8c9882c4b1d 100644 --- a/packages/cli/src/serve/server/error-response.test.ts +++ b/packages/cli/src/serve/server/error-response.test.ts @@ -6,7 +6,10 @@ import type { Response } from 'express'; import { describe, expect, it, vi } from 'vitest'; -import { SessionNotFoundError } from '@qwen-code/acp-bridge/bridgeErrors'; +import { + McpAuthenticationInProgressError, + SessionNotFoundError, +} from '@qwen-code/acp-bridge/bridgeErrors'; import { SessionIdCaseConflictError, SessionTranscriptChangedError, @@ -45,6 +48,18 @@ function responseMock(): { } describe('sendBridgeError session writer errors', () => { + it('maps concurrent MCP authentication to conflict', () => { + const { response, status, json } = responseMock(); + + sendBridgeError(response, new McpAuthenticationInProgressError()); + + expect(status).toHaveBeenCalledWith(409); + expect(json).toHaveBeenCalledWith({ + error: 'Another MCP authentication is already in progress', + code: 'mcp_authentication_in_progress', + }); + }); + it('serializes the structured session-closing code', () => { const { response, status, json } = responseMock(); diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index f3e8f4b8398..a3264fb2ae2 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -30,6 +30,7 @@ import { InvalidRewindTargetError, InvalidSessionMetadataError, InvalidSessionScopeError, + McpAuthenticationInProgressError, McpServerNotFoundError, McpServerRestartFailedError, PermissionForbiddenError, @@ -517,6 +518,13 @@ export function sendBridgeError( }); return; } + if (err instanceof McpAuthenticationInProgressError) { + res.status(409).json({ + error: err.message, + code: 'mcp_authentication_in_progress', + }); + return; + } if (err instanceof McpServerNotFoundError) { // Stable 404 for "MCP server name not in config". res.status(404).json({ diff --git a/packages/cli/src/serve/workspace-qualified-rest.test.ts b/packages/cli/src/serve/workspace-qualified-rest.test.ts index 01f032eabb7..7a84d90beed 100644 --- a/packages/cli/src/serve/workspace-qualified-rest.test.ts +++ b/packages/cli/src/serve/workspace-qualified-rest.test.ts @@ -85,6 +85,15 @@ function makeBridge(): AcpSessionBridge { v: 1, resources: [], })), + getWorkspaceRuntimeLifecycleSnapshot: vi.fn(() => ({ + state: 'idle', + runtimeLive: true, + runtimeEpoch: 1, + activeWork: false, + })), + preheat: vi.fn(async () => {}), + initializeWorkspaceMcp: vi.fn(async () => ({ accepted: true })), + reloadWorkspaceMcp: vi.fn(async () => ({ accepted: true })), getDaemonStatusSnapshot: vi.fn(() => ({ limits: { maxSessions: 20, @@ -186,6 +195,10 @@ function makeWorkspaceService(label: string): DaemonWorkspaceService { getWorkspaceMcpStatus: vi.fn(async (ctx) => ({ v: 1, workspaceCwd: ctx.workspaceCwd, + initialized: true, + runtimeEpoch: 1, + source: 'live' as const, + discoveryState: 'completed' as const, servers: [{ name: label }], })), getWorkspaceSkillsStatus: vi.fn(async (ctx) => ({ @@ -217,6 +230,7 @@ function makeWorkspaceService(label: string): DaemonWorkspaceService { } async function makeHarness(opts?: { + primaryTrusted?: boolean; secondaryTrusted?: boolean; secondaryDirName?: string; token?: string; @@ -237,7 +251,7 @@ async function makeHarness(opts?: { const primaryFsFactoryBase = createWorkspaceFileSystemFactory({ boundWorkspaces: [primaryCwd], - trusted: true, + trusted: opts?.primaryTrusted ?? true, emit: () => {}, }); // A Proxy (not a spread) so prototype methods like resolve/stat are @@ -287,7 +301,7 @@ async function makeHarness(opts?: { workspaceCwd: primaryCwd, sessionRuntimeBaseDir: path.join(primaryCwd, '.runtime'), primary: true, - trusted: true, + trusted: opts?.primaryTrusted ?? true, env: { mode: 'parent-process', overlayKeys: [] }, bridge: makeBridge(), workspaceService: primaryWorkspaceService, @@ -594,6 +608,7 @@ describe('workspace-qualified core REST', () => { for (const route of [ 'mcp', + 'runtime/mcp', 'skills', 'providers', 'env', @@ -656,6 +671,16 @@ describe('workspace-qualified core REST', () => { .set('Host', host()); expect(mcpResources.status).toBe(200); expect(mcpResources.body).toEqual({ v: 1, resources: [] }); + + const runtimeMcp = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.secondaryId)}/runtime/mcp`) + .set('Host', host()); + expect(runtimeMcp.status).toBe(200); + expect(runtimeMcp.body).toMatchObject({ + workspaceCwd: h.secondaryCwd, + runtimeEpoch: 1, + source: 'live', + }); } finally { await fsp.rm(h.scratch, { recursive: true, force: true }); } @@ -1167,6 +1192,44 @@ describe('workspace-qualified core REST', () => { restarted: true, }); + const runtimeRestart = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/runtime/mcp/docs/restart`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({}); + expect(runtimeRestart.status).toBe(200); + expect(runtimeRestart.body).toMatchObject({ + serverName: 'docs', + restarted: true, + }); + + const dottedRestart = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/runtime/mcp/foo-bar.io/restart`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({}); + expect(dottedRestart.status).toBe(200); + expect(dottedRestart.body).toMatchObject({ + serverName: 'foo-bar.io', + restarted: true, + }); + + for (const name of ['constructor', '__proto__', 'prototype']) { + const reserved = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/runtime/mcp/${name}/clear-auth`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({}); + expect(reserved.status).toBe(400); + expect(reserved.body.code).toBe('invalid_server_name'); + } + const enable = await request(h.app) .post( `/workspaces/${encodeURIComponent(h.secondaryId)}/mcp/docs/enable`, @@ -1223,6 +1286,211 @@ describe('workspace-qualified core REST', () => { } }); + it('reports unsupported lifecycle for runtime MCP mutations', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const runtime = h.workspaceRegistry.getByWorkspaceId(h.secondaryId)!; + delete (runtime.bridge as Partial) + .getWorkspaceRuntimeLifecycleSnapshot; + + const res = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/runtime/mcp/reload`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({}); + + expect(res.status).toBe(501); + expect(res.body.code).toBe('workspace_runtime_not_supported'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('rejects an MCP mutation completed by a closed generation', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const runtime = h.workspaceRegistry.getByWorkspaceId(h.secondaryId)!; + vi.mocked( + runtime.workspaceService.restartMcpServer, + ).mockImplementationOnce(async (_ctx, serverName) => { + runtime.generationGuard?.close(); + return { serverName, restarted: true, durationMs: 0 }; + }); + + const res = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/runtime/mcp/docs/restart`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({}); + + expect(res.status).toBe(503); + expect(res.body.code).toBe('workspace_runtime_unavailable'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('persists workspace MCP configuration through the selected workspace route', async () => { + const h = await makeHarness({ token: 'secret' }); + vi.mocked(h.secondaryBridge.publishWorkspaceEvent).mockImplementationOnce( + () => { + throw new Error('subscriber unavailable'); + }, + ); + try { + const response = await request(h.app) + .put( + `/workspaces/${encodeURIComponent(h.secondaryId)}/config/mcp/servers/docs`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ + scope: 'workspace', + config: { command: 'node', args: ['server.js'] }, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + name: 'docs', + scope: 'workspace', + activation: 'reconciling', + }); + expect(h.persistSetting).toHaveBeenCalledWith( + h.secondaryCwd, + expect.any(String), + 'mcpServers', + { docs: { command: 'node', args: ['server.js'] } }, + expect.any(Function), + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('does not publish unchanged workspace MCP exclusions', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const response = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/config/mcp/docs/enable`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ scope: 'workspace' }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ changed: false }); + expect(h.secondaryBridge.publishWorkspaceEvent).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('rejects enabling an MCP server blocked by a wildcard exclusion', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + await fsp.mkdir(path.join(h.secondaryCwd, '.qwen'), { recursive: true }); + await fsp.writeFile( + path.join(h.secondaryCwd, '.qwen', 'settings.json'), + JSON.stringify({ mcp: { excluded: ['docs*'] } }), + ); + + const response = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/config/mcp/docs/enable`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ scope: 'workspace' }); + + expect(response.status).toBe(409); + expect(response.body).toMatchObject({ + code: 'mcp_excluded_by_pattern', + patterns: ['docs*'], + }); + expect(h.persistSetting).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('reconciles user MCP configuration across managed workspaces', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const response = await request(h.app) + .put('/workspace/config/mcp/servers/global-docs') + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ scope: 'user', config: { command: 'node' } }); + + expect(response.status).toBe(200); + expect(response.body.activation).toBe('reconciling'); + expect(h.primaryBridge.publishWorkspaceEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'settings_changed' }), + ); + expect(h.secondaryBridge.publishWorkspaceEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'settings_changed' }), + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('persists user MCP configuration without a live trusted primary runtime', async () => { + const h = await makeHarness({ primaryTrusted: false, token: 'secret' }); + h.workspaceRegistry.beginReplacement( + h.workspaceRegistry.primaryEntry, + 'policy-2', + ); + try { + const response = await request(h.app) + .put('/workspace/config/mcp/servers/global-docs') + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ scope: 'user', config: { command: 'node' } }); + + expect(h.persistSetting).toHaveBeenCalledWith( + h.primaryCwd, + expect.any(String), + 'mcpServers', + expect.objectContaining({ + 'global-docs': expect.objectContaining({ command: 'node' }), + }), + undefined, + ); + expect(response.status).toBe(200); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('reads untrusted workspace MCP configuration but rejects its mutation', async () => { + const h = await makeHarness({ secondaryTrusted: false, token: 'secret' }); + try { + const path = `/workspaces/${encodeURIComponent(h.secondaryId)}/config/mcp/servers`; + const read = await request(h.app) + .get(path) + .set('Authorization', 'Bearer secret') + .set('Host', host()); + expect(read.status).toBe(200); + expect(read.body).toMatchObject({ v: 1 }); + + const mutation = await request(h.app) + .put(`${path}/docs`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ scope: 'workspace', config: { command: 'node' } }); + expect(mutation.status).toBe(403); + expect(mutation.body.code).toBe('untrusted_workspace'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + it('routes workspace-qualified tool toggles and validates mutation bodies', async () => { const h = await makeHarness({ token: 'secret' }); try { diff --git a/packages/cli/src/serve/workspace-runtime-coordinator.test.ts b/packages/cli/src/serve/workspace-runtime-coordinator.test.ts index a1fa3967780..20f7b2a36b9 100644 --- a/packages/cli/src/serve/workspace-runtime-coordinator.test.ts +++ b/packages/cli/src/serve/workspace-runtime-coordinator.test.ts @@ -5,9 +5,10 @@ */ import { describe, expect, it, vi } from 'vitest'; -import type { - AcpSessionBridge, - BridgeWorkspaceRuntimeLifecycleSnapshot, +import { + WorkspaceDrainingError, + type AcpSessionBridge, + type BridgeWorkspaceRuntimeLifecycleSnapshot, } from './acp-session-bridge.js'; import type { WorkspaceRuntime } from './workspace-registry.js'; import { @@ -33,19 +34,46 @@ function makeRuntime() { activeWork: false, }; }); + const initializeWorkspaceMcp = vi.fn(async () => ({ accepted: true })); + const reloadWorkspaceMcp = vi.fn(async () => ({ accepted: true })); + const getWorkspaceMcpStatus = vi.fn( + async (): Promise<{ + v: 1; + workspaceCwd: string; + initialized: boolean; + runtimeEpoch: number; + source: 'live' | 'cache'; + discoveryState: 'not_started' | 'in_progress' | 'completed'; + servers: []; + }> => ({ + v: 1, + workspaceCwd: '/workspace', + initialized: true, + runtimeEpoch: snapshot.runtimeEpoch, + source: 'live', + discoveryState: 'completed', + servers: [], + }), + ); const bridge = { sessionCount: 0, preheat, + initializeWorkspaceMcp, + reloadWorkspaceMcp, getWorkspaceRuntimeLifecycleSnapshot: () => snapshot, } as unknown as AcpSessionBridge; const runtime = { workspaceCwd: '/workspace', bridge, + workspaceService: { getWorkspaceMcpStatus }, } as unknown as WorkspaceRuntime; return { runtime, bridge, preheat, + getWorkspaceMcpStatus, + initializeWorkspaceMcp, + reloadWorkspaceMcp, setSnapshot( update: Partial, ): void { @@ -70,12 +98,447 @@ describe('WorkspaceRuntimeCoordinator', () => { state: 'idle', runtimeLive: true, runtimeEpoch: 1, + capabilities: { + mcp: { state: 'ready', revision: 0, runtimeEpoch: 1 }, + }, }); expect(harness.preheat).toHaveBeenCalledWith({ keepAliveMs: 600_000, }); }); + it('marks MCP stale when its runtime stops without changing epoch', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + + harness.setSnapshot({ state: 'cold', runtimeLive: false }); + + expect(coordinator.status()).toMatchObject({ + state: 'cold', + runtimeLive: false, + runtimeEpoch: 1, + capabilities: { + mcp: { state: 'stale', revision: 0, runtimeEpoch: 1 }, + }, + }); + }); + + it('does not project queued MCP work into the runtime lifecycle', async () => { + const harness = makeRuntime(); + let resolveStatus!: (value: { + v: 1; + workspaceCwd: string; + initialized: boolean; + runtimeEpoch: number; + source: 'live'; + discoveryState: 'completed'; + servers: []; + }) => void; + harness.getWorkspaceMcpStatus.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStatus = resolve; + }), + ); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + const ensure = coordinator.ensure(); + await vi.waitFor(() => { + expect(harness.getWorkspaceMcpStatus).toHaveBeenCalledOnce(); + }); + + expect(coordinator.status()).toMatchObject({ + state: 'idle', + capabilities: { mcp: { state: 'starting' } }, + }); + + resolveStatus({ + v: 1, + workspaceCwd: '/workspace', + initialized: true, + runtimeEpoch: 1, + source: 'live', + discoveryState: 'completed', + servers: [], + }); + await ensure; + }); + + it('defers MCP configuration reconciliation while cold', () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + + expect(coordinator.reconcileMcpConfiguration()).toBe('deferred'); + expect(coordinator.status().capabilities?.mcp?.state).toBe('not_started'); + expect(harness.reloadWorkspaceMcp).not.toHaveBeenCalled(); + }); + + it('reconciles MCP configuration on the live workspace runtime', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + + expect(coordinator.reconcileMcpConfiguration()).toBe('reconciling'); + await vi.waitFor(() => { + expect(harness.reloadWorkspaceMcp).toHaveBeenCalledOnce(); + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'ready', + revision: 1, + runtimeEpoch: 1, + }); + }); + }); + + it('observes an MCP reload already in progress', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + harness.reloadWorkspaceMcp.mockResolvedValueOnce({ accepted: false }); + + expect(coordinator.reconcileMcpConfiguration()).toBe('reconciling'); + + await vi.waitFor(() => { + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'ready', + revision: 1, + }); + }); + }); + + it('skips superseded queued MCP reloads', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + + coordinator.reconcileMcpConfiguration(); + coordinator.reconcileMcpConfiguration(); + + await vi.waitFor(() => { + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'ready', + revision: 2, + }); + }); + expect(harness.reloadWorkspaceMcp).toHaveBeenCalledOnce(); + }); + + it('keeps a queued config reload when a runtime mutation bumps the revision', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + let releaseMutation!: () => void; + const mutationGate = new Promise((resolve) => { + releaseMutation = resolve; + }); + const firstMutation = coordinator.runMcpRuntimeMutation(async () => { + await mutationGate; + return { accepted: true }; + }); + await vi.waitFor(() => expect(coordinator.hasActiveWork()).toBe(true)); + + coordinator.reconcileMcpConfiguration(); + const secondMutation = coordinator.runMcpRuntimeMutation(async () => ({ + accepted: true, + })); + releaseMutation(); + + await Promise.all([firstMutation, secondMutation]); + expect(harness.reloadWorkspaceMcp).toHaveBeenCalledOnce(); + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'ready', + revision: 3, + }); + }); + + it('records a background MCP reconciliation failure', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + harness.reloadWorkspaceMcp.mockRejectedValueOnce( + new Error('reload failed'), + ); + + expect(coordinator.reconcileMcpConfiguration()).toBe('reconciling'); + + await vi.waitFor(() => { + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'error', + revision: 1, + error: { message: 'reload failed' }, + }); + }); + }); + + it('preheats a cold runtime before an MCP mutation', async () => { + const harness = makeRuntime(); + const mutation = vi.fn(async () => { + expect(harness.preheat).toHaveBeenCalledOnce(); + return { accepted: true }; + }); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + + await expect(coordinator.runMcpRuntimeMutation(mutation)).resolves.toEqual({ + accepted: true, + }); + + expect(harness.preheat).toHaveBeenCalledOnce(); + expect(mutation).toHaveBeenCalledOnce(); + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'ready', + revision: 1, + runtimeEpoch: 1, + }); + }); + + it('wraps a failed MCP mutation preheat as an initialization failure', async () => { + const harness = makeRuntime(); + harness.preheat.mockRejectedValueOnce(new Error('child failed')); + + await expect( + getWorkspaceRuntimeCoordinator(harness.runtime).runMcpRuntimeMutation( + async () => ({ accepted: true }), + ), + ).rejects.toBeInstanceOf(WorkspaceRuntimeInitializationError); + }); + + it('rechecks MCP readiness after a rejected runtime mutation', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + const error = new Error('restart failed'); + + await expect( + coordinator.runMcpRuntimeMutation(async () => { + throw error; + }), + ).rejects.toBe(error); + + await vi.waitFor(() => { + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'ready', + revision: 1, + runtimeEpoch: 1, + }); + }); + expect(harness.getWorkspaceMcpStatus).toHaveBeenCalledOnce(); + }); + + it.each([ + { runtimeEpoch: 1, source: 'cache' as const }, + { runtimeEpoch: 0, source: 'live' as const }, + ])( + 'waits for live MCP status after $source epoch $runtimeEpoch', + async (stale) => { + const harness = makeRuntime(); + harness.getWorkspaceMcpStatus.mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/workspace', + initialized: true, + ...stale, + discoveryState: 'completed', + servers: [], + }); + + const result = await getWorkspaceRuntimeCoordinator( + harness.runtime, + ).ensure(); + + expect(harness.getWorkspaceMcpStatus).toHaveBeenCalledTimes(2); + expect(result.capabilities?.mcp).toMatchObject({ + state: 'ready', + runtimeEpoch: 1, + }); + }, + ); + + it('waits for the latest MCP revision when ensure overlaps a config change', async () => { + const harness = makeRuntime(); + let releaseFirstStatus: (() => void) | undefined; + const firstStatus = new Promise((resolve) => { + releaseFirstStatus = resolve; + }); + harness.getWorkspaceMcpStatus.mockImplementationOnce(async () => { + await firstStatus; + return { + v: 1, + workspaceCwd: '/workspace', + initialized: true, + runtimeEpoch: 1, + source: 'live', + discoveryState: 'completed', + servers: [], + }; + }); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + const firstEnsure = coordinator.ensure(); + await vi.waitFor(() => { + expect(harness.getWorkspaceMcpStatus).toHaveBeenCalledOnce(); + }); + harness.preheat.mockImplementation(async () => {}); + + expect(coordinator.reconcileMcpConfiguration()).toBe('reconciling'); + const latestEnsure = coordinator.ensure(); + releaseFirstStatus?.(); + + await firstEnsure; + await expect(latestEnsure).resolves.toMatchObject({ + capabilities: { mcp: { state: 'ready', revision: 1 } }, + }); + }); + + it('abandons stale MCP preparation before running the next revision', async () => { + const harness = makeRuntime(); + let releaseFirstStatus: (() => void) | undefined; + const firstStatus = new Promise((resolve) => { + releaseFirstStatus = resolve; + }); + harness.getWorkspaceMcpStatus.mockImplementationOnce(async () => { + await firstStatus; + return { + v: 1, + workspaceCwd: '/workspace', + initialized: true, + runtimeEpoch: 1, + source: 'live', + discoveryState: 'in_progress', + servers: [], + }; + }); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + const firstEnsure = coordinator.ensure(); + await vi.waitFor(() => { + expect(harness.getWorkspaceMcpStatus).toHaveBeenCalledOnce(); + }); + + expect(coordinator.reconcileMcpConfiguration()).toBe('reconciling'); + releaseFirstStatus?.(); + + await firstEnsure; + await vi.waitFor(() => { + expect(harness.reloadWorkspaceMcp).toHaveBeenCalledOnce(); + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'ready', + revision: 1, + }); + }); + }); + + it('replays a live MCP reconciliation after drain rollback', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + coordinator.beginDrain(); + + expect(coordinator.reconcileMcpConfiguration()).toBe('deferred'); + coordinator.cancelDrain(); + + await vi.waitFor(() => { + expect(harness.reloadWorkspaceMcp).toHaveBeenCalledOnce(); + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'ready', + revision: 1, + }); + }); + }); + + it('repairs a queued MCP mutation rejected by a drain race', async () => { + const harness = makeRuntime(); + harness.setSnapshot({ state: 'idle', runtimeLive: true, runtimeEpoch: 1 }); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + let releaseMutation!: () => void; + const mutationGate = new Promise((resolve) => { + releaseMutation = resolve; + }); + const mutationStarted = vi.fn(); + const first = coordinator.runMcpRuntimeMutation(async () => { + mutationStarted(); + await mutationGate; + return { accepted: true }; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(mutationStarted).toHaveBeenCalledOnce(); + const queued = coordinator.runMcpRuntimeMutation(async () => ({ + accepted: true, + })); + let queuedError: unknown; + const queuedHandled = queued.catch((error: unknown) => { + queuedError = error; + }); + + coordinator.beginDrain(); + releaseMutation(); + await queuedHandled; + expect(queuedError).toBeInstanceOf(WorkspaceDrainingError); + expect(coordinator.status().capabilities?.mcp?.state).toBe('stale'); + coordinator.cancelDrain(); + await first; + + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(harness.reloadWorkspaceMcp).toHaveBeenCalledOnce(); + expect(coordinator.status().capabilities?.mcp?.state).toBe('ready'); + }); + + it('replays MCP reconciliation interrupted while draining', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + harness.getWorkspaceMcpStatus.mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/workspace', + initialized: true, + runtimeEpoch: 1, + source: 'live', + discoveryState: 'in_progress', + servers: [], + }); + + coordinator.reconcileMcpConfiguration(); + await vi.waitFor(() => { + expect(harness.getWorkspaceMcpStatus).toHaveBeenCalledTimes(2); + }); + coordinator.beginDrain(); + await vi.waitFor(() => { + expect(coordinator.status().capabilities?.mcp?.state).toBe('stale'); + }); + coordinator.cancelDrain(); + + await vi.waitFor(() => { + expect(harness.reloadWorkspaceMcp).toHaveBeenCalledTimes(2); + expect(coordinator.status().capabilities?.mcp?.state).toBe('ready'); + }); + }); + + it('handles a queued MCP preparation rejection after ensure returns', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + let releaseMutation!: () => void; + const mutationGate = new Promise((resolve) => { + releaseMutation = resolve; + }); + const mutation = coordinator.runMcpRuntimeMutation(async () => { + await mutationGate; + return { accepted: true }; + }); + await vi.waitFor(() => expect(coordinator.hasActiveWork()).toBe(true)); + + await coordinator.ensure(0); + const unhandled: unknown[] = []; + const onUnhandled = (error: unknown) => unhandled.push(error); + process.on('unhandledRejection', onUnhandled); + try { + coordinator.beginDrain(); + releaseMutation(); + await mutation; + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); + it('renews the warm window on every ensure call', async () => { const harness = makeRuntime(); harness.setSnapshot({ diff --git a/packages/cli/src/serve/workspace-runtime-coordinator.ts b/packages/cli/src/serve/workspace-runtime-coordinator.ts index 912a427c0ab..1987d255bad 100644 --- a/packages/cli/src/serve/workspace-runtime-coordinator.ts +++ b/packages/cli/src/serve/workspace-runtime-coordinator.ts @@ -6,6 +6,7 @@ import { STATUS_SCHEMA_VERSION, + type ServeWorkspaceRuntimeCapabilityStatus, type ServeWorkspaceRuntimeStatus, } from '@qwen-code/acp-bridge/status'; import type { @@ -17,6 +18,8 @@ import type { WorkspaceRuntime } from './workspace-registry.js'; const DEFAULT_ENSURE_TIMEOUT_MS = 60_000; const ENSURE_KEEP_ALIVE_MS = 10 * 60_000; +const MCP_PREPARE_TIMEOUT_MS = 2 * 60_000; +const MCP_POLL_INTERVAL_MS = 250; type LifecycleAcpSessionBridge = AcpSessionBridge & { getWorkspaceRuntimeLifecycleSnapshot(): BridgeWorkspaceRuntimeLifecycleSnapshot; @@ -63,6 +66,23 @@ export class WorkspaceRuntimeCoordinator { private draining = false; + private activeManagementOperations = 0; + + private mcpRevision = 0; + + private mcpConfigRevision = 0; + + private mcpStatus: ServeWorkspaceRuntimeCapabilityStatus = { + state: 'not_started', + revision: 0, + }; + + private mcpReconcileDeferred = false; + + private mcpPhysicalTail: Promise = Promise.resolve(); + + private mcpQueuedWork = 0; + constructor( private readonly runtime: WorkspaceRuntime, private readonly bridge: LifecycleAcpSessionBridge, @@ -73,11 +93,20 @@ export class WorkspaceRuntimeCoordinator { } cancelDrain(): void { - if (!this.disposed) this.draining = false; + if (this.disposed) return; + this.draining = false; + if (this.mcpReconcileDeferred) { + this.mcpReconcileDeferred = false; + this.scheduleMcpReconciliation(); + } } hasActiveWork(): boolean { - return this.bridge.getWorkspaceRuntimeLifecycleSnapshot().activeWork; + return ( + this.activeManagementOperations > 0 || + this.mcpQueuedWork > 0 || + this.bridge.getWorkspaceRuntimeLifecycleSnapshot().activeWork + ); } dispose(): void { @@ -87,12 +116,23 @@ export class WorkspaceRuntimeCoordinator { status(): ServeWorkspaceRuntimeStatus { const snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + const mcpStatus = + this.mcpStatus.runtimeEpoch !== undefined && + (!snapshot.runtimeLive || + this.mcpStatus.runtimeEpoch !== snapshot.runtimeEpoch) + ? { + state: 'stale' as const, + revision: this.mcpStatus.revision, + runtimeEpoch: this.mcpStatus.runtimeEpoch, + } + : this.mcpStatus; return { v: STATUS_SCHEMA_VERSION, workspaceCwd: this.runtime.workspaceCwd, state: snapshot.state, runtimeLive: snapshot.runtimeLive, runtimeEpoch: snapshot.runtimeEpoch, + capabilities: { mcp: mcpStatus }, }; } @@ -100,6 +140,7 @@ export class WorkspaceRuntimeCoordinator { timeoutMs = DEFAULT_ENSURE_TIMEOUT_MS, ): Promise { this.assertAcceptingWork(); + const deadline = Date.now() + timeoutMs; try { await withTimeout( this.bridge.preheat({ keepAliveMs: ENSURE_KEEP_ALIVE_MS }), @@ -117,7 +158,274 @@ export class WorkspaceRuntimeCoordinator { new Error('ACP preheat completed without a live runtime'), ); } - return status; + const revision = this.mcpRevision; + const preparation = this.prepareMcp(); + void preparation.catch((error: unknown) => { + this.recordMcpError(revision, status.runtimeEpoch, error); + }); + const remainingMs = deadline - Date.now(); + if (remainingMs > 0) { + try { + await withTimeout(preparation, remainingMs); + } catch (error) { + if (!(error instanceof WorkspaceRuntimeStillStartingError)) throw error; + } + } + return this.status(); + } + + async runManagementOperation(run: () => Promise): Promise { + this.assertAcceptingWork(); + this.activeManagementOperations += 1; + try { + return await run(); + } finally { + this.activeManagementOperations -= 1; + } + } + + reconcileMcpConfiguration(): 'deferred' | 'reconciling' { + this.mcpRevision += 1; + this.mcpConfigRevision += 1; + return this.scheduleMcpReconciliation(); + } + + private scheduleMcpReconciliation(): 'deferred' | 'reconciling' { + const snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + if (!snapshot.runtimeLive || this.draining || this.disposed) { + this.mcpReconcileDeferred ||= snapshot.runtimeLive && this.draining; + this.mcpStatus = { + state: + this.mcpStatus.runtimeEpoch === undefined ? 'not_started' : 'stale', + revision: this.mcpRevision, + ...(this.mcpStatus.runtimeEpoch === undefined + ? {} + : { runtimeEpoch: this.mcpStatus.runtimeEpoch }), + }; + return 'deferred'; + } + this.mcpStatus = { + state: 'starting', + revision: this.mcpRevision, + runtimeEpoch: snapshot.runtimeEpoch, + }; + const revision = this.mcpRevision; + const configRevision = this.mcpConfigRevision; + void this.queueMcpWork(async () => { + if (configRevision !== this.mcpConfigRevision) return; + await this.bridge.reloadWorkspaceMcp(); + await this.prepareMcpRevision(revision); + }).catch((error: unknown) => { + if (this.draining && !this.disposed) this.mcpReconcileDeferred = true; + this.recordMcpError(revision, snapshot.runtimeEpoch, error); + }); + return 'reconciling'; + } + + async runMcpRuntimeMutation(run: () => Promise): Promise { + this.assertAcceptingWork(); + const revision = ++this.mcpRevision; + const initial = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + this.mcpStatus = { + state: initial.runtimeLive ? 'starting' : 'not_started', + revision, + ...(initial.runtimeLive ? { runtimeEpoch: initial.runtimeEpoch } : {}), + }; + let started = false; + const operation = this.queueMcpWork(async () => { + started = true; + let snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + let mutationRejected = false; + try { + if (!snapshot.runtimeLive) { + try { + await this.bridge.preheat({ keepAliveMs: ENSURE_KEEP_ALIVE_MS }); + } catch (error) { + if (error instanceof WorkspaceRuntimeStillStartingError) + throw error; + throw new WorkspaceRuntimeInitializationError(error); + } + snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + if (!snapshot.runtimeLive) { + throw new WorkspaceRuntimeInitializationError( + new Error('ACP preheat completed without a live runtime'), + ); + } + } + if (revision === this.mcpRevision) { + this.mcpStatus = { + state: 'starting', + revision, + runtimeEpoch: snapshot.runtimeEpoch, + }; + } + let result: T; + try { + result = await run(); + } catch (error) { + mutationRejected = true; + void this.queueMcpWork(() => this.prepareMcpRevision(revision)).catch( + (prepareError: unknown) => { + this.recordMcpError( + revision, + snapshot.runtimeEpoch, + prepareError, + ); + }, + ); + throw error; + } + await this.prepareMcpRevision(revision); + return result; + } catch (error) { + if (!mutationRejected) { + this.recordMcpError(revision, snapshot.runtimeEpoch, error); + } + throw error; + } + }); + try { + return await operation; + } catch (error) { + if (!started) { + if (this.draining && !this.disposed) this.mcpReconcileDeferred = true; + this.recordMcpError(revision, initial.runtimeEpoch, error); + } + throw error; + } + } + + private prepareMcp(): Promise { + const revision = this.mcpRevision; + return this.queueMcpWork(() => this.prepareMcpRevision(revision)); + } + + private queueMcpWork(run: () => Promise): Promise { + this.mcpQueuedWork += 1; + const operation = this.mcpPhysicalTail + .catch(() => undefined) + .then(async () => { + this.assertAcceptingWork(); + return await run(); + }) + .finally(() => { + this.mcpQueuedWork -= 1; + }); + this.mcpPhysicalTail = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + private async prepareMcpRevision(revision: number): Promise { + const deadline = Date.now() + MCP_PREPARE_TIMEOUT_MS; + let snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + if (!snapshot.runtimeLive) { + try { + await this.bridge.preheat({ keepAliveMs: ENSURE_KEEP_ALIVE_MS }); + } catch (error) { + if (error instanceof WorkspaceRuntimeStillStartingError) throw error; + throw new WorkspaceRuntimeInitializationError(error); + } + snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + } + const epoch = snapshot.runtimeEpoch; + if (revision !== this.mcpRevision) return; + this.mcpStatus = { state: 'starting', revision, runtimeEpoch: epoch }; + try { + let status = await this.runtime.workspaceService.getWorkspaceMcpStatus({ + route: 'workspace runtime MCP preparation', + workspaceCwd: this.runtime.workspaceCwd, + }); + let initializationRequested = false; + while (true) { + if (revision !== this.mcpRevision) return; + const current = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + if ( + this.draining || + this.disposed || + !current.runtimeLive || + current.runtimeEpoch !== epoch + ) { + if ( + this.draining && + !this.disposed && + current.runtimeLive && + current.runtimeEpoch === epoch + ) { + this.mcpReconcileDeferred = true; + } + this.mcpStatus = { state: 'stale', revision, runtimeEpoch: epoch }; + return; + } + const currentEpochStatus = + status.runtimeEpoch === epoch && status.source === 'live'; + if ( + currentEpochStatus && + status.discoveryState === 'not_started' && + !initializationRequested + ) { + initializationRequested = true; + await this.bridge.initializeWorkspaceMcp(); + } + if (currentEpochStatus && status.errors?.length) { + throw new Error( + status.errors[0]?.error ?? 'MCP discovery failed to initialize', + ); + } + if (currentEpochStatus && status.discoveryState === 'completed') break; + if (Date.now() >= deadline) { + throw new WorkspaceRuntimeStillStartingError(); + } + await new Promise((resolve) => { + const timer = setTimeout(resolve, MCP_POLL_INTERVAL_MS); + timer.unref?.(); + }); + status = await this.runtime.workspaceService.getWorkspaceMcpStatus({ + route: 'workspace runtime MCP preparation', + workspaceCwd: this.runtime.workspaceCwd, + }); + } + const current = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + if (revision !== this.mcpRevision) return; + if (!current.runtimeLive || current.runtimeEpoch !== epoch) { + this.mcpStatus = { state: 'stale', revision, runtimeEpoch: epoch }; + return; + } + this.mcpStatus = { state: 'ready', revision, runtimeEpoch: epoch }; + } catch (error) { + this.recordMcpError(revision, epoch, error); + } + } + + private recordMcpError( + revision: number, + runtimeEpoch: number, + error: unknown, + ): void { + const current = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + if (revision !== this.mcpRevision) return; + if ( + this.draining || + !current.runtimeLive || + current.runtimeEpoch !== runtimeEpoch + ) { + this.mcpStatus = { state: 'stale', revision, runtimeEpoch }; + return; + } + this.mcpStatus = { + state: 'error', + revision, + runtimeEpoch, + error: { + code: + error instanceof WorkspaceRuntimeStillStartingError + ? 'mcp_prepare_timed_out' + : 'mcp_prepare_failed', + message: error instanceof Error ? error.message : String(error), + }, + }; } private assertAcceptingWork(cause?: unknown): void { diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index fe640b88c48..6dfe1cabefa 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -105,7 +105,10 @@ import type { DaemonGitHubPullRequestList, DaemonGitHubPullRequestCreateResult, DaemonWorkspaceMcpStatus, + DaemonWorkspaceMcpConfigStatus, + DaemonMcpConfigMutationResult, DaemonWorkspaceMcpInitializeResult, + DaemonWorkspaceMcpReloadResult, DaemonWorkspaceMcpReloadOptions, DaemonWorkspaceMcpToolsStatus, DaemonWorkspaceMcpResourcesStatus, @@ -1253,6 +1256,42 @@ export class DaemonClient { ); } + async setUserMcpServer( + name: string, + config: Record, + ): Promise { + return await this.jsonRequest( + `/workspace/config/mcp/servers/${urlEncode(name)}`, + 'PUT /workspace/config/mcp/servers/:name', + { + method: 'PUT', + body: { scope: 'user', config }, + mode: 'rest', + }, + ); + } + + async removeUserMcpServer( + name: string, + ): Promise { + return await this.jsonRequest( + `/workspace/config/mcp/servers/${urlEncode(name)}?scope=user`, + 'DELETE /workspace/config/mcp/servers/:name', + { method: 'DELETE', mode: 'rest' }, + ); + } + + async setUserMcpServerEnabled( + name: string, + enabled: boolean, + ): Promise { + return await this.jsonRequest( + `/workspace/config/mcp/${urlEncode(name)}/${enabled ? 'enable' : 'disable'}`, + 'POST /workspace/config/mcp/:server/:action', + { method: 'POST', body: {}, mode: 'rest' }, + ); + } + async initializeWorkspaceMcp(): Promise { return await this.fetchWithTimeout( `${this.baseUrl}/workspace/mcp/initialize`, @@ -5942,6 +5981,128 @@ export class WorkspaceDaemonClient { return this.get('/mcp', 'GET /workspaces/:workspace/mcp'); } + mcpConfig(): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + '/config/mcp/servers', + 'GET /workspaces/:workspace/config/mcp/servers', + { mode: 'rest' }, + ); + } + + setMcpServer( + name: string, + config: Record, + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/config/mcp/servers/${urlEncode(name)}`, + 'PUT /workspaces/:workspace/config/mcp/servers/:name', + { + method: 'PUT', + body: { scope: 'workspace', config }, + mode: 'rest', + }, + ); + } + + removeMcpServer(name: string): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/config/mcp/servers/${urlEncode(name)}?scope=workspace`, + 'DELETE /workspaces/:workspace/config/mcp/servers/:name', + { method: 'DELETE', mode: 'rest' }, + ); + } + + setMcpServerEnabled( + name: string, + enabled: boolean, + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/config/mcp/${urlEncode(name)}/${enabled ? 'enable' : 'disable'}`, + 'POST /workspaces/:workspace/config/mcp/:server/:action', + { method: 'POST', body: {}, mode: 'rest' }, + ); + } + + runtimeMcp(): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + '/runtime/mcp', + 'GET /workspaces/:workspace/runtime/mcp', + { mode: 'rest' }, + ); + } + + runtimeMcpTools(serverName: string): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/runtime/mcp/${urlEncode(serverName)}/tools`, + 'GET /workspaces/:workspace/runtime/mcp/:server/tools', + { mode: 'rest' }, + ); + } + + runtimeMcpResources( + serverName: string, + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/runtime/mcp/${urlEncode(serverName)}/resources`, + 'GET /workspaces/:workspace/runtime/mcp/:server/resources', + { mode: 'rest' }, + ); + } + + reloadRuntimeMcp( + options: DaemonWorkspaceMcpReloadOptions = {}, + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + '/runtime/mcp/reload', + 'POST /workspaces/:workspace/runtime/mcp/reload', + { + method: 'POST', + body: options, + mode: 'rest', + timeoutMs: MCP_RESTART_DEFAULT_TIMEOUT_MS, + }, + ); + } + + restartRuntimeMcpServer(serverName: string): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/runtime/mcp/${urlEncode(serverName)}/restart`, + 'POST /workspaces/:workspace/runtime/mcp/:server/restart', + { + method: 'POST', + body: {}, + mode: 'rest', + timeoutMs: MCP_RESTART_DEFAULT_TIMEOUT_MS, + }, + ); + } + + manageRuntimeMcpServer( + serverName: string, + action: 'approve' | 'authenticate' | 'clear-auth', + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/runtime/mcp/${urlEncode(serverName)}/${action}`, + 'POST /workspaces/:workspace/runtime/mcp/:server/:action', + { + method: 'POST', + body: {}, + mode: 'rest', + timeoutMs: MCP_RESTART_DEFAULT_TIMEOUT_MS, + }, + ); + } + ensureRuntime(): Promise { return this.client.workspaceJsonRequest( this.workspaceSelector, diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 4a70fcb9fe2..31e8c5a2c76 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -680,7 +680,11 @@ export type { DaemonWorkspaceFileWriteResult, DaemonWorkspaceMcpServerStatus, DaemonWorkspaceMcpStatus, + DaemonWorkspaceMcpConfigStatus, + DaemonMcpConfigMutationResult, + DaemonMcpConfigScope, DaemonWorkspaceMcpInitializeResult, + DaemonWorkspaceMcpReloadResult, DaemonWorkspaceMcpReloadOptions, DaemonWorkspaceMcpToolStatus, DaemonWorkspaceMcpToolsStatus, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index a3ec5c18b83..bb1f8e15725 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1860,6 +1860,8 @@ export interface DaemonWorkspaceMcpStatus { v: 1; workspaceCwd: string; initialized: boolean; + runtimeEpoch?: number; + source?: 'live' | 'cache'; discoveryState?: DaemonMcpDiscoveryState; servers: DaemonWorkspaceMcpServerStatus[]; errors?: DaemonStatusCell[]; @@ -1877,12 +1879,36 @@ export interface DaemonWorkspaceMcpStatus { budgets?: DaemonMcpBudgetStatusCell[]; } +export type DaemonMcpConfigScope = 'user' | 'workspace'; + +export interface DaemonWorkspaceMcpConfigStatus { + v: 1; + effective: Record; + user: Record; + workspace: Record; +} + +export interface DaemonMcpConfigMutationResult { + name?: string; + serverName?: string; + scope?: DaemonMcpConfigScope; + config?: unknown; + action?: 'enable' | 'disable'; + ok?: true; + changed?: boolean; + activation: 'applied' | 'deferred' | 'reconciling'; +} + /** Response of `POST /workspace/mcp/initialize`. */ export interface DaemonWorkspaceMcpInitializeResult { /** True only when this request started a new background discovery task. */ accepted: boolean; } +export interface DaemonWorkspaceMcpReloadResult { + accepted: boolean; +} + export interface DaemonWorkspaceMcpReloadOptions { forceReconnectAll?: boolean; forceReconnectWhich?: string[]; @@ -1903,6 +1929,7 @@ export interface DaemonWorkspaceMcpToolsStatus { workspaceCwd: string; serverName: string; initialized: boolean; + runtimeEpoch?: number; acpChannelLive: boolean; tools: DaemonWorkspaceMcpToolStatus[]; errors?: DaemonStatusCell[]; @@ -1931,6 +1958,7 @@ export interface DaemonWorkspaceMcpResourcesStatus { workspaceCwd: string; serverName: string; initialized: boolean; + runtimeEpoch?: number; acpChannelLive: boolean; resources: DaemonWorkspaceMcpResourceStatus[]; errors?: DaemonStatusCell[]; @@ -1982,6 +2010,14 @@ export interface DaemonWorkspaceRuntimeStatus { state: 'cold' | 'starting' | 'active' | 'idle' | 'stopping'; runtimeLive: boolean; runtimeEpoch: number; + capabilities?: { + mcp?: { + state: 'not_started' | 'starting' | 'ready' | 'stale' | 'error'; + revision: number; + runtimeEpoch?: number; + error?: { code: string; message: string }; + }; + }; } export interface DaemonWorkspaceProviderCurrent { diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index b9321d9a280..bd18e851697 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -312,7 +312,11 @@ export { type DaemonFollowupSuggestionEvent, type DaemonWorkspaceMcpServerStatus, type DaemonWorkspaceMcpStatus, + type DaemonWorkspaceMcpConfigStatus, + type DaemonMcpConfigMutationResult, + type DaemonMcpConfigScope, type DaemonWorkspaceMcpInitializeResult, + type DaemonWorkspaceMcpReloadResult, type DaemonWorkspaceAcpStatusResult, type DaemonWorkspaceAcpPreheatResult, type DaemonWorkspaceRuntimeStatus, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index ba8cac67f65..9e1eeaa5dd2 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -1324,6 +1324,92 @@ describe('DaemonClient', () => { ]); }); + it('uses the workspace-scoped MCP config and runtime REST routes', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { activation: 'reconciling' }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const workspace = client.workspaceByCwd('/work/secondary'); + + await client.setUserMcpServer('docs', { command: 'node' }); + await client.setUserMcpServerEnabled('docs', false); + await client.removeUserMcpServer('docs'); + await workspace.setMcpServer('docs', { command: 'node' }); + await workspace.setMcpServerEnabled('docs', true); + await workspace.removeMcpServer('docs'); + await workspace.mcpConfig(); + await workspace.runtimeMcp(); + await workspace.runtimeMcpTools('docs'); + await workspace.runtimeMcpResources('docs'); + await workspace.reloadRuntimeMcp({ forceReconnectWhich: ['docs'] }); + await workspace.restartRuntimeMcpServer('docs'); + await workspace.manageRuntimeMcpServer('docs', 'authenticate'); + + expect(calls.map((call) => [call.method, call.url, call.body])).toEqual([ + [ + 'PUT', + 'http://daemon/workspace/config/mcp/servers/docs', + '{"scope":"user","config":{"command":"node"}}', + ], + ['POST', 'http://daemon/workspace/config/mcp/docs/disable', '{}'], + [ + 'DELETE', + 'http://daemon/workspace/config/mcp/servers/docs?scope=user', + null, + ], + [ + 'PUT', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/config/mcp/servers/docs', + '{"scope":"workspace","config":{"command":"node"}}', + ], + [ + 'POST', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/config/mcp/docs/enable', + '{}', + ], + [ + 'DELETE', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/config/mcp/servers/docs?scope=workspace', + null, + ], + [ + 'GET', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/config/mcp/servers', + null, + ], + [ + 'GET', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/runtime/mcp', + null, + ], + [ + 'GET', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/runtime/mcp/docs/tools', + null, + ], + [ + 'GET', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/runtime/mcp/docs/resources', + null, + ], + [ + 'POST', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/runtime/mcp/reload', + '{"forceReconnectWhich":["docs"]}', + ], + [ + 'POST', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/runtime/mcp/docs/restart', + '{}', + ], + [ + 'POST', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/runtime/mcp/docs/authenticate', + '{}', + ], + ]); + }); + it('asks the daemon host to open the workspace locally', async () => { const { fetch, calls } = recordingFetch(() => jsonResponse(200, { diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index ab89a011036..1d88fac1377 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -19,6 +19,7 @@ import { type DaemonSessionTaskStatus, type DaemonSettingDescriptor, type DaemonSkillToggleMutation, + type DaemonWorkspaceMcpServerStatus, type DaemonWorkspaceGitStatus, type GoalSnapshotV2, } from '@qwen-code/sdk/daemon'; @@ -409,6 +410,14 @@ const { }), }, mockMcp: { + ensureRuntime: vi.fn().mockResolvedValue({}), + runtimeStatus: vi.fn().mockResolvedValue({ + runtimeLive: true, + runtimeEpoch: 1, + capabilities: { + mcp: { state: 'ready', revision: 0, runtimeEpoch: 1 }, + }, + }), initialize: vi.fn().mockResolvedValue({ accepted: true }), reloadConfig: vi.fn().mockResolvedValue({ accepted: true }), reload: vi.fn(), @@ -418,6 +427,9 @@ const { manageServer: vi.fn(), addServer: vi.fn(), removeServer: vi.fn(), + setConfigServer: vi.fn(), + removeConfigServer: vi.fn(), + setConfigServerEnabled: vi.fn(), loading: false, error: undefined, }, @@ -2066,6 +2078,10 @@ const { mergeMonitorTaskSnapshot, mergeSideTaskCatalog, } = await import('./App'); +const { McpStatusMessage } = await import( + './components/messages/McpStatusMessage' +); +const { I18nProvider } = await import('./i18n'); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -5819,6 +5835,16 @@ beforeEach(() => { mockWorkspaceActions.deleteModel.mockResolvedValue(undefined); mockMcp.initialize.mockClear(); mockMcp.initialize.mockResolvedValue({ accepted: true }); + mockMcp.ensureRuntime.mockClear(); + mockMcp.ensureRuntime.mockResolvedValue({}); + mockMcp.runtimeStatus.mockClear(); + mockMcp.runtimeStatus.mockResolvedValue({ + runtimeLive: true, + runtimeEpoch: 1, + capabilities: { + mcp: { state: 'ready', revision: 0, runtimeEpoch: 1 }, + }, + }); mockMcp.reloadConfig.mockClear(); mockMcp.reloadConfig.mockResolvedValue({ accepted: true }); mockMcp.reload.mockReset(); @@ -5831,6 +5857,9 @@ beforeEach(() => { durationMs: 1, }); mockMcp.manageServer.mockReset(); + mockMcp.setConfigServer.mockReset(); + mockMcp.removeConfigServer.mockReset(); + mockMcp.setConfigServerEnabled.mockReset(); vi.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -20922,8 +20951,12 @@ describe('App session callbacks', () => { }); it('restores composer interaction after closing Plugins on the MCP tab', async () => { - mockWorkspaceActions.loadMcpStatus.mockResolvedValue({ + mockMcp.reload.mockResolvedValue({ + v: 1, + workspaceCwd: '/workspace', initialized: true, + runtimeEpoch: 1, + source: 'live', discoveryState: 'completed', servers: [], }); @@ -20956,7 +20989,7 @@ describe('App session callbacks', () => { await Promise.resolve(); }); await flush(); - expect(mockWorkspaceActions.loadMcpStatus).toHaveBeenCalled(); + expect(mockMcp.reload).toHaveBeenCalled(); expect(testState.latestChatEditorProps?.dialogOpen).toBe(true); const mcpPanel = container.querySelector('[data-testid="inline-panel"]'); @@ -20976,6 +21009,48 @@ describe('App session callbacks', () => { expect(testState.latestChatEditorProps?.dialogOpen).toBe(false); }); + it('ensures the MCP runtime when the manager is refreshed', async () => { + mockMcp.reload.mockResolvedValue({ + v: 1, + workspaceCwd: '/workspace', + initialized: true, + runtimeEpoch: 1, + source: 'live', + discoveryState: 'completed', + servers: [], + }); + const { container } = renderApp(); + await flush(); + + await act(async () => { + container + .querySelector('[data-testid="open-plugins"]') + ?.click(); + await Promise.resolve(); + }); + const panel = container.querySelector('[data-testid="inline-panel"]'); + const mcpTab = + panel?.querySelectorAll('button[role="tab"]')[1]; + await act(async () => { + mcpTab?.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, button: 0 }), + ); + mcpTab?.click(); + await Promise.resolve(); + }); + await vi.waitFor(() => expect(mockMcp.reload).toHaveBeenCalled()); + mockMcp.ensureRuntime.mockClear(); + + const refresh = Array.from( + panel?.querySelectorAll('button') ?? [], + ).find((button) => button.textContent === 'Refresh'); + await act(async () => refresh?.click()); + + await vi.waitFor(() => { + expect(mockMcp.ensureRuntime).toHaveBeenCalledOnce(); + }); + }); + it('opens Channel management from the sidebar', async () => { const { container } = renderApp(); await flush(); @@ -21148,11 +21223,116 @@ describe('App session callbacks', () => { ).toHaveProperty('tabIndex', 0); }); - it('shows server operations without duplicating tools and resources tabs', async () => { + it('prioritizes MCP approval and authentication labels over connection labels', async () => { + const servers: DaemonWorkspaceMcpServerStatus[] = [ + { + name: 'disabled-server', + configOrigin: 'workspace_settings', + disabled: true, + approvalState: 'pending', + requiresAuth: true, + mcpStatus: 'connected', + }, + { + name: 'approval', + configOrigin: 'workspace_settings', + disabled: false, + approvalState: 'pending', + requiresAuth: true, + mcpStatus: 'connected', + }, + { + name: 'authenticating', + configOrigin: 'workspace_settings', + disabled: false, + authenticationState: 'pending', + mcpStatus: 'connected', + }, + { + name: 'auth-failed', + configOrigin: 'workspace_settings', + disabled: false, + authenticationState: 'failed', + mcpStatus: 'connected', + }, + { + name: 'needs-auth', + configOrigin: 'workspace_settings', + disabled: false, + requiresAuth: true, + mcpStatus: 'connecting', + }, + { + name: 'connected-server', + configOrigin: 'workspace_settings', + disabled: false, + authenticationState: 'succeeded', + requiresAuth: true, + mcpStatus: 'connected', + }, + ]; mockWorkspaceActions.loadMcpStatus.mockResolvedValue({ initialized: true, discoveryState: 'completed', workspaceCwd: '/workspace', + source: 'live', + runtimeEpoch: 1, + servers, + }); + const { container } = renderApp(); + await flush(); + + testState.prompt = '/mcp'; + await clickSubmit(container); + await flush(); + + const labelFor = (name: string) => + container.querySelector(`[aria-label="${name}"]`)?.textContent; + expect(labelFor('disabled-server')).toContain('disabled'); + expect(labelFor('approval')).toContain('Needs approval'); + expect(labelFor('authenticating')).toContain('Authenticating'); + expect(labelFor('auth-failed')).toContain('Authentication failed'); + expect(labelFor('needs-auth')).toContain('Needs authentication'); + expect(labelFor('connected-server')).toContain('connected'); + + const messageContainer = document.createElement('div'); + document.body.appendChild(messageContainer); + const root = createRoot(messageContainer); + mounted.push({ root, container: messageContainer }); + await act(async () => { + root.render( + + + , + ); + }); + expect(messageContainer.textContent).toContain('Needs approval'); + expect(messageContainer.textContent).toContain('Authenticating'); + expect(messageContainer.textContent).toContain('Authentication failed'); + expect(messageContainer.textContent).toContain('Needs authentication'); + }); + + it('shows server operations without duplicating tools and resources tabs', async () => { + mockMcp.reload.mockResolvedValue({ + initialized: true, + discoveryState: 'completed', + workspaceCwd: '/workspace', + source: 'live', + runtimeEpoch: 1, servers: [ { name: 'filesystem', @@ -21218,6 +21398,223 @@ describe('App session callbacks', () => { expect(mockMcp.restartServer).toHaveBeenCalledWith('filesystem'); }); + it('observes MCP config reconciliation and reports a timeout', async () => { + const server = { + name: 'filesystem', + source: 'project' as const, + configOrigin: 'workspace_settings' as const, + disabled: false, + mcpStatus: 'connected' as const, + resourceCount: 0, + }; + mockMcp.reload.mockResolvedValue({ + initialized: true, + discoveryState: 'completed', + workspaceCwd: '/workspace', + source: 'live', + runtimeEpoch: 1, + servers: [server], + }); + mockMcp.loadTools.mockResolvedValue({ + serverName: 'filesystem', + runtimeEpoch: 1, + tools: [], + }); + mockMcp.setConfigServerEnabled.mockResolvedValue({ + changed: true, + activation: 'reconciling', + }); + const { container } = renderApp(); + await flush(); + + testState.prompt = '/mcp'; + await clickSubmit(container); + await flush(); + await act(async () => { + container + .querySelector('[aria-label="filesystem"]') + ?.click(); + await Promise.resolve(); + }); + await flush(); + await act(async () => { + container + .querySelector('[data-testid="mcp-server-actions"]') + ?.dispatchEvent( + new MouseEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + }), + ); + await Promise.resolve(); + }); + + mockMcp.reload.mockClear(); + mockMcp.reload.mockResolvedValue({ + initialized: true, + discoveryState: 'completed', + workspaceCwd: '/workspace', + source: 'live', + runtimeEpoch: 1, + servers: [{ ...server, disabled: true }], + }); + await act(async () => { + document + .querySelector('[data-testid="mcp-server-action-disable"]') + ?.click(); + await Promise.resolve(); + }); + await vi.waitFor(() => { + expect(mockMcp.setConfigServerEnabled).toHaveBeenCalledWith( + 'filesystem', + 'workspace', + false, + ); + }); + + expect(mockMcp.reloadConfig).not.toHaveBeenCalled(); + expect(mockMcp.reload).toHaveBeenCalled(); + vi.useFakeTimers(); + await act(async () => { + container + .querySelector('[data-testid="mcp-server-actions"]') + ?.dispatchEvent( + new MouseEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + }), + ); + await Promise.resolve(); + }); + + mockMcp.runtimeStatus.mockResolvedValue({ + runtimeLive: true, + runtimeEpoch: 1, + capabilities: { + mcp: { state: 'starting', revision: 1, runtimeEpoch: 1 }, + }, + }); + await act(async () => { + document + .querySelector('[data-testid="mcp-server-action-enable"]') + ?.click(); + await Promise.resolve(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(81 * 1_500); + }); + await flush(); + + expect(container.textContent).toContain( + 'Configuration was saved, but the current session was not updated.', + ); + expect(container.textContent).not.toContain('Enable complete.'); + }); + + it('waits for the current MCP runtime before refreshing a status message', async () => { + vi.useFakeTimers(); + mockMcp.manageServer.mockResolvedValue({ + serverName: 'filesystem', + action: 'enable', + ok: true, + }); + mockMcp.runtimeStatus + .mockResolvedValueOnce({ + runtimeLive: true, + runtimeEpoch: 2, + capabilities: { + mcp: { state: 'starting', revision: 1, runtimeEpoch: 2 }, + }, + }) + .mockResolvedValue({ + runtimeLive: true, + runtimeEpoch: 2, + capabilities: { + mcp: { state: 'ready', revision: 1, runtimeEpoch: 2 }, + }, + }); + mockMcp.reload.mockResolvedValue({ + initialized: true, + discoveryState: 'completed', + workspaceCwd: '/workspace', + source: 'live', + runtimeEpoch: 2, + servers: [ + { + name: 'filesystem', + disabled: false, + mcpStatus: 'connected', + }, + ], + }); + mockMcp.loadTools.mockResolvedValue({ + serverName: 'filesystem', + runtimeEpoch: 2, + tools: [], + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + await act(async () => { + root.render( + + + , + ); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + await act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + await act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + await Promise.resolve(); + }); + + expect(mockMcp.manageServer).toHaveBeenCalledWith('filesystem', 'enable'); + expect(mockMcp.ensureRuntime).toHaveBeenCalledOnce(); + expect(mockMcp.runtimeStatus).toHaveBeenCalledOnce(); + expect(mockMcp.reload).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_500); + }); + await flush(); + + expect(mockMcp.runtimeStatus).toHaveBeenCalledTimes(2); + expect(mockMcp.reload).toHaveBeenCalledOnce(); + expect(container.textContent).toContain('Enable complete.'); + }); + it('polls workspace MCP status until browser authentication completes', async () => { vi.useFakeTimers(); const disconnectedServer = { @@ -21233,6 +21630,8 @@ describe('App session callbacks', () => { initialized: true, discoveryState: 'completed', workspaceCwd: '/workspace', + runtimeEpoch: 1, + source: 'live', servers: [disconnectedServer], }); mockMcp.loadTools.mockResolvedValue({ serverName: 'yuque', tools: [] }); @@ -21249,6 +21648,8 @@ describe('App session callbacks', () => { initialized: true, discoveryState: 'completed', workspaceCwd: '/workspace', + runtimeEpoch: 1, + source: 'live', servers: [ { ...disconnectedServer, authenticationState: 'pending' as const }, ], @@ -21257,6 +21658,8 @@ describe('App session callbacks', () => { initialized: true, discoveryState: 'completed', workspaceCwd: '/workspace', + runtimeEpoch: 1, + source: 'live', servers: [ { ...disconnectedServer, @@ -21332,6 +21735,15 @@ describe('App session callbacks', () => { await clickSubmit(container); await flush(); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + expect(container.textContent).toContain('Loading MCP status...'); + expect( + container.querySelector('input[placeholder="Search MCP…"]'), + ).not.toBeNull(); + expect(container.textContent).not.toContain('Initializing MCP servers'); + expect(container.textContent).not.toContain( + 'The first startup can take longer', + ); expect(container.textContent).not.toContain('Loading MCP tools...'); }); @@ -21339,6 +21751,8 @@ describe('App session callbacks', () => { mockWorkspaceActions.loadMcpStatus.mockResolvedValue({ initialized: true, discoveryState: 'completed', + source: 'live', + runtimeEpoch: 1, servers: [], }); const { container } = renderApp(); @@ -21348,6 +21762,8 @@ describe('App session callbacks', () => { await clickSubmit(container); await flush(); + expect(mockMcp.ensureRuntime).not.toHaveBeenCalled(); + expect(mockMcp.runtimeStatus).toHaveBeenCalledOnce(); expect(mockMcp.initialize).not.toHaveBeenCalled(); expect(mockMcp.reloadConfig).not.toHaveBeenCalled(); expect(container.textContent).not.toContain('MCP tools are ready.'); @@ -21364,6 +21780,8 @@ describe('App session callbacks', () => { mockMcp.reload.mockResolvedValue({ initialized: true, discoveryState: 'completed', + runtimeEpoch: 1, + source: 'live', servers: [], }); const { container } = renderApp(); @@ -21372,6 +21790,7 @@ describe('App session callbacks', () => { testState.prompt = '/mcp'; await clickSubmit(container); await flush(); + expect(mockMcp.reload).toHaveBeenCalledTimes(1); expect(container.textContent).not.toContain('Loading MCP tools...'); await act(async () => { @@ -21379,10 +21798,53 @@ describe('App session callbacks', () => { }); await flush(); + expect(mockMcp.reload).toHaveBeenCalledTimes(1); expect(container.textContent).not.toContain('Loading MCP tools...'); expect(container.textContent).not.toContain('MCP tools are ready.'); }); + it('waits for a live MCP catalog from the current runtime epoch', async () => { + vi.useFakeTimers(); + mockWorkspaceActions.loadMcpStatus.mockResolvedValue({ + initialized: true, + discoveryState: 'starting', + servers: [], + }); + mockMcp.reload + .mockResolvedValueOnce({ + initialized: true, + discoveryState: 'completed', + runtimeEpoch: 0, + source: 'cache', + servers: [{ name: 'stale' }], + }) + .mockResolvedValueOnce({ + initialized: true, + discoveryState: 'completed', + runtimeEpoch: 1, + source: 'live', + servers: [], + }); + const { container } = renderApp(); + await flush(); + + testState.prompt = '/mcp'; + await clickSubmit(container); + await flush(); + + expect(mockMcp.reload).toHaveBeenCalledTimes(1); + expect(container.textContent).not.toContain('stale'); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_500); + }); + await flush(); + + expect(mockMcp.reload).toHaveBeenCalledTimes(2); + expect(container.querySelector('[role="status"]')).toBeNull(); + }); + it('auto-closes an open panel when an AskUserQuestion approval becomes pending', async () => { // The auto-close effect gates on pendingToolApproval || pendingAskUserApproval; // this covers the second branch (ask_user_question resolves to diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index b78f2831ffe..40f17eb4f22 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -5865,17 +5865,6 @@ export function App({ }, [showChat], ); - const loadMcpManagerMessage = useCallback(async () => { - const status = await workspaceActions.loadMcpStatus(); - setMcpDialogMessage({ - status, - toolsByServer: {}, - resourcesByServer: {}, - showDescriptions: false, - showSchema: false, - showTips: false, - }); - }, [workspaceActions]); const openScheduledTasks = useCallback(() => { splitClassificationGenerationRef.current += 1; setActivePanel(null); @@ -14701,15 +14690,6 @@ export function App({ /> ) : activePanel === 'plugins' ? ( { - try { - await loadMcpManagerMessage(); - } catch (error) { - reportError(error, 'Failed to load MCP status'); - throw error; - } - }} onClose={closePanel} onUseSkill={handleUseSkill} initialFocusRef={pluginTabRef} diff --git a/packages/web-shell/client/components/mcp/McpManagerPage.tsx b/packages/web-shell/client/components/mcp/McpManagerPage.tsx index 72d7ae90905..5f3e5513975 100644 --- a/packages/web-shell/client/components/mcp/McpManagerPage.tsx +++ b/packages/web-shell/client/components/mcp/McpManagerPage.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; import { AlertCircleIcon, ArrowLeftIcon, @@ -19,7 +26,7 @@ import type { DaemonWorkspaceMcpToolStatus, DaemonWorkspaceMcpToolsStatus, } from '@qwen-code/web-shell/daemon-react-sdk'; -import { useMcp, useSettings } from '@qwen-code/web-shell/daemon-react-sdk'; +import { useMcp } from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { useExternalLinkOpener } from '../../hooks/useExternalLinkOpener'; import { extractErrorDetail } from '../../utils/errorDetail'; @@ -109,9 +116,11 @@ type McpServerAction = { }; interface McpManagerPageProps { - message: SerializedMcpStatusMessage; + message?: SerializedMcpStatusMessage; onClose: () => void; embedded?: EmbeddedManagerPage; + workspaceCwd?: string; + workspaceControl?: ReactNode; } function configOriginValue( @@ -136,6 +145,12 @@ function sourceValue(server: DaemonWorkspaceMcpServerStatus): SourceFilter { return 'user'; } +function configScopeForServer( + server: DaemonWorkspaceMcpServerStatus, +): McpSettingsScope { + return configOriginValue(server) === 'user_settings' ? 'user' : 'workspace'; +} + function isManagedServerVisible( server: DaemonWorkspaceMcpServerStatus, ): boolean { @@ -156,17 +171,6 @@ function sourceLabel(server: DaemonWorkspaceMcpServerStatus, t: T): string { : t('mcp.source.user'); } -function mcpServersForScope( - settings: Awaited>, - scope: McpSettingsScope, -): Record { - const value = settings.settings.find( - (setting) => setting.key === 'mcpServers', - )?.values[scope]; - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return Object.fromEntries(Object.entries(value)); -} - function statusLabel(server: DaemonWorkspaceMcpServerStatus, t: T): string { if (server.disabled) return t('mcp.status.disabled'); if (server.approvalState === 'pending') { @@ -178,6 +182,12 @@ function statusLabel(server: DaemonWorkspaceMcpServerStatus, t: T): string { if (server.authenticationState === 'pending') { return t('mcp.status.authenticating'); } + if (server.authenticationState === 'failed') { + return t('mcp.status.authenticationFailed'); + } + if (server.requiresAuth && server.authenticationState !== 'succeeded') { + return t('mcp.status.needsAuthentication'); + } if (server.mcpStatus === 'connected') return t('mcp.status.connected'); if (server.mcpStatus === 'connecting') return t('mcp.status.connecting'); return t('mcp.status.disconnectedTitle'); @@ -186,6 +196,9 @@ function statusLabel(server: DaemonWorkspaceMcpServerStatus, t: T): string { function statusBadgeClass(server: DaemonWorkspaceMcpServerStatus): string { return !server.disabled && !server.approvalState && + server.authenticationState !== 'pending' && + server.authenticationState !== 'failed' && + (!server.requiresAuth || server.authenticationState === 'succeeded') && server.mcpStatus === 'connected' ? styles.connectedBadge : ''; @@ -377,18 +390,33 @@ export function McpManagerPage({ message, onClose, embedded, + workspaceCwd, + workspaceControl, }: McpManagerPageProps) { const { t } = useI18n(); const openExternalLink = useExternalLinkOpener(); - const mcp = useMcp({ autoLoad: false }); - const settings = useSettings({ autoLoad: false }); - const [status, setStatus] = useState(message.status); + const mcp = useMcp({ autoLoad: false, workspaceCwd }); + const initialMessage = + message && (!workspaceCwd || message.status.workspaceCwd === workspaceCwd) + ? message + : undefined; + const [status, setStatus] = useState(() => + initialMessage + ? initialMessage.status + : { + v: 1, + workspaceCwd: workspaceCwd ?? '', + initialized: false, + discoveryState: 'not_started', + servers: [], + }, + ); const [toolsByServer, setToolsByServer] = useState< Record - >(message.toolsByServer); + >(initialMessage?.toolsByServer ?? {}); const [resourcesByServer, setResourcesByServer] = useState< Record - >(message.resourcesByServer ?? {}); + >(initialMessage?.resourcesByServer ?? {}); const [selectedServerName, setSelectedServerName] = useState( null, ); @@ -431,9 +459,7 @@ export function McpManagerPage({ const [loadErrorsByServer, setLoadErrorsByServer] = useState< Record >({}); - const hasInitializedDiscovery = useRef( - message.status.discoveryState === 'completed', - ); + const hasInitializedDiscovery = useRef(false); const mountedRef = useRef(true); useEffect(() => { @@ -448,22 +474,40 @@ export function McpManagerPage({ return mountedRef.current; }, []); - const startDiscovery = useCallback( - async ( - operation: 'initialize' | 'reload', - showProgress = true, - deferStatus = false, - ): Promise => { - await (operation === 'initialize' - ? mcp.initialize() - : mcp.reloadConfig()); - if (!mountedRef.current) return false; + const loadReadyRuntime = useCallback(async () => { + const runtimeStatus = await mcp.runtimeStatus(); + const capability = runtimeStatus.capabilities?.mcp; + if (capability?.state === 'error') { + throw new Error(capability.error?.message ?? t('mcp.discovery.timeout')); + } + return runtimeStatus.runtimeLive && + capability?.state === 'ready' && + capability.runtimeEpoch === runtimeStatus.runtimeEpoch + ? runtimeStatus + : undefined; + }, [mcp, t]); - for (let attempt = 0; attempt < 40; attempt += 1) { - if (!(await waitForPoll())) return false; - const nextStatus = await mcp.reload(); + const loadCurrentStatus = useCallback(async (): Promise< + McpStatus | undefined + > => { + const runtimeStatus = await loadReadyRuntime(); + if (!runtimeStatus) return undefined; + const nextStatus = await mcp.reload(); + return nextStatus?.source === 'live' && + nextStatus.runtimeEpoch === runtimeStatus.runtimeEpoch + ? nextStatus + : undefined; + }, [loadReadyRuntime, mcp]); + + const observeDiscovery = useCallback( + async (showProgress = true, deferStatus = false): Promise => { + for (let attempt = 0; attempt < 81; attempt += 1) { + const nextStatus = await loadCurrentStatus(); if (!mountedRef.current) return false; - if (!nextStatus) continue; + if (!nextStatus) { + if (!(await waitForPoll())) return false; + continue; + } if (!deferStatus || nextStatus.discoveryState === 'completed') { setStatus((current) => showProgress && @@ -497,20 +541,36 @@ export function McpManagerPage({ if (showProgress) setNotice({ text: t('mcp.discovery.empty') }); return true; } + if (!(await waitForPoll())) return false; } if (showProgress) { setNotice({ text: t('mcp.discovery.timeout'), error: true }); } return false; }, - [mcp, t, waitForPoll], + [loadCurrentStatus, t, waitForPoll], ); useEffect(() => { if (hasInitializedDiscovery.current) return; hasInitializedDiscovery.current = true; setInitializing(true); - void startDiscovery('initialize') + void (async () => { + if ( + initialMessage?.status.source === 'live' && + initialMessage.status.runtimeEpoch !== undefined && + initialMessage.status.discoveryState === 'completed' + ) { + const runtimeStatus = await loadReadyRuntime(); + if ( + runtimeStatus?.runtimeEpoch === initialMessage.status.runtimeEpoch + ) { + return; + } + } + await mcp.ensureRuntime(); + if (mountedRef.current) await observeDiscovery(); + })() .catch((error: unknown) => { if (mountedRef.current) { setNotice({ text: extractErrorDetail(error), error: true }); @@ -519,7 +579,7 @@ export function McpManagerPage({ .finally(() => { if (mountedRef.current) setInitializing(false); }); - }, [startDiscovery]); + }, [initialMessage, loadReadyRuntime, mcp, observeDiscovery]); const servers = useMemo( () => (status.servers ?? []).filter(isManagedServerVisible), @@ -560,6 +620,8 @@ export function McpManagerPage({ const loadServerData = useCallback( async (server: DaemonWorkspaceMcpServerStatus) => { + const runtimeStatus = await loadReadyRuntime(); + if (!runtimeStatus) throw new Error(t('mcp.discovery.timeout')); const failures: unknown[] = []; const [toolsResult, resourcesResult] = await Promise.allSettled([ mcp.loadTools(server.name), @@ -568,6 +630,9 @@ export function McpManagerPage({ : Promise.resolve(null), ]); if (toolsResult.status === 'fulfilled') { + if (toolsResult.value.runtimeEpoch !== runtimeStatus.runtimeEpoch) { + throw new Error(t('mcp.discovery.timeout')); + } setToolsByServer((current) => ({ ...current, [server.name]: toolsResult.value, @@ -589,6 +654,9 @@ export function McpManagerPage({ resourcesResult.value !== null ) { const resources = resourcesResult.value; + if (resources.runtimeEpoch !== runtimeStatus.runtimeEpoch) { + throw new Error(t('mcp.discovery.timeout')); + } setResourcesByServer((current) => ({ ...current, [server.name]: resources, @@ -623,7 +691,7 @@ export function McpManagerPage({ ); } }, - [mcp], + [loadReadyRuntime, mcp, t], ); const refreshAll = useCallback(async () => { @@ -631,15 +699,16 @@ export function McpManagerPage({ setRefreshing(true); setNotice(null); try { - const nextStatus = await mcp.reload(); - if (!nextStatus) return; - setStatus(nextStatus); + await mcp.ensureRuntime(); + if (!(await observeDiscovery(false))) { + throw new Error(t('mcp.discovery.timeout')); + } } catch (error) { setNotice({ text: extractErrorDetail(error), error: true }); } finally { setRefreshing(false); } - }, [mcp, refreshing]); + }, [mcp, observeDiscovery, refreshing, t]); const addServer = useCallback(async () => { const name = serverName.trim(); @@ -648,7 +717,7 @@ export function McpManagerPage({ setAddError(t('mcp.add.nameRequired')); return; } - let config: Parameters[0]['config']; + let config: Record; try { const parsed: unknown = JSON.parse(serverConfig); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { @@ -656,7 +725,7 @@ export function McpManagerPage({ } const description = serverDescription.trim(); config = { - ...(parsed as Parameters[0]['config']), + ...(parsed as Record), ...(description ? { description } : {}), }; } catch (error) { @@ -665,16 +734,12 @@ export function McpManagerPage({ } setAddError(null); setAdding(true); - let persisted = false; try { - await settings.setValue(serverScope, 'mcpServers', config, { - mcpServerMutation: { operation: 'set', name }, - }); - persisted = true; + const result = await mcp.setConfigServer(name, serverScope, config); setNotice(null); - const runtimeUpdated = await startDiscovery('reload', false).catch( - () => false, - ); + const runtimeUpdated = + result.activation !== 'deferred' && + (await observeDiscovery(false).catch(() => false)); setNotice({ ...(editing ? { serverName: name } : {}), text: runtimeUpdated @@ -689,20 +754,7 @@ export function McpManagerPage({ setServerDescription(''); setServerConfig(DEFAULT_MCP_SERVER_CONFIG); } catch (error) { - if (persisted) { - setNotice({ - ...(editing ? { serverName: name } : {}), - text: t('mcp.runtime.notUpdated'), - error: true, - }); - setAddDialogOpen(false); - setEditingServer(null); - setServerName(''); - setServerDescription(''); - setServerConfig(DEFAULT_MCP_SERVER_CONFIG); - } else { - setAddError(extractErrorDetail(error)); - } + setAddError(extractErrorDetail(error)); } finally { setAdding(false); } @@ -713,57 +765,25 @@ export function McpManagerPage({ serverDescription, serverName, serverScope, - settings, - startDiscovery, + observeDiscovery, t, ]); const removeServer = useCallback(async () => { if (!serverToRemove) return; setBusyServer(serverToRemove.name); - let persisted = false; try { - const currentSettings = await settings.reload(); - if (!currentSettings) { - setNotice({ - serverName: serverToRemove.name, - text: t('mcp.add.settingsUnavailable'), - error: true, - }); - return; - } - const scope: McpSettingsScope = - sourceValue(serverToRemove) === 'workspace' ? 'workspace' : 'user'; - const servers = mcpServersForScope(currentSettings, scope); - if (!(serverToRemove.name in servers)) { - setNotice({ - serverName: serverToRemove.name, - text: t('mcp.remove.notWorkspace'), - error: true, - }); - return; - } - await settings.setValue( - scope, - 'mcpServers', - {}, - { - mcpServerMutation: { - operation: 'remove', - name: serverToRemove.name, - }, - }, - ); - persisted = true; + const scope: McpSettingsScope = configScopeForServer(serverToRemove); + const result = await mcp.removeConfigServer(serverToRemove.name, scope); setServerToRemove(null); setNotice({ serverName: serverToRemove.name, text: t('mcp.action.running', { action: t('mcp.action.remove') }), progress: true, }); - const runtimeUpdated = await startDiscovery('reload', false, true).catch( - () => false, - ); + const runtimeUpdated = + result.activation !== 'deferred' && + (await observeDiscovery(false, true).catch(() => false)); if (runtimeUpdated) { setSelectedServerName(null); setSelectedToolName(null); @@ -779,15 +799,13 @@ export function McpManagerPage({ } catch (error) { setNotice({ serverName: serverToRemove.name, - text: persisted - ? t('mcp.runtime.removeNotUpdated') - : t('mcp.action.failed', { error: extractErrorDetail(error) }), + text: t('mcp.action.failed', { error: extractErrorDetail(error) }), error: true, }); } finally { setBusyServer(null); } - }, [serverToRemove, settings, startDiscovery, t]); + }, [mcp, observeDiscovery, serverToRemove, t]); const openEditServer = useCallback( async (server: DaemonWorkspaceMcpServerStatus) => { @@ -801,13 +819,8 @@ export function McpManagerPage({ setBusyServer(server.name); setNotice(null); try { - const currentSettings = await settings.reload(); - if (!currentSettings) { - throw new Error(t('mcp.add.settingsUnavailable')); - } - const storedConfig = mcpServersForScope(currentSettings, scope)[ - server.name - ]; + const configStatus = await mcp.loadConfig(); + const storedConfig = configStatus[scope][server.name]; if ( !storedConfig || typeof storedConfig !== 'object' || @@ -839,7 +852,7 @@ export function McpManagerPage({ setBusyServer(null); } }, - [busyServer, settings, t], + [busyServer, mcp, t], ); const runAction = useCallback( @@ -892,6 +905,41 @@ export function McpManagerPage({ }), ); } + } else if (action.id === 'enable' || action.id === 'disable') { + const scopes: McpSettingsScope[] = + action.id === 'enable' + ? ['user', 'workspace'] + : [configScopeForServer(server)]; + let activation: 'applied' | 'deferred' | 'reconciling' = 'deferred'; + for (const scope of scopes) { + const result = await mcp.setConfigServerEnabled( + server.name, + scope, + action.id === 'enable', + ); + if (result.activation !== 'deferred') { + activation = result.activation; + } + } + if (!mountedRef.current) return; + if (activation === 'deferred') { + setNotice({ + serverName: server.name, + text: t('mcp.runtime.notUpdated'), + error: true, + }); + return; + } + const runtimeUpdated = await observeDiscovery(false); + if (!mountedRef.current) return; + if (!runtimeUpdated) { + setNotice({ + serverName: server.name, + text: t('mcp.runtime.notUpdated'), + error: true, + }); + return; + } } else { const result = await mcp.manageServer(server.name, action.id); if (!mountedRef.current) return; @@ -912,7 +960,7 @@ export function McpManagerPage({ if (pendingAuthentication) { for (let attempt = 0; attempt < 400; attempt += 1) { if (!(await waitForPoll())) return; - const candidate = await mcp.reload(); + const candidate = await loadCurrentStatus(); if (!mountedRef.current) return; if (!candidate) continue; setStatus(candidate); @@ -947,7 +995,7 @@ export function McpManagerPage({ } if (!nextStatus) throw new Error(t('mcp.oauth.timeout')); } else { - nextStatus = await mcp.reload(); + nextStatus = await loadCurrentStatus(); if (!mountedRef.current) return; for ( let attempt = 0; @@ -958,7 +1006,7 @@ export function McpManagerPage({ attempt += 1 ) { if (!(await waitForPoll())) return; - const candidate = await mcp.reload(); + const candidate = await loadCurrentStatus(); if (!mountedRef.current) return; if (!candidate) continue; nextStatus = candidate; @@ -1007,7 +1055,16 @@ export function McpManagerPage({ if (mountedRef.current) setBusyServer(null); } }, - [busyServer, loadServerData, mcp, openEditServer, t, waitForPoll], + [ + busyServer, + loadCurrentStatus, + loadServerData, + mcp, + openEditServer, + observeDiscovery, + t, + waitForPoll, + ], ); const openServer = (server: DaemonWorkspaceMcpServerStatus) => { @@ -1031,16 +1088,7 @@ export function McpManagerPage({ setSelectedServerName(null); setSelectedToolName(null); setSelectedResourceUri(null); - setRefreshing(true); - void mcp - .reload() - .then((nextStatus) => { - if (nextStatus) setStatus(nextStatus); - }) - .catch((error: unknown) => { - setNotice({ text: extractErrorDetail(error), error: true }); - }) - .finally(() => setRefreshing(false)); + void refreshAll(); }; const showSelectedServer = () => { @@ -1116,39 +1164,42 @@ export function McpManagerPage({ : selectedServer?.name; const navigation = embedded ? ( selectedServer ? ( - - - - - - - - - - {selectedTool || selectedResource ? ( +
+ + + - - ) : ( - {selectedServer.name} - )} - - {selectedTool || selectedResource ? : null} - {selectedTool || selectedResource ? ( - - {detailLabel} - ) : null} - - + + + {selectedTool || selectedResource ? ( + + + + ) : ( + {selectedServer.name} + )} + + {selectedTool || selectedResource ? : null} + {selectedTool || selectedResource ? ( + + {detailLabel} + + ) : null} + + + {workspaceControl} +
) : null ) : ( standaloneNavigation @@ -1663,21 +1714,7 @@ export function McpManagerPage({ - {initializing && connectingCount === 0 ? ( - undefined} - > - - - {t('mcp.discovery.initializing')} - - {t('mcp.startingNote')} - - - ) : connectingCount > 0 ? ( + {connectingCount > 0 ? ( - {filteredServers.length ? ( + {initializing && connectingCount === 0 ? ( +
+ + {t('mcp.loadingStatus')} +
+ ) : filteredServers.length ? (
{ - const nextStatus = await mcp.reload(); - if (nextStatus) { - setLocalStatus(nextStatus); - const nextServer = - nextStatus.servers?.find( - (server) => server.name === selectedServer?.name, - ) ?? null; - if (nextServer) { - if (nextServer.approvalState) return; - const nextTools = await mcp.loadTools(nextServer.name); - setLocalToolsByServer((current) => ({ - ...current, - [nextServer.name]: nextTools, - })); + let nextStatus: DaemonWorkspaceMcpStatus | undefined; + let runtimeEpoch: number | undefined; + await mcp.ensureRuntime(); + for (let attempt = 0; attempt < 81; attempt += 1) { + const runtimeStatus = await mcp.runtimeStatus(); + const capability = runtimeStatus.capabilities?.mcp; + if (capability?.state === 'error') { + throw new Error( + capability.error?.message ?? t('mcp.discovery.timeout'), + ); } + if ( + runtimeStatus.runtimeLive && + capability?.state === 'ready' && + capability.runtimeEpoch === runtimeStatus.runtimeEpoch + ) { + const candidate = await mcp.reload(); + if ( + candidate?.source === 'live' && + candidate.runtimeEpoch === runtimeStatus.runtimeEpoch + ) { + nextStatus = candidate; + runtimeEpoch = runtimeStatus.runtimeEpoch; + break; + } + } + await new Promise((resolve) => window.setTimeout(resolve, 1_500)); } - }, [mcp, selectedServer?.name]); + if (!nextStatus || runtimeEpoch === undefined) { + throw new Error(t('mcp.discovery.timeout')); + } + setLocalStatus(nextStatus); + const nextServer = + nextStatus.servers?.find( + (server) => server.name === selectedServer?.name, + ) ?? null; + if (nextServer) { + if (nextServer.approvalState) return; + const nextTools = await mcp.loadTools(nextServer.name); + if (nextTools.runtimeEpoch !== runtimeEpoch) { + throw new Error(t('mcp.discovery.timeout')); + } + setLocalToolsByServer((current) => ({ + ...current, + [nextServer.name]: nextTools, + })); + } + }, [mcp, selectedServer?.name, t]); const runServerAction = useCallback( async (action: McpServerAction) => { @@ -480,7 +532,11 @@ export function McpStatusMessage({ : details; } } - await reloadSelectedServer(); + try { + await reloadSelectedServer(); + } catch (error) { + if (!nextActionMessage) throw error; + } setActionMessage( nextActionMessage ?? t('mcp.action.done', { action: action.label }), ); diff --git a/packages/web-shell/client/components/plugins/PluginManagerPage.test.tsx b/packages/web-shell/client/components/plugins/PluginManagerPage.test.tsx new file mode 100644 index 00000000000..8a387209c3a --- /dev/null +++ b/packages/web-shell/client/components/plugins/PluginManagerPage.test.tsx @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// @vitest-environment jsdom + +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const workspaceState = vi.hoisted(() => ({ + workspaceCwd: '/work/primary', + capabilities: { + workspaces: [ + { + id: 'primary', + cwd: '/work/primary', + displayName: 'Primary', + primary: true, + trusted: true, + }, + { + id: 'secondary', + cwd: '/work/secondary', + displayName: 'Secondary', + primary: false, + trusted: true, + }, + ], + }, +})); + +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ + useWorkspace: () => workspaceState, +})); + +vi.mock('../extensions/ExtensionsManagerPage', () => ({ + ExtensionsManagerPage: () =>
Extensions
, +})); +vi.mock('../skills/SkillsManagerPage', () => ({ + SkillsManagerPage: () =>
Skills
, +})); +vi.mock('../agents/AgentsManagerPage', () => ({ + AgentsManagerPage: () =>
Agents
, +})); +vi.mock('../mcp/McpManagerPage', () => ({ + McpManagerPage: (props: { + workspaceCwd?: string; + workspaceControl?: ReactNode; + embedded?: { onDetailChange(open: boolean): void }; + }) => ( +
+ {props.workspaceControl} + +
+ ), +})); + +const { PluginManagerPage } = await import('./PluginManagerPage'); +const { I18nProvider } = await import('../../i18n'); + +let root: Root | undefined; +let container: HTMLDivElement | undefined; + +afterEach(async () => { + await act(async () => root?.unmount()); + container?.remove(); + workspaceState.capabilities.workspaces[0]!.trusted = true; +}); + +describe('PluginManagerPage MCP workspace selection', () => { + it('shows the multi-workspace selector and locks it in MCP detail', async () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + + + , + ); + }); + + const mcpTab = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'MCP', + ); + expect(mcpTab).toBeDefined(); + await act(async () => { + mcpTab?.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, button: 0 }), + ); + mcpTab?.click(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await vi.waitFor(() => { + expect( + container?.querySelector('button[aria-label="Workspace"]'), + ).not.toBeNull(); + }); + const rootSelector = container.querySelector( + 'button[aria-label="Workspace"]', + )!; + expect(rootSelector.disabled).toBe(false); + expect( + container + .querySelector('[data-testid="mcp-page"]') + ?.getAttribute('data-workspace'), + ).toBe('/work/primary'); + + await act(async () => rootSelector.click()); + const secondary = Array.from( + document.querySelectorAll('[role="option"]'), + ).find((option) => option.textContent?.trim() === 'Secondary'); + expect(secondary).toBeDefined(); + await act(async () => secondary?.click()); + expect( + container + .querySelector('[data-testid="mcp-page"]') + ?.getAttribute('data-workspace'), + ).toBe('/work/secondary'); + + const openDetail = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Open detail', + ); + await act(async () => { + openDetail?.click(); + }); + + const detailSelector = container.querySelector( + 'button[aria-label="Workspace"]', + ); + expect(detailSelector?.disabled).toBe(true); + }); + + it('selects the first trusted workspace', async () => { + workspaceState.capabilities.workspaces[0]!.trusted = false; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + + + , + ); + }); + + const mcpTab = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'MCP', + ); + await act(async () => { + mcpTab?.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, button: 0 }), + ); + mcpTab?.click(); + }); + + expect( + container + .querySelector('[data-testid="mcp-page"]') + ?.getAttribute('data-workspace'), + ).toBe('/work/secondary'); + }); +}); diff --git a/packages/web-shell/client/components/plugins/PluginManagerPage.tsx b/packages/web-shell/client/components/plugins/PluginManagerPage.tsx index 83ac931d50f..a66ed32daa2 100644 --- a/packages/web-shell/client/components/plugins/PluginManagerPage.tsx +++ b/packages/web-shell/client/components/plugins/PluginManagerPage.tsx @@ -1,57 +1,76 @@ -import { useCallback, useMemo, useState, type Ref } from 'react'; -import { SearchIcon, ServerIcon } from 'lucide-react'; -import type { SerializedMcpStatusMessage } from '../messages/McpStatusMessage'; +import { useCallback, useEffect, useMemo, useState, type Ref } from 'react'; +import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk'; import { AgentsManagerPage } from '../agents/AgentsManagerPage'; import { ExtensionsManagerPage } from '../extensions/ExtensionsManagerPage'; import { McpManagerPage } from '../mcp/McpManagerPage'; import { SkillsManagerPage } from '../skills/SkillsManagerPage'; -import { Alert, AlertDescription, AlertTitle } from '../ui/alert'; -import { Button } from '../ui/button'; import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from '../ui/empty'; -import { Input } from '../ui/input'; + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { useI18n } from '../../i18n'; +import { workspaceLabel } from '../../utils/workspace'; import type { EmbeddedManagerPage } from './manager-page'; type PluginTab = 'extensions' | 'mcp' | 'skills' | 'agents'; interface PluginManagerPageProps { - mcpMessage: SerializedMcpStatusMessage | null; - loadMcpMessage: () => Promise; onClose: () => void; onUseSkill: (name: string) => void; initialFocusRef?: Ref; } export function PluginManagerPage({ - mcpMessage, - loadMcpMessage, onClose, onUseSkill, initialFocusRef, }: PluginManagerPageProps) { const { t } = useI18n(); + const workspace = useWorkspace(); + const workspaces = useMemo(() => { + const listed = (workspace.capabilities?.workspaces ?? []).filter( + (entry) => entry.kind !== 'live', + ); + if (listed.length > 0) return listed; + return workspace.workspaceCwd + ? [ + { + id: 'primary', + cwd: workspace.workspaceCwd, + primary: true, + trusted: true, + }, + ] + : []; + }, [workspace.capabilities?.workspaces, workspace.workspaceCwd]); + const defaultWorkspaceCwd = + workspaces.find( + (entry) => entry.cwd === workspace.workspaceCwd && entry.trusted, + )?.cwd ?? + workspaces.find((entry) => entry.primary && entry.trusted)?.cwd ?? + workspaces.find((entry) => entry.trusted)?.cwd; + const [selectedWorkspaceCwd, setSelectedWorkspaceCwd] = useState( + () => defaultWorkspaceCwd, + ); const [activeTab, setActiveTab] = useState('extensions'); const [detailOpen, setDetailOpen] = useState(false); const [pageRevision, setPageRevision] = useState(0); - const [mcpLoaded, setMcpLoaded] = useState(false); - const [mcpLoadError, setMcpLoadError] = useState(null); - const loadMcp = useCallback(() => { - setMcpLoaded(false); - setMcpLoadError(null); - void loadMcpMessage() - .then(() => setMcpLoaded(true)) - .catch((error: unknown) => { - setMcpLoadError(error instanceof Error ? error.message : String(error)); - }); - }, [loadMcpMessage]); + useEffect(() => { + if ( + selectedWorkspaceCwd && + workspaces.some( + (entry) => entry.cwd === selectedWorkspaceCwd && entry.trusted, + ) + ) { + return; + } + setSelectedWorkspaceCwd(defaultWorkspaceCwd); + }, [defaultWorkspaceCwd, selectedWorkspaceCwd, workspaces]); const resetToRoot = useCallback(() => { setDetailOpen(false); @@ -67,15 +86,43 @@ export function PluginManagerPage({ setActiveTab(nextTab); setDetailOpen(false); setPageRevision((revision) => revision + 1); - if (nextTab === 'mcp') { - loadMcp(); - } }; + const workspaceSelect = (disabled: boolean) => + activeTab === 'mcp' && workspaces.length > 1 ? ( + + ) : null; + return ( {!detailOpen ? ( -
+
{t('plugins.extensions')} @@ -84,6 +131,7 @@ export function PluginManagerPage({ {t('plugins.skills')} {t('plugins.agents')} + {workspaceSelect(false)}
) : null} @@ -107,44 +155,14 @@ export function PluginManagerPage({ onClose={onClose} embedded={embedded} /> - ) : mcpLoadError ? ( - - {t('plugins.mcpLoadFailed')} - -

{mcpLoadError}

- -
-
- ) : mcpMessage && mcpLoaded ? ( + ) : ( - ) : ( -
-
- - -
- - - - - - {t('mcp.empty')} - {t('mcp.emptyDescription')} - - -
)} diff --git a/packages/web-shell/client/daemon/workspace/DaemonWorkspaceProvider.test.tsx b/packages/web-shell/client/daemon/workspace/DaemonWorkspaceProvider.test.tsx index 1b8fcc63bbf..54fed0f9496 100644 --- a/packages/web-shell/client/daemon/workspace/DaemonWorkspaceProvider.test.tsx +++ b/packages/web-shell/client/daemon/workspace/DaemonWorkspaceProvider.test.tsx @@ -48,6 +48,11 @@ const sdkMocks = vi.hoisted(() => { class MockDaemonClient { constructor(_opts: unknown) {} + workspaceByCwd = vi.fn(() => ({ + runtimeMcpTools: workspaceMcpTools, + runtimeMcpResources: workspaceMcpResources, + })); + capabilities = capabilities; workspaceMcp = workspaceMcp; workspaceMcpTools = workspaceMcpTools; @@ -533,7 +538,7 @@ describe('DaemonWorkspaceProvider', () => { expect(context).toBeUndefined(); }); - it('returns MCP tools fallback for older daemons', async () => { + it('propagates MCP tools failures', async () => { sdkMocks.workspaceMcpTools.mockRejectedValueOnce( new Error('missing route'), ); @@ -555,21 +560,9 @@ describe('DaemonWorkspaceProvider', () => { const workspaceActions = actions; await act(async () => { - await expect(workspaceActions.loadMcpTools('server-a')).resolves.toEqual({ - v: 1, - workspaceCwd: '', - serverName: 'server-a', - initialized: false, - acpChannelLive: false, - tools: [], - errors: [ - { - kind: 'mcp_tools', - status: 'error', - error: 'The connected daemon does not expose MCP tool details.', - }, - ], - }); + await expect(workspaceActions.loadMcpTools('server-a')).rejects.toThrow( + 'missing route', + ); }); }); @@ -609,7 +602,7 @@ describe('DaemonWorkspaceProvider', () => { expect(sdkMocks.workspaceMcpResources).toHaveBeenCalledWith('docs'); }); - it('returns MCP resources fallback for older daemons', async () => { + it('propagates MCP resources failures', async () => { sdkMocks.workspaceMcpResources.mockRejectedValueOnce( new Error('missing route'), ); @@ -632,21 +625,7 @@ describe('DaemonWorkspaceProvider', () => { await act(async () => { await expect( workspaceActions.loadMcpResources('server-a'), - ).resolves.toEqual({ - v: 1, - workspaceCwd: '', - serverName: 'server-a', - initialized: false, - acpChannelLive: false, - resources: [], - errors: [ - { - kind: 'mcp_resources', - status: 'error', - error: 'The connected daemon does not expose MCP resource details.', - }, - ], - }); + ).rejects.toThrow('missing route'); }); }); diff --git a/packages/web-shell/client/daemon/workspace/actions.test.ts b/packages/web-shell/client/daemon/workspace/actions.test.ts index 326032558fc..ea64ba3fc70 100644 --- a/packages/web-shell/client/daemon/workspace/actions.test.ts +++ b/packages/web-shell/client/daemon/workspace/actions.test.ts @@ -334,16 +334,132 @@ describe('workspace actions', () => { expect(activeExtensionOperations).toHaveBeenCalledOnce(); }); - it('reloads MCP settings through the daemon client', async () => { - const reloadWorkspaceMcp = vi.fn().mockResolvedValue({ accepted: true }); + it('reloads MCP through the selected workspace runtime client', async () => { + const reloadRuntimeMcp = vi.fn().mockResolvedValue({ accepted: true }); + const workspaceByCwd = vi.fn(() => ({ reloadRuntimeMcp })); const actions = createDaemonWorkspaceActions({ - getClient: () => ({ reloadWorkspaceMcp }) as unknown as DaemonClient, + getClient: () => ({ workspaceByCwd }) as unknown as DaemonClient, getWorkspaceCwd: () => '/workspace', baseUrl: 'http://daemon', }); await expect(actions.reloadMcp()).resolves.toEqual({ accepted: true }); - expect(reloadWorkspaceMcp).toHaveBeenCalledOnce(); + expect(workspaceByCwd).toHaveBeenCalledWith('/workspace'); + expect(reloadRuntimeMcp).toHaveBeenCalledOnce(); + }); + + it('loads MCP status from the selected workspace runtime', async () => { + const runtimeMcp = vi.fn().mockResolvedValue({ + v: 1, + workspaceCwd: '/secondary', + initialized: false, + servers: [], + }); + const workspaceByCwd = vi.fn(() => ({ runtimeMcp })); + const actions = createDaemonWorkspaceActions({ + getClient: () => ({ workspaceByCwd }) as unknown as DaemonClient, + getWorkspaceCwd: () => '/secondary', + baseUrl: 'http://daemon', + }); + + await expect(actions.loadMcpStatus()).resolves.toMatchObject({ + workspaceCwd: '/secondary', + }); + expect(workspaceByCwd).toHaveBeenCalledWith('/secondary'); + }); + + it('persists MCP configuration in the requested scope', async () => { + const setUserMcpServer = vi + .fn() + .mockResolvedValue({ activation: 'reconciling' }); + const setMcpServer = vi + .fn() + .mockResolvedValue({ activation: 'reconciling' }); + const workspaceByCwd = vi.fn(() => ({ setMcpServer })); + const actions = createDaemonWorkspaceActions({ + getClient: () => + ({ setUserMcpServer, workspaceByCwd }) as unknown as DaemonClient, + getWorkspaceCwd: () => '/secondary', + baseUrl: 'http://daemon', + }); + + await actions.setMcpServer('user-docs', 'user', { command: 'user' }); + await actions.setMcpServer('workspace-docs', 'workspace', { + command: 'workspace', + }); + + expect(setUserMcpServer).toHaveBeenCalledWith('user-docs', { + command: 'user', + }); + expect(workspaceByCwd).toHaveBeenCalledWith('/secondary'); + expect(setMcpServer).toHaveBeenCalledWith('workspace-docs', { + command: 'workspace', + }); + }); + + it('enables an MCP server in every disabled scope', async () => { + const setUserMcpServerEnabled = vi + .fn() + .mockResolvedValue({ changed: true, activation: 'reconciling' }); + const setMcpServerEnabled = vi + .fn() + .mockResolvedValue({ changed: true, activation: 'reconciling' }); + const workspaceByCwd = vi.fn(() => ({ + mcpConfig: vi.fn().mockResolvedValue({ + user: {}, + workspace: {}, + }), + setMcpServerEnabled, + })); + const actions = createDaemonWorkspaceActions({ + getClient: () => + ({ + setUserMcpServerEnabled, + workspaceByCwd, + }) as unknown as DaemonClient, + getWorkspaceCwd: () => '/secondary', + baseUrl: 'http://daemon', + }); + + await expect(actions.manageMcpServer('docs', 'enable')).resolves.toEqual({ + serverName: 'docs', + action: 'enable', + ok: true, + changed: true, + }); + expect(setUserMcpServerEnabled).toHaveBeenCalledWith('docs', true); + expect(setMcpServerEnabled).toHaveBeenCalledWith('docs', true); + expect(setUserMcpServerEnabled.mock.invocationCallOrder[0]!).toBeLessThan( + setMcpServerEnabled.mock.invocationCallOrder[0]!, + ); + }); + + it('disables a user MCP server in user scope', async () => { + const setUserMcpServerEnabled = vi + .fn() + .mockResolvedValue({ changed: true, activation: 'reconciling' }); + const setMcpServerEnabled = vi.fn(); + const workspaceByCwd = vi.fn(() => ({ + mcpConfig: vi.fn().mockResolvedValue({ + user: { docs: { command: 'docs' } }, + workspace: {}, + }), + setMcpServerEnabled, + })); + const actions = createDaemonWorkspaceActions({ + getClient: () => + ({ + setUserMcpServerEnabled, + workspaceByCwd, + }) as unknown as DaemonClient, + getWorkspaceCwd: () => '/secondary', + baseUrl: 'http://daemon', + }); + + await actions.manageMcpServer('docs', 'disable'); + + expect(setUserMcpServerEnabled).toHaveBeenCalledWith('docs', false); + expect(setMcpServerEnabled).not.toHaveBeenCalled(); }); it('forwards an extension interaction response to the daemon client', async () => { diff --git a/packages/web-shell/client/daemon/workspace/actions.ts b/packages/web-shell/client/daemon/workspace/actions.ts index d9b274ef369..c1f5386b3ca 100644 --- a/packages/web-shell/client/daemon/workspace/actions.ts +++ b/packages/web-shell/client/daemon/workspace/actions.ts @@ -290,90 +290,101 @@ export function createDaemonWorkspaceActions({ }, }, - async loadMcpStatus() { - const client = requireClient(getClient, 'Load MCP status failed'); + async ensureRuntime() { + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Initialize workspace runtime failed', + ); return withActionTimeout( - client.workspaceMcp(), + workspace.ensureRuntime(), + 'Initialize workspace runtime timed out', + 62_000, + ); + }, + + async loadMcpConfig() { + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Load MCP configuration failed', + ); + return withActionTimeout( + workspace.mcpConfig(), + 'Load MCP configuration timed out', + ); + }, + + async loadMcpStatus() { + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Load MCP status failed', + ); + return withActionTimeout( + workspace.runtimeMcp(), 'Load MCP status timed out', ); }, async initializeMcp() { - const client = requireClient(getClient, 'Initialize MCP failed'); + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Initialize MCP failed', + ); return withActionTimeout( - client.initializeWorkspaceMcp(), + workspace.ensureRuntime().then(() => ({ accepted: true })), 'Initialize MCP timed out', + 62_000, ); }, async reloadMcp() { - const client = requireClient(getClient, 'Reload MCP failed'); + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Reload MCP failed', + ); return withActionTimeout( - client.reloadWorkspaceMcp(), + workspace.reloadRuntimeMcp(), 'Reload MCP timed out', + 5 * 60_000, ); }, async loadMcpTools(serverName) { - const client = requireClient(getClient, 'Load MCP tools failed'); - try { - return await withActionTimeout( - client.workspaceMcpTools(serverName), - 'Load MCP tools timed out', - ); - } catch { - return { - v: 1 as const, - workspaceCwd: '', - serverName, - initialized: false, - acpChannelLive: false, - tools: [], - errors: [ - { - kind: 'mcp_tools' as const, - status: 'error' as const, - error: 'The connected daemon does not expose MCP tool details.', - }, - ], - }; - } + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Load MCP tools failed', + ); + return withActionTimeout( + workspace.runtimeMcpTools(serverName), + 'Load MCP tools timed out', + ); }, async loadMcpResources(serverName) { - const client = requireClient(getClient, 'Load MCP resources failed'); - try { - return await withActionTimeout( - client.workspaceMcpResources(serverName), - 'Load MCP resources timed out', - ); - } catch { - // Older daemons lack the resources route. Degrade gracefully so a - // mixed-version client still renders the rest of the /mcp dialog — - // mirrors the loadMcpTools fallback. - return { - v: 1 as const, - workspaceCwd: '', - serverName, - initialized: false, - acpChannelLive: false, - resources: [], - errors: [ - { - kind: 'mcp_resources' as const, - status: 'error' as const, - error: - 'The connected daemon does not expose MCP resource details.', - }, - ], - }; - } + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Load MCP resources failed', + ); + return withActionTimeout( + workspace.runtimeMcpResources(serverName), + 'Load MCP resources timed out', + ); }, async restartMcpServer(serverName) { - const client = requireClient(getClient, 'Restart MCP server failed'); + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Restart MCP server failed', + ); return withActionTimeout( - client.restartMcpServer(serverName), + workspace.restartRuntimeMcpServer(serverName), 'Restart MCP server timed out', 5 * 60_000, ); @@ -381,9 +392,42 @@ export function createDaemonWorkspaceActions({ async manageMcpServer(serverName, action) { const client = requireClient(getClient, 'Manage MCP server failed'); + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Manage MCP server failed', + ); const timeoutMs = action === 'authenticate' ? 10 * 60_000 : 5 * 60_000; + if (action === 'enable' || action === 'disable') { + const config = + action === 'disable' ? await workspace.mcpConfig() : undefined; + const scopes = + action === 'enable' + ? (['user', 'workspace'] as const) + : [ + Object.hasOwn(config!.user, serverName) && + !Object.hasOwn(config!.workspace, serverName) + ? ('user' as const) + : ('workspace' as const), + ]; + let changed: boolean | undefined; + for (const scope of scopes) { + const result = await (scope === 'workspace' + ? workspace.setMcpServerEnabled(serverName, action === 'enable') + : client.setUserMcpServerEnabled(serverName, action === 'enable')); + if (result.changed !== undefined) { + changed = (changed ?? false) || result.changed; + } + } + return { + serverName, + action, + ok: true, + ...(changed === undefined ? {} : { changed }), + }; + } return withActionTimeout( - client.manageMcpServer(serverName, action), + workspace.manageRuntimeMcpServer(serverName, action), 'Manage MCP server timed out', timeoutMs, ); @@ -407,6 +451,42 @@ export function createDaemonWorkspaceActions({ ); }, + async setMcpServer(name, scope, config) { + const client = requireClient(getClient, 'Save MCP server failed'); + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Save MCP server failed', + ); + return scope === 'user' + ? client.setUserMcpServer(name, config) + : workspace.setMcpServer(name, config); + }, + + async removeMcpServer(name, scope) { + const client = requireClient(getClient, 'Remove MCP server failed'); + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Remove MCP server failed', + ); + return scope === 'user' + ? client.removeUserMcpServer(name) + : workspace.removeMcpServer(name); + }, + + async setMcpServerEnabled(name, scope, enabled) { + const client = requireClient(getClient, 'Update MCP server failed'); + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Update MCP server failed', + ); + return scope === 'user' + ? client.setUserMcpServerEnabled(name, enabled) + : workspace.setMcpServerEnabled(name, enabled); + }, + async loadDaemonStatus(detail) { const client = requireClient(getClient, 'Load daemon status failed'); return withActionTimeout( diff --git a/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.test.tsx b/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.test.tsx new file mode 100644 index 00000000000..e31dd1e2de9 --- /dev/null +++ b/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.test.tsx @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// @vitest-environment jsdom + +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { actions, client, resource, workspaceClient } = vi.hoisted(() => { + const workspace = { + runtimeMcp: vi.fn(), + ensureRuntime: vi.fn(), + runtimeStatus: vi.fn(), + mcpConfig: vi.fn(), + reloadRuntimeMcp: vi.fn(), + runtimeMcpTools: vi.fn(), + runtimeMcpResources: vi.fn(), + restartRuntimeMcpServer: vi.fn(), + manageRuntimeMcpServer: vi.fn(), + setMcpServer: vi.fn(), + removeMcpServer: vi.fn(), + setMcpServerEnabled: vi.fn(), + }; + return { + actions: { + loadMcpStatus: vi.fn(), + ensureRuntime: vi.fn(), + loadMcpConfig: vi.fn(), + initializeMcp: vi.fn(), + reloadMcp: vi.fn(), + loadMcpTools: vi.fn(), + loadMcpResources: vi.fn(), + restartMcpServer: vi.fn(), + manageMcpServer: vi.fn(), + addRuntimeMcpServer: vi.fn(), + removeRuntimeMcpServer: vi.fn(), + setMcpServer: vi.fn(), + removeMcpServer: vi.fn(), + setMcpServerEnabled: vi.fn(), + }, + client: { + workspaceByCwd: vi.fn(() => workspace), + setUserMcpServerEnabled: vi.fn(), + }, + resource: { + data: undefined, + loading: false, + error: undefined, + reload: vi.fn(), + }, + workspaceClient: workspace, + }; +}); + +vi.mock('../DaemonWorkspaceProvider.js', () => ({ + useDaemonWorkspace: () => ({ + actions, + client, + workspaceCwd: '/workspace', + }), +})); +vi.mock('../../session/DaemonSessionProvider.js', () => ({ + useDaemonWorkspaceEventSignals: () => undefined, +})); +vi.mock('./useDaemonResource.js', () => ({ + useDaemonResource: () => resource, +})); +vi.mock('./useWorkspaceEventReload.js', () => ({ + useWorkspaceEventReload: () => undefined, +})); + +const { useDaemonMcp } = await import('./useDaemonMcp.js'); + +describe('useDaemonMcp', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('keeps action identities stable across renders', async () => { + let current: ReturnType | undefined; + function TestComponent() { + current = useDaemonMcp({ autoLoad: false }); + return null; + } + await act(async () => root.render(() as ReactNode)); + const first = current; + await act(async () => root.render(() as ReactNode)); + + expect(current?.initialize).toBe(first?.initialize); + expect(current?.reloadConfig).toBe(first?.reloadConfig); + expect(current?.reload).toBe(first?.reload); + expect(current?.loadTools).toBe(first?.loadTools); + }); + + it('uses own properties when selecting a config scope', async () => { + workspaceClient.mcpConfig.mockResolvedValue({ + user: { constructor: { command: 'user-server' } }, + workspace: {}, + }); + client.setUserMcpServerEnabled.mockResolvedValue({ changed: true }); + let current: ReturnType | undefined; + function TestComponent() { + current = useDaemonMcp({ + autoLoad: false, + workspaceCwd: '/workspace', + }); + return null; + } + await act(async () => root.render(() as ReactNode)); + await current?.manageServer('constructor', 'disable'); + + expect(client.setUserMcpServerEnabled).toHaveBeenCalledWith( + 'constructor', + false, + ); + expect(workspaceClient.setMcpServerEnabled).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.ts index d51361664f2..13692537ad0 100644 --- a/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.ts +++ b/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.ts @@ -4,36 +4,152 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useDaemonWorkspaceEventSignals } from '../../session/DaemonSessionProvider.js'; -import { useDaemonWorkspaceActions } from '../DaemonWorkspaceProvider.js'; +import { useDaemonWorkspace } from '../DaemonWorkspaceProvider.js'; import type { DaemonResourceOptions } from '../types.js'; import { useDaemonResource } from './useDaemonResource.js'; import { useWorkspaceEventReload } from './useWorkspaceEventReload.js'; -export function useDaemonMcp(options: DaemonResourceOptions = {}) { - const workspaceActions = useDaemonWorkspaceActions(); - const load = useCallback( - () => workspaceActions.loadMcpStatus(), - [workspaceActions], +interface DaemonMcpOptions extends DaemonResourceOptions { + workspaceCwd?: string; +} + +export function useDaemonMcp(options: DaemonMcpOptions = {}) { + const { actions, client, workspaceCwd } = useDaemonWorkspace(); + const { workspaceCwd: requestedWorkspaceCwd, ...resourceOptions } = options; + const workspaceClient = useMemo( + () => + requestedWorkspaceCwd + ? client.workspaceByCwd(requestedWorkspaceCwd) + : undefined, + [client, requestedWorkspaceCwd], ); - const result = useDaemonResource(load, options); + const load = useCallback( + () => + workspaceClient ? workspaceClient.runtimeMcp() : actions.loadMcpStatus(), + [actions, workspaceClient], + ); + const result = useDaemonResource(load, resourceOptions); const signals = useDaemonWorkspaceEventSignals(); useWorkspaceEventReload( signals?.mcpVersion, result.reload, - options.autoLoad === true || result.data !== undefined, + resourceOptions.autoLoad === true || result.data !== undefined, ); - return { - ...result, - status: result.data, - initialize: workspaceActions.initializeMcp, - reloadConfig: workspaceActions.reloadMcp, - loadTools: workspaceActions.loadMcpTools, - loadResources: workspaceActions.loadMcpResources, - restartServer: workspaceActions.restartMcpServer, - manageServer: workspaceActions.manageMcpServer, - addServer: workspaceActions.addRuntimeMcpServer, - removeServer: workspaceActions.removeRuntimeMcpServer, - }; + const api = useMemo( + () => ({ + ensureRuntime: () => + workspaceClient + ? workspaceClient.ensureRuntime() + : actions.ensureRuntime(), + runtimeStatus: () => + workspaceClient + ? workspaceClient.runtimeStatus() + : workspaceCwd + ? client.workspaceByCwd(workspaceCwd).runtimeStatus() + : Promise.reject(new Error('Workspace is unavailable.')), + loadConfig: () => + workspaceClient ? workspaceClient.mcpConfig() : actions.loadMcpConfig(), + initialize: () => + workspaceClient + ? workspaceClient.ensureRuntime().then(() => ({ accepted: true })) + : actions.initializeMcp(), + reloadConfig: () => + workspaceClient + ? workspaceClient.reloadRuntimeMcp() + : actions.reloadMcp(), + loadTools: (serverName: string) => + workspaceClient + ? workspaceClient.runtimeMcpTools(serverName) + : actions.loadMcpTools(serverName), + loadResources: (serverName: string) => + workspaceClient + ? workspaceClient.runtimeMcpResources(serverName) + : actions.loadMcpResources(serverName), + restartServer: (serverName: string) => + workspaceClient + ? workspaceClient.restartRuntimeMcpServer(serverName) + : actions.restartMcpServer(serverName), + manageServer: async ( + serverName: string, + action: Parameters[1], + ) => { + if (!workspaceClient) + return actions.manageMcpServer(serverName, action); + if (action !== 'enable' && action !== 'disable') { + return workspaceClient.manageRuntimeMcpServer(serverName, action); + } + const config = + action === 'disable' ? await workspaceClient.mcpConfig() : undefined; + const scopes = + action === 'enable' + ? (['user', 'workspace'] as const) + : [ + Object.hasOwn(config!.user, serverName) && + !Object.hasOwn(config!.workspace, serverName) + ? ('user' as const) + : ('workspace' as const), + ]; + let changed: boolean | undefined; + for (const scope of scopes) { + const result = await (scope === 'user' + ? client.setUserMcpServerEnabled(serverName, action === 'enable') + : workspaceClient.setMcpServerEnabled( + serverName, + action === 'enable', + )); + if (result.changed !== undefined) { + changed = (changed ?? false) || result.changed; + } + } + return { + serverName, + action, + ok: true as const, + ...(changed === undefined ? {} : { changed }), + }; + }, + addServer: ( + request: Parameters[0], + ) => + workspaceClient + ? Promise.reject( + new Error( + 'Runtime-only MCP servers are unavailable outside the primary workspace.', + ), + ) + : actions.addRuntimeMcpServer(request), + removeServer: (name: string) => + workspaceClient + ? Promise.reject( + new Error( + 'Runtime-only MCP servers are unavailable outside the primary workspace.', + ), + ) + : actions.removeRuntimeMcpServer(name), + setConfigServer: ( + name: string, + scope: 'user' | 'workspace', + config: Record, + ) => + scope === 'workspace' && workspaceClient + ? workspaceClient.setMcpServer(name, config) + : actions.setMcpServer(name, scope, config), + removeConfigServer: (name: string, scope: 'user' | 'workspace') => + scope === 'workspace' && workspaceClient + ? workspaceClient.removeMcpServer(name) + : actions.removeMcpServer(name, scope), + setConfigServerEnabled: ( + name: string, + scope: 'user' | 'workspace', + enabled: boolean, + ) => + scope === 'workspace' && workspaceClient + ? workspaceClient.setMcpServerEnabled(name, enabled) + : actions.setMcpServerEnabled(name, scope, enabled), + }), + [actions, client, workspaceClient, workspaceCwd], + ); + return { ...result, status: result.data, ...api }; } diff --git a/packages/web-shell/client/daemon/workspace/types.ts b/packages/web-shell/client/daemon/workspace/types.ts index 996ed8b6f86..12647a2cbe8 100644 --- a/packages/web-shell/client/daemon/workspace/types.ts +++ b/packages/web-shell/client/daemon/workspace/types.ts @@ -63,7 +63,12 @@ import type { DaemonWorkspaceFileWriteRequest, DaemonWorkspaceFileWriteResult, DaemonWorkspaceMcpStatus, + DaemonWorkspaceMcpConfigStatus, + DaemonMcpConfigMutationResult, + DaemonMcpConfigScope, + DaemonWorkspaceRuntimeStatus, DaemonWorkspaceMcpInitializeResult, + DaemonWorkspaceMcpReloadResult, DaemonWorkspaceMcpToolsStatus, DaemonWorkspaceMcpResourcesStatus, DaemonWorkspaceMemoryStatus, @@ -427,9 +432,11 @@ export interface DaemonWorkspaceActions { channelPairing: DaemonChannelPairingActions; // MCP + ensureRuntime(): Promise; + loadMcpConfig(): Promise; loadMcpStatus(): Promise; initializeMcp(): Promise; - reloadMcp(): Promise; + reloadMcp(): Promise; loadMcpTools(serverName: string): Promise; loadMcpResources( serverName: string, @@ -443,6 +450,20 @@ export interface DaemonWorkspaceActions { request: DaemonRuntimeMcpAddRequest, ): Promise; removeRuntimeMcpServer(name: string): Promise; + setMcpServer( + name: string, + scope: DaemonMcpConfigScope, + config: Record, + ): Promise; + removeMcpServer( + name: string, + scope: DaemonMcpConfigScope, + ): Promise; + setMcpServerEnabled( + name: string, + scope: DaemonMcpConfigScope, + enabled: boolean, + ): Promise; // Daemon status (read-only) loadDaemonStatus( diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index fbea0a4b496..82263a0617a 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2159,7 +2159,6 @@ const EN: Messages = { 'mcp.runtime.removeNotUpdated': 'Configuration was deleted, but the current session was not updated.', 'mcp.discovery.empty': 'No MCP servers are configured.', - 'mcp.discovery.initializing': 'Initializing MCP servers…', 'mcp.discovery.timeout': 'MCP discovery is taking longer than expected. Refresh to check again.', 'mcp.add.settingsUnavailable': 'Workspace settings are unavailable.', @@ -2225,6 +2224,8 @@ const EN: Messages = { 'mcp.status': 'Status', 'mcp.status.blocked': 'Blocked', 'mcp.status.authenticating': 'Authenticating', + 'mcp.status.authenticationFailed': 'Authentication failed', + 'mcp.status.needsAuthentication': 'Needs authentication', 'mcp.status.needsApproval': 'Needs approval', 'mcp.status.rejected': 'Rejected', 'mcp.status.connected': 'connected', @@ -2579,7 +2580,6 @@ const EN: Messages = { 'plugins.extensions': 'Extensions', 'plugins.agents': 'Agents', 'plugins.mcp': 'MCP', - 'plugins.mcpLoadFailed': 'Failed to load MCP status', 'plugins.sections': 'Plugin sections', 'plugins.skills': 'Skills', 'plugins.title': 'Plugins', @@ -5437,7 +5437,6 @@ const ZH: Messages = { 'mcp.runtime.notUpdated': '配置已保存,但未同步当前会话。', 'mcp.runtime.removeNotUpdated': '配置已删除,但未同步当前会话。', 'mcp.discovery.empty': '尚未配置 MCP 服务器。', - 'mcp.discovery.initializing': '正在初始化 MCP 服务器…', 'mcp.discovery.timeout': 'MCP 发现耗时较长,请刷新后查看状态。', 'mcp.add.settingsUnavailable': '无法读取工作区设置。', 'mcp.action.remove': '删除', @@ -5497,6 +5496,8 @@ const ZH: Messages = { 'mcp.status': '状态', 'mcp.status.blocked': '已阻止', 'mcp.status.authenticating': '认证中', + 'mcp.status.authenticationFailed': '认证失败', + 'mcp.status.needsAuthentication': '待认证', 'mcp.status.needsApproval': '待审批', 'mcp.status.rejected': '已拒绝', 'mcp.status.connected': '已连接', @@ -5827,7 +5828,6 @@ const ZH: Messages = { 'plugins.extensions': '扩展', 'plugins.agents': '智能体', 'plugins.mcp': 'MCP', - 'plugins.mcpLoadFailed': 'MCP 状态加载失败', 'plugins.sections': '插件分类', 'plugins.skills': '技能', 'plugins.title': '插件',