mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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': '插件',
|
||||
|
||||
Reference in new issue
Block a user