feat(serve): add workspace-scoped MCP management (#10679)

* 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: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-09-02 13:33:37 +00:00
1 parent ceb5b007ef
commit 38d532cdba
39 files changed
+4330 -574

No files matched your search

@@ -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.
+42 -39
View File
@@ -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;
+208 -18
View File
@@ -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<Record<string, unknown>>();
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<Record<string, unknown>>();
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,
});
+139 -94
View File
@@ -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<string>;
workspaceMcpAuthenticationTimers: Map<string, NodeJS.Timeout>;
workspaceMcpAuthenticationReleases: Map<string, () => 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<T>(
ci: ChannelInfo,
fn: () => Promise<T>,
statusRead = false,
): Promise<T> {
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<T>,
): Promise<T> {
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<ServeWorkspaceMcpStatus>(
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<ServeWorkspaceMcpStatus>(
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;
}
},
);
+7
View File
@@ -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';
}
}
+7
View File
@@ -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;
/**
+14
View File
@@ -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[];
@@ -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<void>((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',
+16 -2
View File
@@ -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) => ({
@@ -135,6 +135,7 @@ export {
WorkspaceInitPathEscapeError,
WorkspaceInitSymlinkError,
WorkspaceInitRaceError,
McpAuthenticationInProgressError,
McpServerNotFoundError,
McpServerRestartFailedError,
SessionBusyError,
@@ -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<string, unknown>;
persistSetting: (
workspace: string,
scope: SettingScope,
key: string,
value: unknown,
assertGenerationOpen?: () => void,
) => Promise<void>;
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<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (redactMcpServersSetting(value) as Record<string, unknown>)
: {};
}
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<T>(
target: McpConfigTarget,
operation: () => Promise<T>,
): Promise<T> {
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,
);
}
@@ -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<string, unknown>;
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<string, unknown>, 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,
);
}
@@ -73,7 +73,7 @@ const VALID_WRITE_SCOPES = new Set(['workspace', 'user']);
const QUALIFIED_WRITE_SCOPES = new Set(['workspace']);
const mcpServerMutationQueues = new Map<string, Promise<void>>();
interface McpServerSettingMutation {
export interface McpServerSettingMutation {
operation: 'set' | 'remove';
name: string;
}
@@ -225,7 +225,7 @@ const SCOPE_MAP: Record<string, SettingScope> = {
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<T>(
export async function withMcpServerMutationLock<T>(
workspace: string,
scope: SettingScope,
operation: () => Promise<T>,
@@ -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,
+14
View File
@@ -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'
+19
View File
@@ -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
@@ -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();
@@ -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({
@@ -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<typeof runtime.bridge>)
.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 {
@@ -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<BridgeWorkspaceRuntimeLifecycleSnapshot>,
): 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<void>((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<void>((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<void>((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<void>((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<void>((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({
@@ -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<void> = 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<ServeWorkspaceRuntimeStatus> {
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<T>(run: () => Promise<T>): Promise<T> {
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<T>(run: () => Promise<T>): Promise<T> {
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<void> {
const revision = this.mcpRevision;
return this.queueMcpWork(() => this.prepareMcpRevision(revision));
}
private queueMcpWork<T>(run: () => Promise<T>): Promise<T> {
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<void> {
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<void>((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 {
@@ -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<string, unknown>,
): Promise<DaemonMcpConfigMutationResult> {
return await this.jsonRequest<DaemonMcpConfigMutationResult>(
`/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<DaemonMcpConfigMutationResult> {
return await this.jsonRequest<DaemonMcpConfigMutationResult>(
`/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<DaemonMcpConfigMutationResult> {
return await this.jsonRequest<DaemonMcpConfigMutationResult>(
`/workspace/config/mcp/${urlEncode(name)}/${enabled ? 'enable' : 'disable'}`,
'POST /workspace/config/mcp/:server/:action',
{ method: 'POST', body: {}, mode: 'rest' },
);
}
async initializeWorkspaceMcp(): Promise<DaemonWorkspaceMcpInitializeResult> {
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<DaemonWorkspaceMcpConfigStatus> {
return this.client.workspaceJsonRequest<DaemonWorkspaceMcpConfigStatus>(
this.workspaceSelector,
'/config/mcp/servers',
'GET /workspaces/:workspace/config/mcp/servers',
{ mode: 'rest' },
);
}
setMcpServer(
name: string,
config: Record<string, unknown>,
): Promise<DaemonMcpConfigMutationResult> {
return this.client.workspaceJsonRequest<DaemonMcpConfigMutationResult>(
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<DaemonMcpConfigMutationResult> {
return this.client.workspaceJsonRequest<DaemonMcpConfigMutationResult>(
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<DaemonMcpConfigMutationResult> {
return this.client.workspaceJsonRequest<DaemonMcpConfigMutationResult>(
this.workspaceSelector,
`/config/mcp/${urlEncode(name)}/${enabled ? 'enable' : 'disable'}`,
'POST /workspaces/:workspace/config/mcp/:server/:action',
{ method: 'POST', body: {}, mode: 'rest' },
);
}
runtimeMcp(): Promise<DaemonWorkspaceMcpStatus> {
return this.client.workspaceJsonRequest<DaemonWorkspaceMcpStatus>(
this.workspaceSelector,
'/runtime/mcp',
'GET /workspaces/:workspace/runtime/mcp',
{ mode: 'rest' },
);
}
runtimeMcpTools(serverName: string): Promise<DaemonWorkspaceMcpToolsStatus> {
return this.client.workspaceJsonRequest<DaemonWorkspaceMcpToolsStatus>(
this.workspaceSelector,
`/runtime/mcp/${urlEncode(serverName)}/tools`,
'GET /workspaces/:workspace/runtime/mcp/:server/tools',
{ mode: 'rest' },
);
}
runtimeMcpResources(
serverName: string,
): Promise<DaemonWorkspaceMcpResourcesStatus> {
return this.client.workspaceJsonRequest<DaemonWorkspaceMcpResourcesStatus>(
this.workspaceSelector,
`/runtime/mcp/${urlEncode(serverName)}/resources`,
'GET /workspaces/:workspace/runtime/mcp/:server/resources',
{ mode: 'rest' },
);
}
reloadRuntimeMcp(
options: DaemonWorkspaceMcpReloadOptions = {},
): Promise<DaemonWorkspaceMcpReloadResult> {
return this.client.workspaceJsonRequest<DaemonWorkspaceMcpReloadResult>(
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<DaemonMcpRestartResult> {
return this.client.workspaceJsonRequest<DaemonMcpRestartResult>(
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<DaemonMcpManageResult> {
return this.client.workspaceJsonRequest<DaemonMcpManageResult>(
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<DaemonWorkspaceRuntimeStatus> {
return this.client.workspaceJsonRequest<DaemonWorkspaceRuntimeStatus>(
this.workspaceSelector,
@@ -680,7 +680,11 @@ export type {
DaemonWorkspaceFileWriteResult,
DaemonWorkspaceMcpServerStatus,
DaemonWorkspaceMcpStatus,
DaemonWorkspaceMcpConfigStatus,
DaemonMcpConfigMutationResult,
DaemonMcpConfigScope,
DaemonWorkspaceMcpInitializeResult,
DaemonWorkspaceMcpReloadResult,
DaemonWorkspaceMcpReloadOptions,
DaemonWorkspaceMcpToolStatus,
DaemonWorkspaceMcpToolsStatus,
@@ -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<string, unknown>;
user: Record<string, unknown>;
workspace: Record<string, unknown>;
}
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 {
+4
View File
@@ -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,
@@ -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, {
+465 -3
View File
@@ -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<HTMLButtonElement>('[data-testid="open-plugins"]')
?.click();
await Promise.resolve();
});
const panel = container.querySelector('[data-testid="inline-panel"]');
const mcpTab =
panel?.querySelectorAll<HTMLButtonElement>('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<HTMLButtonElement>('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(
<I18nProvider language="en">
<McpStatusMessage
message={{
status: {
v: 1,
initialized: true,
discoveryState: 'completed',
workspaceCwd: '/workspace',
servers,
},
toolsByServer: {},
showDescriptions: false,
showSchema: false,
showTips: false,
}}
/>
</I18nProvider>,
);
});
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<HTMLButtonElement>('[aria-label="filesystem"]')
?.click();
await Promise.resolve();
});
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[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<HTMLElement>('[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<HTMLButtonElement>('[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<HTMLElement>('[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(
<I18nProvider language="en">
<McpStatusMessage
message={{
status: {
v: 1,
workspaceCwd: '/workspace',
initialized: true,
discoveryState: 'completed',
servers: [
{
name: 'filesystem',
disabled: true,
mcpStatus: 'disconnected',
},
],
},
toolsByServer: {},
showDescriptions: false,
showSchema: false,
showTips: false,
}}
/>
</I18nProvider>,
);
});
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
-20
View File
@@ -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' ? (
<PluginManagerPage
mcpMessage={mcpDialogMessage}
loadMcpMessage={async () => {
try {
await loadMcpManagerMessage();
} catch (error) {
reportError(error, 'Failed to load MCP status');
throw error;
}
}}
onClose={closePanel}
onUseSkill={handleUseSkill}
initialFocusRef={pluginTabRef}
@@ -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<ReturnType<DaemonWorkspaceActions['loadSettingsStatus']>>,
scope: McpSettingsScope,
): Record<string, unknown> {
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<McpStatus>(message.status);
const mcp = useMcp({ autoLoad: false, workspaceCwd });
const initialMessage =
message && (!workspaceCwd || message.status.workspaceCwd === workspaceCwd)
? message
: undefined;
const [status, setStatus] = useState<McpStatus>(() =>
initialMessage
? initialMessage.status
: {
v: 1,
workspaceCwd: workspaceCwd ?? '',
initialized: false,
discoveryState: 'not_started',
servers: [],
},
);
const [toolsByServer, setToolsByServer] = useState<
Record<string, DaemonWorkspaceMcpToolsStatus>
>(message.toolsByServer);
>(initialMessage?.toolsByServer ?? {});
const [resourcesByServer, setResourcesByServer] = useState<
Record<string, DaemonWorkspaceMcpResourcesStatus>
>(message.resourcesByServer ?? {});
>(initialMessage?.resourcesByServer ?? {});
const [selectedServerName, setSelectedServerName] = useState<string | null>(
null,
);
@@ -431,9 +459,7 @@ export function McpManagerPage({
const [loadErrorsByServer, setLoadErrorsByServer] = useState<
Record<string, { tools?: string; resources?: string }>
>({});
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<boolean> => {
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<boolean> => {
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<typeof mcp.addServer>[0]['config'];
let config: Record<string, unknown>;
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<typeof mcp.addServer>[0]['config']),
...(parsed as Record<string, unknown>),
...(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 ? (
<Breadcrumb className="sticky -top-4 z-10 -mx-5 -mt-4 border-b bg-background px-5 py-3">
<BreadcrumbList className="h-8 text-sm">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<button
type="button"
disabled={busyServer !== null}
onClick={showServerList}
>
{t('mcp.title')}
</button>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
{selectedTool || selectedResource ? (
<div className="sticky -top-4 z-10 -mx-5 -mt-4 flex items-center gap-3 border-b bg-background px-5 py-3">
<Breadcrumb className="min-w-0 flex-1">
<BreadcrumbList className="h-8 text-sm">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<button type="button" onClick={showSelectedServer}>
{selectedServer.name}
<button
type="button"
disabled={busyServer !== null}
onClick={showServerList}
>
{t('mcp.title')}
</button>
</BreadcrumbLink>
) : (
<BreadcrumbPage>{selectedServer.name}</BreadcrumbPage>
)}
</BreadcrumbItem>
{selectedTool || selectedResource ? <BreadcrumbSeparator /> : null}
{selectedTool || selectedResource ? (
<BreadcrumbItem>
<BreadcrumbPage>{detailLabel}</BreadcrumbPage>
</BreadcrumbItem>
) : null}
</BreadcrumbList>
</Breadcrumb>
<BreadcrumbSeparator />
<BreadcrumbItem>
{selectedTool || selectedResource ? (
<BreadcrumbLink asChild>
<button type="button" onClick={showSelectedServer}>
{selectedServer.name}
</button>
</BreadcrumbLink>
) : (
<BreadcrumbPage>{selectedServer.name}</BreadcrumbPage>
)}
</BreadcrumbItem>
{selectedTool || selectedResource ? <BreadcrumbSeparator /> : null}
{selectedTool || selectedResource ? (
<BreadcrumbItem>
<BreadcrumbPage>{detailLabel}</BreadcrumbPage>
</BreadcrumbItem>
) : null}
</BreadcrumbList>
</Breadcrumb>
{workspaceControl}
</div>
) : null
) : (
standaloneNavigation
@@ -1663,21 +1714,7 @@ export function McpManagerPage({
</div>
</div>
{initializing && connectingCount === 0 ? (
<ManagementNotice
tone="progress"
noticeKey="mcp-initializing"
closeLabel={t('common.close')}
onDismiss={() => undefined}
>
<span className="grid gap-1">
<span className="font-medium">
{t('mcp.discovery.initializing')}
</span>
<span>{t('mcp.startingNote')}</span>
</span>
</ManagementNotice>
) : connectingCount > 0 ? (
{connectingCount > 0 ? (
<ManagementNotice
tone="progress"
noticeKey={`mcp-connecting-${connectingCount}`}
@@ -1749,7 +1786,12 @@ export function McpManagerPage({
))}
</ToggleGroup>
{filteredServers.length ? (
{initializing && connectingCount === 0 ? (
<div className="flex flex-col items-center gap-2 py-6 text-muted-foreground">
<Spinner className="size-6" />
<span className="text-sm">{t('mcp.loadingStatus')}</span>
</div>
) : filteredServers.length ? (
<div
className={styles.serverGrid}
data-column-count={Math.min(filteredServers.length, 4)}
@@ -97,6 +97,27 @@ function statusDisplay(
className: styles.warning,
};
}
if (server.authenticationState === 'pending') {
return {
icon: '🔄',
text: t('mcp.status.authenticating'),
className: styles.warning,
};
}
if (server.authenticationState === 'failed') {
return {
icon: '✗',
text: t('mcp.status.authenticationFailed'),
className: styles.error,
};
}
if (server.requiresAuth && server.authenticationState !== 'succeeded') {
return {
icon: '!',
text: t('mcp.status.needsAuthentication'),
className: styles.warning,
};
}
switch (server.mcpStatus) {
case 'connected':
return {
@@ -407,23 +428,54 @@ export function McpStatusMessage({
}, [selectedServer, selectedTools.length, t]);
const reloadSelectedServer = useCallback(async () => {
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 }),
);
@@ -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: () => <div>Extensions</div>,
}));
vi.mock('../skills/SkillsManagerPage', () => ({
SkillsManagerPage: () => <div>Skills</div>,
}));
vi.mock('../agents/AgentsManagerPage', () => ({
AgentsManagerPage: () => <div>Agents</div>,
}));
vi.mock('../mcp/McpManagerPage', () => ({
McpManagerPage: (props: {
workspaceCwd?: string;
workspaceControl?: ReactNode;
embedded?: { onDetailChange(open: boolean): void };
}) => (
<div data-testid="mcp-page" data-workspace={props.workspaceCwd}>
{props.workspaceControl}
<button
type="button"
onClick={() => props.embedded?.onDetailChange(true)}
>
Open detail
</button>
</div>
),
}));
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(
<I18nProvider language="en">
<PluginManagerPage onClose={vi.fn()} onUseSkill={vi.fn()} />
</I18nProvider>,
);
});
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<HTMLButtonElement>(
'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<HTMLElement>('[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<HTMLButtonElement>(
'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(
<I18nProvider language="en">
<PluginManagerPage onClose={vi.fn()} onUseSkill={vi.fn()} />
</I18nProvider>,
);
});
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');
});
});
@@ -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<void>;
onClose: () => void;
onUseSkill: (name: string) => void;
initialFocusRef?: Ref<HTMLButtonElement>;
}
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<PluginTab>('extensions');
const [detailOpen, setDetailOpen] = useState(false);
const [pageRevision, setPageRevision] = useState(0);
const [mcpLoaded, setMcpLoaded] = useState(false);
const [mcpLoadError, setMcpLoadError] = useState<string | null>(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 ? (
<Select
value={selectedWorkspaceCwd}
disabled={disabled}
onValueChange={(cwd) => {
setSelectedWorkspaceCwd(cwd);
setDetailOpen(false);
setPageRevision((revision) => revision + 1);
}}
>
<SelectTrigger
className="h-8 w-48"
aria-label={t('sidebar.workspaceSelectLabel')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{workspaces.map((entry) => (
<SelectItem
key={entry.id}
value={entry.cwd}
disabled={!entry.trusted}
>
{workspaceLabel(entry)}
</SelectItem>
))}
</SelectContent>
</Select>
) : null;
return (
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
{!detailOpen ? (
<div className="sticky -top-4 z-10 -mx-5 -mt-4 border-b bg-background px-5 py-3">
<div className="sticky -top-4 z-10 -mx-5 -mt-4 flex items-center justify-between gap-3 border-b bg-background px-5 py-3">
<TabsList className="h-8" aria-label={t('plugins.sections')}>
<TabsTrigger ref={initialFocusRef} value="extensions">
{t('plugins.extensions')}
@@ -84,6 +131,7 @@ export function PluginManagerPage({
<TabsTrigger value="skills">{t('plugins.skills')}</TabsTrigger>
<TabsTrigger value="agents">{t('plugins.agents')}</TabsTrigger>
</TabsList>
{workspaceSelect(false)}
</div>
) : null}
@@ -107,44 +155,14 @@ export function PluginManagerPage({
onClose={onClose}
embedded={embedded}
/>
) : mcpLoadError ? (
<Alert variant="destructive" className="mt-4">
<AlertTitle>{t('plugins.mcpLoadFailed')}</AlertTitle>
<AlertDescription className="space-y-3">
<p>{mcpLoadError}</p>
<Button variant="outline" size="sm" onClick={loadMcp}>
{t('common.retry')}
</Button>
</AlertDescription>
</Alert>
) : mcpMessage && mcpLoaded ? (
) : (
<McpManagerPage
key={`mcp-${pageRevision}`}
message={mcpMessage}
key={`mcp-${pageRevision}-${selectedWorkspaceCwd ?? ''}`}
onClose={onClose}
embedded={embedded}
workspaceCwd={selectedWorkspaceCwd}
workspaceControl={workspaceSelect(true)}
/>
) : (
<div className="flex w-full flex-col gap-6 pb-8 pt-4">
<div className="relative">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label={t('common.search')}
readOnly
className="pl-9"
placeholder={`${t('common.search')} MCP…`}
/>
</div>
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<ServerIcon />
</EmptyMedia>
<EmptyTitle>{t('mcp.empty')}</EmptyTitle>
<EmptyDescription>{t('mcp.emptyDescription')}</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
)}
</TabsContent>
</Tabs>
@@ -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');
});
});
@@ -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 () => {
@@ -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(
@@ -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<typeof useDaemonMcp> | undefined;
function TestComponent() {
current = useDaemonMcp({ autoLoad: false });
return null;
}
await act(async () => root.render((<TestComponent />) as ReactNode));
const first = current;
await act(async () => root.render((<TestComponent />) 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<typeof useDaemonMcp> | undefined;
function TestComponent() {
current = useDaemonMcp({
autoLoad: false,
workspaceCwd: '/workspace',
});
return null;
}
await act(async () => root.render((<TestComponent />) as ReactNode));
await current?.manageServer('constructor', 'disable');
expect(client.setUserMcpServerEnabled).toHaveBeenCalledWith(
'constructor',
false,
);
expect(workspaceClient.setMcpServerEnabled).not.toHaveBeenCalled();
});
});
@@ -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<typeof actions.manageMcpServer>[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<typeof actions.addRuntimeMcpServer>[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<string, unknown>,
) =>
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 };
}
@@ -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<DaemonWorkspaceRuntimeStatus>;
loadMcpConfig(): Promise<DaemonWorkspaceMcpConfigStatus>;
loadMcpStatus(): Promise<DaemonWorkspaceMcpStatus>;
initializeMcp(): Promise<DaemonWorkspaceMcpInitializeResult>;
reloadMcp(): Promise<DaemonWorkspaceMcpInitializeResult>;
reloadMcp(): Promise<DaemonWorkspaceMcpReloadResult>;
loadMcpTools(serverName: string): Promise<DaemonWorkspaceMcpToolsStatus>;
loadMcpResources(
serverName: string,
@@ -443,6 +450,20 @@ export interface DaemonWorkspaceActions {
request: DaemonRuntimeMcpAddRequest,
): Promise<DaemonRuntimeMcpAddResult>;
removeRuntimeMcpServer(name: string): Promise<DaemonRuntimeMcpRemoveResult>;
setMcpServer(
name: string,
scope: DaemonMcpConfigScope,
config: Record<string, unknown>,
): Promise<DaemonMcpConfigMutationResult>;
removeMcpServer(
name: string,
scope: DaemonMcpConfigScope,
): Promise<DaemonMcpConfigMutationResult>;
setMcpServerEnabled(
name: string,
scope: DaemonMcpConfigScope,
enabled: boolean,
): Promise<DaemonMcpConfigMutationResult>;
// Daemon status (read-only)
loadDaemonStatus(
+4 -4
View File
@@ -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': '插件',